diff --git a/.editorconfig b/.editorconfig index b054ffcdb..265801516 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,8 @@ max_line_length = 150 tab_width = 4 ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_no-trailing-spaces = disabled +ktlint_standard_blank-line-between-when-conditions = disabled [*.md] generated_code = true diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index aedb14282..56b8362de 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -2,15 +2,6 @@ name: Build APK for Release (Full & FOSS) && Desktop app release on: workflow_dispatch: - pull_request: - branches: [ main ] - paths-ignore: - - 'README.md' - - 'fastlane/**' - - 'assets/**' - - '.github/**/*.md' - - '.github/FUNDING.yml' - - '.github/ISSUE_TEMPLATE/**' push: branches: - 'main' @@ -74,7 +65,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-full-release - path: composeApp/build/outputs/apk/release/*.apk + path: androidApp/build/outputs/apk/release/*.apk build-foss-release: name: Build foss release version @@ -116,7 +107,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-foss-release - path: composeApp/build/outputs/apk/release/*.apk + path: androidApp/build/outputs/apk/release/*.apk build-desktop-deb: name: Build desktop DEB package @@ -153,13 +144,105 @@ jobs: run: ./gradlew exportLibraryDefinitions - name: Build desktop DEB package - run: ./gradlew packageDeb + run: ./gradlew packageReleaseDeb - name: Upload DEB package uses: actions/upload-artifact@v4 with: name: desktop-deb-package - path: composeApp/build/compose/binaries/main/deb/*.deb + path: composeApp/build/compose/binaries/main-release/deb/*.deb + + build-desktop-rpm: + name: Build desktop RPM package + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Install RPM build tools + run: sudo apt-get update && sudo apt-get install -y rpm + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: "zulu" + cache: 'gradle' + + - name: Update build product flavor + run: | + echo "" >> ./gradle.properties + echo "isFullBuild=true" >> ./gradle.properties + + - name: Update Sentry Secrets + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN_JVM }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + + - name: Generate aboutLibraries.json + run: ./gradlew exportLibraryDefinitions + + - name: Build desktop RPM package + run: ./gradlew packageReleaseRpm + + - name: Upload RPM package + uses: actions/upload-artifact@v4 + with: + name: desktop-rpm-package + path: composeApp/build/compose/binaries/main-release/rpm/*.rpm + + build-desktop-appimage: + name: Build desktop AppImage package + runs-on: ubuntu-22.04 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: "zulu" + cache: 'gradle' + + - name: Install libfuse2 + run: sudo apt install libfuse2 + + - name: Update build product flavor + run: | + echo "" >> ./gradle.properties + echo "isFullBuild=true" >> ./gradle.properties + + - name: Update Sentry Secrets + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN_JVM }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + + - name: Generate aboutLibraries.json + run: ./gradlew exportLibraryDefinitions + + - name: Build desktop AppImage package + run: ./gradlew composeApp:packageReleaseAppImage + + - name: Upload AppImage package + uses: actions/upload-artifact@v4 + with: + name: desktop-appimage-package + path: composeApp/build/appimage/main-release/SimpMusic-x86_64.AppImage build-desktop-dmg: name: Build desktop DMG package @@ -196,13 +279,13 @@ jobs: run: ./gradlew exportLibraryDefinitions - name: Build desktop DMG package - run: ./gradlew packageDmg + run: ./gradlew packageReleaseDmg - name: Upload DMG package uses: actions/upload-artifact@v4 with: name: desktop-dmg-package - path: composeApp/build/compose/binaries/main/dmg/*.dmg + path: composeApp/build/compose/binaries/main-release/dmg/*.dmg build-desktop-msi: name: Build desktop MSI package @@ -239,10 +322,10 @@ jobs: run: ./gradlew exportLibraryDefinitions - name: Build desktop MSI package - run: ./gradlew packageMsi + run: ./gradlew packageReleaseMsi - name: Upload MSI package uses: actions/upload-artifact@v4 with: name: desktop-msi-package - path: composeApp/build/compose/binaries/main/msi/*.msi + path: composeApp/build/compose/binaries/main-release/msi/*.msi diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 53f802bdd..9276a0c89 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -53,7 +53,7 @@ jobs: run: ./gradlew exportLibraryDefinitions - name: Build release APK - run: ./gradlew composeApp:assembleRelease + run: ./gradlew androidApp:assembleRelease - name: Check build-tools version shell: bash @@ -65,7 +65,7 @@ jobs: - uses: kevin-david/zipalign-sign-android-release@v2 id: sign_app with: - releaseDirectory: composeApp/build/outputs/apk/release + releaseDirectory: androidApp/build/outputs/apk/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} @@ -78,4 +78,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: app - path: composeApp/build/outputs/apk/release/*-signed.apk + path: androidApp/build/outputs/apk/release/*-signed.apk diff --git a/.github/workflows/desktop-appimage-package.yml b/.github/workflows/desktop-appimage-package.yml new file mode 100644 index 000000000..28320b6f7 --- /dev/null +++ b/.github/workflows/desktop-appimage-package.yml @@ -0,0 +1,64 @@ +name: Build desktop AppImage package + +on: + workflow_dispatch: + push: + branches: + - 'dev' + paths-ignore: + - 'README.md' + - 'fastlane/**' + - 'assets/**' + - '.github/**/*.md' + - '.github/FUNDING.yml' + - '.github/ISSUE_TEMPLATE/**' + +permissions: + contents: write + +jobs: + build-desktop-appimage: + name: Build desktop AppImage package + runs-on: ubuntu-22.04 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: "zulu" + cache: 'gradle' + + - name: Install libfuse2 + run: sudo apt install libfuse2 + + - name: Update build product flavor + run: | + echo "" >> ./gradle.properties + echo "isFullBuild=true" >> ./gradle.properties + + - name: Update Sentry Secrets + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN_JVM }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + + - name: Generate aboutLibraries.json + run: ./gradlew exportLibraryDefinitions + + - name: Build desktop AppImage package + run: ./gradlew composeApp:packageReleaseAppImage + + - name: Upload AppImage package + uses: actions/upload-artifact@v4 + with: + name: desktop-appimage-package + path: composeApp/build/appimage/main-release/SimpMusic-x86_64.AppImage \ No newline at end of file diff --git a/.gitignore b/.gitignore index 72122f4e8..75d4b58b9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ crowdin.yml /simpmusic.jks /ffmpeg-kit/build/ +#dev files +ign.md +.docs + # Sentry Config File sentry.properties /.claude/ @@ -28,3 +32,6 @@ sentry.properties /composeApp/cache/ /composeApp/kcef-bundle/ /.mcp.json + +/androidApp/build/ +/androidApp/cache/ diff --git a/MediaServiceCore b/MediaServiceCore index 5a4f7e1de..a22df4f68 160000 --- a/MediaServiceCore +++ b/MediaServiceCore @@ -1 +1 @@ -Subproject commit 5a4f7e1de5f36a1702d48e1c89e9acf66b22bdde +Subproject commit a22df4f682bc73a863c6f5b0847a4973aa259c5f diff --git a/README.md b/README.md index 63d506fc9..103b37637 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ I use [Sentry](http://sentry.io) crashlytics to catch all crashes in the Full ve ### Which file should I download? - For Windows: Download the file with extension `.msi`. - For macOS: Download the file with extension `.dmg`. -- For Linux: Download the file with extension `.deb`. +- For Linux: Download the file with extension `.deb` (Debian based), `.rpm` (Red-hat based), `.AppImage` (all Linux distributions) . ### Log in guide: https://www.simpmusic.org/blogs/en/how-to-log-in-on-desktop-app @@ -134,6 +134,12 @@ sometimes, some songs or videos get the wrong lyrics We're looking for more contributors, all contributions are welcome! See our [CODE OF CONDUCT](https://github.com/maxrave-dev/SimpMusic/blob/main/CODE_OF_CONDUCT.md) +Thanks for all my contributors: + + + + + ## Showcase This project is following clean architecture and MVVM pattern (in UI, app module). diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts new file mode 100644 index 000000000..f4c643370 --- /dev/null +++ b/androidApp/build.gradle.kts @@ -0,0 +1,264 @@ +import java.util.Properties + +val isFullBuild: Boolean = + try { + extra["isFullBuild"] == "true" + } catch (e: Exception) { + false + } + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.sentry.gradle) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + val abis = arrayOf("armeabi-v7a", "arm64-v8a", "x86_64") + + namespace = "com.maxrave.simpmusic" + compileSdk = 36 + + defaultConfig { + applicationId = "com.maxrave.simpmusic" + minSdk = 26 + targetSdk = 36 + versionCode = + libs.versions.version.code + .get() + .toInt() + versionName = + libs.versions.version.name + .get() + vectorDrawables.useSupportLibrary = true + multiDexEnabled = true + + @Suppress("UnstableApiUsage") + androidResources { + localeFilters += + listOf( + "en", + "vi", + "it", + "de", + "ru", + "tr", + "fi", + "pl", + "pt", + "fr", + "es", + "zh", + "in", + "ar", + "ja", + "b+zh+Hant+TW", + "uk", + "iw", + "az", + "hi", + "th", + "nl", + "ko", + "ca", + "fa", + "bg", + ) + } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters.add("x86_64") + abiFilters.add("armeabi-v7a") + abiFilters.add("arm64-v8a") + } + } + + bundle { + language { + enableSplit = false + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + splits { + abi { + isEnable = true + reset() + isUniversalApk = true + include(*abis) + } + } + } + debug { + isMinifyEnabled = false + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + // enable view binding + buildFeatures { + viewBinding = true + compose = true + buildConfig = true + } + packaging { + jniLibs.useLegacyPackaging = true + jniLibs.excludes += + listOf( + "META-INF/META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/notice.txt", + "META-INF/ASL2.0", + "META-INF/asm-license.txt", + "META-INF/notice", + "META-INF/*.kotlin_module", + ) + resources { + excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +dependencies { + coreLibraryDesugaring(libs.desugaring) + val debugImplementation = "debugImplementation" + debugImplementation(libs.ui.tooling) + implementation(libs.activity.compose) + + // Custom Activity On Crash + implementation(libs.customactivityoncrash) + + // Easy Permissions + implementation(libs.easypermissions) + + // Legacy Support + implementation(libs.legacy.support.v4) + // Coroutines + implementation(libs.coroutines.android) + + // Glance + implementation(libs.glance) + implementation(libs.glance.appwidget) + implementation(libs.glance.material3) + + implementation(projects.composeApp) + implementation(projects.data) + + if (isFullBuild) { + implementation(projects.crashlytics) + } else { + implementation(projects.crashlyticsEmpty) + } +} + +sentry { + org.set("simpmusic") + projectName.set("android") + ignoredFlavors.set(setOf("foss")) + ignoredBuildTypes.set(setOf("debug")) + autoInstallation.enabled = false + if (isFullBuild) { + val token = + try { + println("Full build detected, enabling Sentry Auth Token") + val properties = Properties() + properties.load(rootProject.file("local.properties").inputStream()) + properties.getProperty("SENTRY_AUTH_TOKEN") + } catch (e: Exception) { + println("Failed to load SENTRY_AUTH_TOKEN from local.properties: ${e.message}") + null + } + authToken.set(token ?: "") + includeProguardMapping.set(true) + autoUploadProguardMapping.set(true) + } else { + includeProguardMapping.set(false) + autoUploadProguardMapping.set(false) + uploadNativeSymbols.set(false) + includeDependenciesReport.set(false) + includeSourceContext.set(false) + includeNativeSources.set(false) + } + telemetry.set(false) +} + +if (!isFullBuild) { + abstract class CleanSentryMetaTask : DefaultTask() { + @get:InputFiles + abstract val assetDirectories: ConfigurableFileCollection + + @get:Internal + abstract val buildDirectory: DirectoryProperty + + @TaskAction + fun execute() { + assetDirectories.forEach { assetDir -> + val sentryFile = File(assetDir, "sentry-debug-meta.properties") + if (sentryFile.exists()) { + sentryFile.delete() + println("Deleted: ${sentryFile.absolutePath}") + } + } + + val dirName = "release/mergeReleaseAssets" + val injectDirName = "release/injectSentryDebugMetaPropertiesIntoAssetsRelease" + println("Cleaning Sentry meta files in build directories") + println("Build directory: ${buildDirectory.asFile.get().absolutePath}") + + val buildAssetsDir = File(buildDirectory.asFile.get(), "intermediates/assets/$dirName") + println("Checking directory buildAssetsDir: ${buildAssetsDir.absolutePath}") + val sentryFile = File(buildAssetsDir, "sentry-debug-meta.properties") + if (sentryFile.exists()) { + sentryFile.delete() + println("Deleted: ${sentryFile.absolutePath}") + } + + val injectBuildAssetsDir = File(buildDirectory.asFile.get(), "intermediates/assets/$injectDirName") + println("Checking directory injectBuildAssetsDir: ${injectBuildAssetsDir.absolutePath}") + val injectSentryFile = File(injectBuildAssetsDir, "sentry-debug-meta.properties") + if (injectSentryFile.exists()) { + injectSentryFile.delete() + println("Deleted: ${injectSentryFile.absolutePath}") + val sentryFile = File(injectBuildAssetsDir, "sentry-debug-meta.properties") + sentryFile.writeText("") + println("✓ Overwritten: ${sentryFile.absolutePath}") + } + } + } + + tasks.whenTaskAdded { + if (name.contains("injectSentryDebugMetaPropertiesIntoAssetsRelease")) { + val cleanSentryMetaTaskName = "cleanSentryMetaForRelease" + val cleanSentryMetaTask = + tasks.register(cleanSentryMetaTaskName) { + assetDirectories.from(android.sourceSets.flatMap { it.assets.srcDirs }) + buildDirectory.set(layout.buildDirectory) + } + tasks.named(name).configure { + finalizedBy(cleanSentryMetaTask) + } + } + } +} \ No newline at end of file diff --git a/composeApp/proguard-rules.pro b/androidApp/proguard-rules.pro similarity index 98% rename from composeApp/proguard-rules.pro rename to androidApp/proguard-rules.pro index f8ad0332a..2a1724491 100644 --- a/composeApp/proguard-rules.pro +++ b/androidApp/proguard-rules.pro @@ -212,6 +212,10 @@ -keep class org.simpmusic.lyrics.models.** { *; } -keep class com.simpmusic.lyrics.parser.** { *; } +-keep class com.google.re2j.** { *; } +-dontwarn com.google.re2j.Matcher +-dontwarn com.google.re2j.Pattern + -keep class * extends androidx.room.RoomDatabase { (); } -dontwarn io.sentry.android.core.SentryLogcatAdapter diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a7491d693 --- /dev/null +++ b/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/MainActivity.kt b/androidApp/src/main/java/com/maxrave/simpmusic/MainActivity.kt similarity index 84% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/MainActivity.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/MainActivity.kt index 191458222..63c0a4872 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/MainActivity.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/MainActivity.kt @@ -14,11 +14,15 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.net.toUri import androidx.core.os.LocaleListCompat +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import com.eygraber.uri.toKmpUriOrNull import com.maxrave.common.FIRST_TIME_MIGRATION import com.maxrave.common.SELECTED_LANGUAGE @@ -31,21 +35,19 @@ import com.maxrave.domain.mediaservice.handler.ToastType import com.maxrave.logger.Logger import com.maxrave.media3.di.setServiceActivitySession import com.maxrave.simpmusic.di.viewModelModule +import com.maxrave.simpmusic.service.test.notification.NotifyWork +import com.maxrave.simpmusic.utils.ComposeResUtils import com.maxrave.simpmusic.utils.VersionManager import com.maxrave.simpmusic.viewModel.SharedViewModel import kotlinx.coroutines.runBlocking -import org.jetbrains.compose.resources.getString import org.koin.android.ext.android.inject import org.koin.core.context.loadKoinModules import org.koin.core.context.unloadKoinModules import org.koin.dsl.module import org.simpmusic.crashlytics.pushPlayerError import pub.devrel.easypermissions.EasyPermissions -import simpmusic.composeapp.generated.resources.Res -import simpmusic.composeapp.generated.resources.explicit_content_blocked -import simpmusic.composeapp.generated.resources.this_app_needs_to_access_your_notification -import simpmusic.composeapp.generated.resources.time_out_check_internet_connection_or_change_piped_instance_in_settings import java.util.Locale +import java.util.concurrent.TimeUnit @Suppress("DEPRECATION") class MainActivity : AppCompatActivity() { @@ -92,18 +94,17 @@ class MainActivity : AppCompatActivity() { action = intent.action, data = (intent.data ?: intent.getStringExtra(Intent.EXTRA_TEXT)?.toUri())?.toKmpUriOrNull(), type = intent.type, - ) + ), ) } - @ExperimentalMaterial3Api @ExperimentalFoundationApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadKoinModules( module { - single { this@MainActivity } - } + single { this@MainActivity } + }, ) // Recreate view model to fix the issue of view model not getting data from the service unloadKoinModules(viewModelModule) @@ -118,12 +119,12 @@ class MainActivity : AppCompatActivity() { Logger.d("MainActivity", "onCreate: ") val data = (intent?.data ?: intent?.getStringExtra(Intent.EXTRA_TEXT)?.toUri())?.toKmpUriOrNull() if (data != null) { - viewModel.setIntent( + viewModel.setIntent( GenericIntent( action = intent.action, data = data, type = intent.type, - ) + ), ) } Logger.d("Italy", "Key: ${Locale.ITALY.toLanguageTag()}") @@ -182,13 +183,28 @@ class MainActivity : AppCompatActivity() { ), ) viewModel.checkIsRestoring() - viewModel.runWorker() + val request = + PeriodicWorkRequestBuilder( + 12L, + TimeUnit.HOURS, + ).addTag("Worker Test") + .setConstraints( + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ).build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "Artist Worker", + ExistingPeriodicWorkPolicy.KEEP, + request, + ) if (!EasyPermissions.hasPermissions(this, Manifest.permission.POST_NOTIFICATIONS)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { EasyPermissions.requestPermissions( this, - runBlocking { getString(Res.string.this_app_needs_to_access_your_notification) }, + runBlocking { ComposeResUtils.getResString(ComposeResUtils.StringType.NOTIFICATION_REQUEST) }, 1, Manifest.permission.POST_NOTIFICATIONS, ) @@ -229,10 +245,14 @@ class MainActivity : AppCompatActivity() { mediaPlayerHandler.showToast = { type -> viewModel.makeToast( when (type) { - ToastType.ExplicitContent -> runBlocking { getString(Res.string.explicit_content_blocked) } - is ToastType.PlayerError -> - runBlocking { getString(Res.string.time_out_check_internet_connection_or_change_piped_instance_in_settings, type.error) } - } + is ToastType.ExplicitContent -> { + runBlocking { ComposeResUtils.getResString(ComposeResUtils.StringType.EXPLICIT_CONTENT_BLOCKED) } + } + + is ToastType.PlayerError -> { + runBlocking { ComposeResUtils.getResString(ComposeResUtils.StringType.TIME_OUT_ERROR) } + } + }, ) } viewModel.isServiceRunning = true diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/SimpMusicApplication.kt b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt similarity index 82% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/SimpMusicApplication.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt index a2e76b11b..6e73ae3e6 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/SimpMusicApplication.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt @@ -16,14 +16,21 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.crossfade import com.maxrave.data.di.loader.loadAllModules +import com.maxrave.domain.manager.DataStoreManager import com.maxrave.logger.Logger import com.maxrave.simpmusic.di.viewModelModule +import com.maxrave.simpmusic.service.backup.AutoBackupScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import multiplatform.network.cmptoast.AppContext import okhttp3.OkHttpClient import okio.FileSystem import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.logger.Level @@ -34,6 +41,11 @@ class SimpMusicApplication : Application(), KoinComponent, SingletonImageLoader.Factory { + + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val dataStoreManager: DataStoreManager by inject() + private lateinit var autoBackupScheduler: AutoBackupScheduler + override fun onCreate() { super.onCreate() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) @@ -54,6 +66,12 @@ class SimpMusicApplication : // initialize WorkManager WorkManager.initialize(this, workConfig) + // Initialize and start AutoBackupScheduler + autoBackupScheduler = AutoBackupScheduler(this, dataStoreManager) + applicationScope.launch { + autoBackupScheduler.observeAndSchedule() + } + CaocConfig.Builder .create() .backgroundMode(CaocConfig.BACKGROUND_MODE_SILENT) // default: CaocConfig.BACKGROUND_MODE_SHOW_CUSTOM @@ -95,8 +113,7 @@ class SimpMusicApplication : }, ), ) - } - .diskCachePolicy(CachePolicy.ENABLED) + }.diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .diskCache( DiskCache diff --git a/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupScheduler.kt b/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupScheduler.kt new file mode 100644 index 000000000..749807fe0 --- /dev/null +++ b/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupScheduler.kt @@ -0,0 +1,84 @@ +package com.maxrave.simpmusic.service.backup + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.maxrave.domain.manager.DataStoreManager +import com.maxrave.logger.Logger +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import java.util.concurrent.TimeUnit + +class AutoBackupScheduler( + private val context: Context, + private val dataStoreManager: DataStoreManager, +) { + private val workManager = WorkManager.getInstance(context) + + /** + * Observe DataStore preferences and schedule/cancel WorkManager accordingly. + * This should be called in a coroutine scope that lives as long as the application. + */ + suspend fun observeAndSchedule() { + combine( + dataStoreManager.autoBackupEnabled, + dataStoreManager.autoBackupFrequency, + ) { enabled, frequency -> + Pair(enabled, frequency) + }.distinctUntilChanged().collect { (enabled, frequency) -> + Logger.i(TAG, "Auto backup settings changed: enabled=$enabled, frequency=$frequency") + + if (enabled == DataStoreManager.TRUE) { + scheduleBackup(frequency) + } else { + cancelBackup() + } + } + } + + private fun scheduleBackup(frequency: String) { + val (intervalValue, intervalUnit) = when (frequency) { + DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY -> 24L to TimeUnit.HOURS + DataStoreManager.AUTO_BACKUP_FREQUENCY_WEEKLY -> 7L to TimeUnit.DAYS + DataStoreManager.AUTO_BACKUP_FREQUENCY_MONTHLY -> 30L to TimeUnit.DAYS + else -> 24L to TimeUnit.HOURS + } + + Logger.i(TAG, "Scheduling auto backup: interval=$intervalValue $intervalUnit") + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val request = PeriodicWorkRequestBuilder( + intervalValue, + intervalUnit + ) + .setConstraints(constraints) + .addTag(WORK_TAG) + .build() + + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + + Logger.i(TAG, "Auto backup scheduled successfully") + } + + private fun cancelBackup() { + Logger.i(TAG, "Cancelling auto backup") + workManager.cancelUniqueWork(WORK_NAME) + } + + companion object { + private const val TAG = "AutoBackupScheduler" + const val WORK_NAME = "AutoBackupWorker" + const val WORK_TAG = "auto_backup" + } +} diff --git a/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupWorker.kt b/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupWorker.kt new file mode 100644 index 000000000..375ca9dfd --- /dev/null +++ b/androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupWorker.kt @@ -0,0 +1,248 @@ +package com.maxrave.simpmusic.service.backup + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.maxrave.common.DB_NAME +import com.maxrave.common.DOWNLOAD_EXOPLAYER_FOLDER +import com.maxrave.common.EXOPLAYER_DB_NAME +import com.maxrave.common.SETTINGS_FILENAME +import com.maxrave.domain.manager.DataStoreManager +import com.maxrave.domain.repository.CommonRepository +import com.maxrave.logger.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class AutoBackupWorker( + private val context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params), + KoinComponent { + + private val commonRepository: CommonRepository by inject() + private val dataStoreManager: DataStoreManager by inject() + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + Logger.i(TAG, "Starting auto backup...") + + // Check if auto backup is still enabled + val enabled = dataStoreManager.autoBackupEnabled.first() + if (enabled != DataStoreManager.TRUE) { + Logger.i(TAG, "Auto backup is disabled, skipping...") + return@withContext Result.success() + } + + // Get backup settings + val backupDownloaded = dataStoreManager.backupDownloaded.first() == DataStoreManager.TRUE + val maxFiles = dataStoreManager.autoBackupMaxFiles.first() + + // Create temp backup file + val tempBackupFile = createBackupFile(backupDownloaded) + + // Save to Downloads/SimpMusic folder + val success = saveToDownloads(tempBackupFile) + + // Delete temp file + tempBackupFile.delete() + + if (success) { + // Cleanup old backups + cleanupOldBackups(maxFiles) + + // Update last backup time + dataStoreManager.setAutoBackupLastTime(System.currentTimeMillis()) + + Logger.i(TAG, "Auto backup completed successfully") + Result.success() + } else { + Logger.e(TAG, "Failed to save backup to Downloads") + Result.retry() + } + } catch (e: Exception) { + Logger.e(TAG, "Auto backup failed: ${e.message}") + e.printStackTrace() + Result.retry() + } + } + + private suspend fun createBackupFile(backupDownloaded: Boolean): File { + val tempFile = File(context.cacheDir, "temp_backup.zip") + + FileOutputStream(tempFile).buffered().use { bufferedOutput -> + ZipOutputStream(bufferedOutput).use { zipOutputStream -> + // Backup DataStore preferences + val dataStoreFile = File(context.filesDir, "datastore/$SETTINGS_FILENAME.preferences_pb") + if (dataStoreFile.exists()) { + zipOutputStream.putNextEntry(ZipEntry("$SETTINGS_FILENAME.preferences_pb")) + dataStoreFile.inputStream().buffered().use { inputStream -> + inputStream.copyTo(zipOutputStream) + } + zipOutputStream.closeEntry() + } + + // Checkpoint and backup database + commonRepository.databaseDaoCheckpoint() + val dbPath = commonRepository.getDatabasePath() + FileInputStream(dbPath).use { inputStream -> + zipOutputStream.putNextEntry(ZipEntry(DB_NAME)) + inputStream.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + + // Backup downloaded data if enabled + if (backupDownloaded) { + // Backup ExoPlayer database + val exoPlayerDb = context.getDatabasePath(EXOPLAYER_DB_NAME) + if (exoPlayerDb.exists()) { + zipOutputStream.putNextEntry(ZipEntry(EXOPLAYER_DB_NAME)) + exoPlayerDb.inputStream().buffered().use { inputStream -> + inputStream.copyTo(zipOutputStream) + } + zipOutputStream.closeEntry() + } + + // Backup download folder + val downloadFolder = File(context.filesDir, DOWNLOAD_EXOPLAYER_FOLDER) + if (downloadFolder.exists() && downloadFolder.isDirectory) { + backupFolder(downloadFolder, DOWNLOAD_EXOPLAYER_FOLDER, zipOutputStream) + } + } + } + } + + return tempFile + } + + private fun backupFolder( + folder: File, + baseName: String, + zipOutputStream: ZipOutputStream, + ) { + if (!folder.exists() || !folder.isDirectory) return + + folder.listFiles()?.forEach { file -> + if (file.isFile) { + val entryName = "$baseName/${file.name}" + zipOutputStream.putNextEntry(ZipEntry(entryName)) + file.inputStream().buffered().use { inputStream -> + inputStream.copyTo(zipOutputStream) + } + zipOutputStream.closeEntry() + } else if (file.isDirectory) { + backupFolder(file, "$baseName/${file.name}", zipOutputStream) + } + } + } + + private fun saveToDownloads(backupFile: File): Boolean { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "simpmusic_backup_$timestamp.zip" + + return try { + val contentValues = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "application/zip") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Downloads.RELATIVE_PATH, "Download/SimpMusic") + } + } + + val uri = context.contentResolver.insert( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + contentValues + ) + + uri?.let { outputUri -> + context.contentResolver.openOutputStream(outputUri)?.use { output -> + backupFile.inputStream().use { input -> + input.copyTo(output) + } + } + Logger.i(TAG, "Backup saved to Downloads/SimpMusic/$fileName") + true + } ?: false + } catch (e: Exception) { + Logger.e(TAG, "Error saving to Downloads: ${e.message}") + false + } + } + + private fun cleanupOldBackups(maxFiles: Int) { + try { + val projection = arrayOf( + MediaStore.Downloads._ID, + MediaStore.Downloads.DISPLAY_NAME, + MediaStore.Downloads.DATE_ADDED + ) + + val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.Downloads.RELATIVE_PATH} = ? AND ${MediaStore.Downloads.DISPLAY_NAME} LIKE ?" + } else { + "${MediaStore.Downloads.DISPLAY_NAME} LIKE ?" + } + + val selectionArgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + arrayOf("Download/SimpMusic/", "simpmusic_backup_%.zip") + } else { + arrayOf("simpmusic_backup_%.zip") + } + + val sortOrder = "${MediaStore.Downloads.DATE_ADDED} DESC" + + context.contentResolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads._ID) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Downloads.DISPLAY_NAME) + + val backupFiles = mutableListOf>() + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + if (name.startsWith("simpmusic_backup_") && name.endsWith(".zip")) { + backupFiles.add(id to name) + } + } + + // Delete old files if exceeding maxFiles + if (backupFiles.size > maxFiles) { + val filesToDelete = backupFiles.drop(maxFiles) + filesToDelete.forEach { (id, name) -> + val deleteUri = android.content.ContentUris.withAppendedId( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + id + ) + context.contentResolver.delete(deleteUri, null, null) + Logger.i(TAG, "Deleted old backup: $name") + } + } + } + } catch (e: Exception) { + Logger.e(TAG, "Error cleaning up old backups: ${e.message}") + } + } + + companion object { + private const val TAG = "AutoBackupWorker" + } +} diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt b/androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt similarity index 89% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt index ec84822b7..bcd796909 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt @@ -20,11 +20,8 @@ import coil3.request.allowHardware import coil3.toBitmap import com.maxrave.simpmusic.MainActivity import com.maxrave.simpmusic.R +import com.maxrave.simpmusic.utils.ComposeResUtils import kotlinx.coroutines.runBlocking -import org.jetbrains.compose.resources.getString -import simpmusic.composeapp.generated.resources.Res -import simpmusic.composeapp.generated.resources.new_albums -import simpmusic.composeapp.generated.resources.new_singles object NotificationHandler { private const val CHANNEL_ID = "transactions_reminder_channel" @@ -66,11 +63,15 @@ object NotificationHandler { .build() return@runBlocking when (val result = loader.execute(request)) { - is SuccessResult -> result.image.toBitmap() - else -> + is SuccessResult -> { + result.image.toBitmap() + } + + else -> { AppCompatResources .getDrawable(context, R.drawable.holder) ?.toBitmap(128, 128) + } } } val builder = @@ -80,9 +81,9 @@ object NotificationHandler { .setContentTitle(noti.name) .setContentText( if (noti.single.isNotEmpty()) { - "${getString(Res.string.new_singles)}: ${noti.single.joinToString { it.title }}" + "${ComposeResUtils.getResString(ComposeResUtils.StringType.NEW_SINGLES)}: ${noti.single.joinToString { it.title }}" } else { - "${getString(Res.string.new_albums)}: ${noti.album.joinToString { it.title }}" + "${ComposeResUtils.getResString(ComposeResUtils.StringType.NEW_ALBUMS)}: ${noti.album.joinToString { it.title }}" }, ).setPriority(NotificationCompat.PRIORITY_HIGH) .setLargeIcon(bitmap) diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/service/test/notification/NotifyWork.kt b/androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotifyWork.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/service/test/notification/NotifyWork.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotifyWork.kt diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/widget/AppWidgetReceiver.kt b/androidApp/src/main/java/com/maxrave/simpmusic/ui/widget/AppWidgetReceiver.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/widget/AppWidgetReceiver.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/ui/widget/AppWidgetReceiver.kt diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt b/androidApp/src/main/java/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt similarity index 98% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt index 543d78838..7fda6c00d 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt @@ -175,12 +175,15 @@ class MainAppWidget : val result = ImageLoader(context).execute(request) randomImage = when (result) { - is SuccessResult -> + is SuccessResult -> { result.image.toBitmap().also { bitmap = it } + } - else -> null + else -> { + null + } } } diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/composeApp/src/androidMain/res/drawable/app_icon.png b/androidApp/src/main/res/drawable/app_icon.png similarity index 100% rename from composeApp/src/androidMain/res/drawable/app_icon.png rename to androidApp/src/main/res/drawable/app_icon.png diff --git a/composeApp/src/androidMain/res/drawable/baseline_pause_circle_24.xml b/androidApp/src/main/res/drawable/baseline_pause_circle_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_pause_circle_24.xml rename to androidApp/src/main/res/drawable/baseline_pause_circle_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_play_circle_24.xml b/androidApp/src/main/res/drawable/baseline_play_circle_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_play_circle_24.xml rename to androidApp/src/main/res/drawable/baseline_play_circle_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_repeat_24.xml b/androidApp/src/main/res/drawable/baseline_repeat_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_repeat_24.xml rename to androidApp/src/main/res/drawable/baseline_repeat_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_repeat_24_enable.xml b/androidApp/src/main/res/drawable/baseline_repeat_24_enable.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_repeat_24_enable.xml rename to androidApp/src/main/res/drawable/baseline_repeat_24_enable.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_repeat_one_24.xml b/androidApp/src/main/res/drawable/baseline_repeat_one_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_repeat_one_24.xml rename to androidApp/src/main/res/drawable/baseline_repeat_one_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_shuffle_24.xml b/androidApp/src/main/res/drawable/baseline_shuffle_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_shuffle_24.xml rename to androidApp/src/main/res/drawable/baseline_shuffle_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_skip_next_24.xml b/androidApp/src/main/res/drawable/baseline_skip_next_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_skip_next_24.xml rename to androidApp/src/main/res/drawable/baseline_skip_next_24.xml diff --git a/composeApp/src/androidMain/res/drawable/baseline_skip_previous_24.xml b/androidApp/src/main/res/drawable/baseline_skip_previous_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/baseline_skip_previous_24.xml rename to androidApp/src/main/res/drawable/baseline_skip_previous_24.xml diff --git a/composeApp/src/androidMain/res/drawable/holder.png b/androidApp/src/main/res/drawable/holder.png similarity index 100% rename from composeApp/src/androidMain/res/drawable/holder.png rename to androidApp/src/main/res/drawable/holder.png diff --git a/composeApp/src/androidMain/res/drawable/holder_video.png b/androidApp/src/main/res/drawable/holder_video.png similarity index 100% rename from composeApp/src/androidMain/res/drawable/holder_video.png rename to androidApp/src/main/res/drawable/holder_video.png diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/ic_launcher_background.xml rename to androidApp/src/main/res/drawable/ic_launcher_background.xml diff --git a/composeApp/src/androidMain/res/drawable/mono.xml b/androidApp/src/main/res/drawable/mono.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/mono.xml rename to androidApp/src/main/res/drawable/mono.xml diff --git a/composeApp/src/androidMain/res/drawable/monochrome.xml b/androidApp/src/main/res/drawable/monochrome.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/monochrome.xml rename to androidApp/src/main/res/drawable/monochrome.xml diff --git a/composeApp/src/androidMain/res/drawable/preview_widget.png b/androidApp/src/main/res/drawable/preview_widget.png similarity index 100% rename from composeApp/src/androidMain/res/drawable/preview_widget.png rename to androidApp/src/main/res/drawable/preview_widget.png diff --git a/composeApp/src/androidMain/res/drawable/round_home_24.xml b/androidApp/src/main/res/drawable/round_home_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/round_home_24.xml rename to androidApp/src/main/res/drawable/round_home_24.xml diff --git a/composeApp/src/androidMain/res/drawable/round_library_music_24.xml b/androidApp/src/main/res/drawable/round_library_music_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/round_library_music_24.xml rename to androidApp/src/main/res/drawable/round_library_music_24.xml diff --git a/composeApp/src/androidMain/res/drawable/round_search_24.xml b/androidApp/src/main/res/drawable/round_search_24.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/round_search_24.xml rename to androidApp/src/main/res/drawable/round_search_24.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/composeApp/src/androidMain/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml similarity index 100% rename from composeApp/src/androidMain/res/values/strings.xml rename to androidApp/src/main/res/values/strings.xml diff --git a/composeApp/src/androidMain/res/xml/allowed_media_browser_callers.xml b/androidApp/src/main/res/xml/allowed_media_browser_callers.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/allowed_media_browser_callers.xml rename to androidApp/src/main/res/xml/allowed_media_browser_callers.xml diff --git a/composeApp/src/androidMain/res/xml/automotive_app_desc.xml b/androidApp/src/main/res/xml/automotive_app_desc.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/automotive_app_desc.xml rename to androidApp/src/main/res/xml/automotive_app_desc.xml diff --git a/composeApp/src/androidMain/res/xml/backup_rules.xml b/androidApp/src/main/res/xml/backup_rules.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/backup_rules.xml rename to androidApp/src/main/res/xml/backup_rules.xml diff --git a/composeApp/src/androidMain/res/xml/data_extraction_rules.xml b/androidApp/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/data_extraction_rules.xml rename to androidApp/src/main/res/xml/data_extraction_rules.xml diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/androidApp/src/main/res/xml/locale_config.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/locale_config.xml rename to androidApp/src/main/res/xml/locale_config.xml diff --git a/composeApp/src/androidMain/res/xml/network_security_config.xml b/androidApp/src/main/res/xml/network_security_config.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/network_security_config.xml rename to androidApp/src/main/res/xml/network_security_config.xml diff --git a/composeApp/src/androidMain/res/xml/new_app_widget_info.xml b/androidApp/src/main/res/xml/new_app_widget_info.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/new_app_widget_info.xml rename to androidApp/src/main/res/xml/new_app_widget_info.xml diff --git a/composeApp/src/androidMain/res/xml/provider_paths.xml b/androidApp/src/main/res/xml/provider_paths.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/provider_paths.xml rename to androidApp/src/main/res/xml/provider_paths.xml diff --git a/composeApp/src/androidMain/res/xml/shortcuts.xml b/androidApp/src/main/res/xml/shortcuts.xml similarity index 100% rename from composeApp/src/androidMain/res/xml/shortcuts.xml rename to androidApp/src/main/res/xml/shortcuts.xml diff --git a/build_and_sign_apk.sh b/build_and_sign_apk.sh index e629c184a..b66172d2f 100755 --- a/build_and_sign_apk.sh +++ b/build_and_sign_apk.sh @@ -61,8 +61,8 @@ while [[ "$#" -gt 0 ]]; do done # Set derived variables based on selected options -APK_OUTPUT_DIR="./composeApp/build/outputs/apk/$BUILD_TYPE" -SIGNED_APK_OUTPUT_DIR="./composeApp/build/outputs/apk/$BUILD_TYPE" +APK_OUTPUT_DIR="./androidApp/build/outputs/apk/$BUILD_TYPE" +SIGNED_APK_OUTPUT_DIR="./androidApp/build/outputs/apk/$BUILD_TYPE" # Android build-tools path BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | sort | tail -n 1)" @@ -86,7 +86,7 @@ echo "Project cleaned successfully." # Step 2: Build the APK echo "[Step 2] Building APK..." -./gradlew composeApp:assemble"$BUILD_TYPE" +./gradlew androidApp:assemble"$BUILD_TYPE" echo "APK built successfully." # Step 3: Locate the built APKs @@ -102,7 +102,7 @@ for APK_PATH in $APK_PATHS; do ALIGNED_APK_PATH="$SIGNED_APK_OUTPUT_DIR/aligned-$(basename "${APK_PATH/-unsigned/}")" RELEASE_NAME=$(basename "${APK_PATH/-unsigned/}") RELEASE_NAME="${RELEASE_NAME/app-/}" - RELEASE_NAME="${RELEASE_NAME/composeApp-/}" + RELEASE_NAME="${RELEASE_NAME/androidApp-/}" SIGNED_APK_PATH="$SIGNED_APK_OUTPUT_DIR/SimpMusic-$BUILD_VARIANT-$(basename "$RELEASE_NAME")" echo "[Step 4] Aligning the APK: $APK_PATH..." diff --git a/composeApp/appimage/AppRun b/composeApp/appimage/AppRun new file mode 100644 index 000000000..10d2ea559 --- /dev/null +++ b/composeApp/appimage/AppRun @@ -0,0 +1,10 @@ +#!/bin/sh + +SELF=$(readlink -f "$0") +HERE=${SELF%/*} +EXEC=$(grep -e '^Exec=.*' "${HERE}"/*.desktop | head -n 1 | cut -d "=" -f 2 | cut -d " " -f 1) + +cd $HERE + +echo "Running SimpMusic..." +exec "${EXEC}" "$@" \ No newline at end of file diff --git a/composeApp/appimage/simpmusic.desktop b/composeApp/appimage/simpmusic.desktop new file mode 100644 index 000000000..cde596884 --- /dev/null +++ b/composeApp/appimage/simpmusic.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=SimpMusic +Exec=bin/SimpMusic +Icon=simpmusic +Terminal=false +Categories=Audio;AudioVideo; diff --git a/composeApp/appimage/simpmusic.png b/composeApp/appimage/simpmusic.png new file mode 100644 index 000000000..71a4ee2dd Binary files /dev/null and b/composeApp/appimage/simpmusic.png differ diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5f6cbd0ea..4bcc5477f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -2,9 +2,12 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.INT import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING +import org.apache.commons.io.FileUtils import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI import java.util.Properties val isFullBuild: Boolean = @@ -16,23 +19,15 @@ val isFullBuild: Boolean = plugins { alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.android.application) - alias(libs.plugins.sentry.gradle) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.compose.compiler) -// alias(libs.plugins.compose.hotReload) alias(libs.plugins.aboutlibraries.multiplatform) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.build.config) alias(libs.plugins.osdetector) } -dependencies { - coreLibraryDesugaring(libs.desugaring) - val debugImplementation = "debugImplementation" - debugImplementation(libs.ui.tooling) -} - kotlin { compilerOptions { freeCompilerArgs.add("-Xwhen-guards") @@ -40,7 +35,14 @@ kotlin { freeCompilerArgs.add("-Xmulti-dollar-interpolation") freeCompilerArgs.add("-Xexpect-actual-classes") } - androidTarget { + android { + namespace = "com.maxrave.simpmusic.composeapp" + compileSdk = 36 + minSdk = 26 + withJava() + androidResources { + enable = true + } compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -64,47 +66,26 @@ kotlin { val koinBom = project.dependencies.platform(libs.koin.bom) implementation(composeBom) implementation(koinBom) + + implementation("commons-io:commons-io:2.5") } androidMain.dependencies { - implementation(libs.koin.android) + api(libs.koin.android) implementation(libs.koin.androidx.compose) implementation(libs.jetbrains.ui.tooling.preview) - implementation(libs.activity.compose) implementation(libs.constraintlayout.compose) - implementation(libs.work.runtime.ktx) + api(libs.work.runtime.ktx) // Runtime - implementation(libs.startup.runtime) - - // Glance - implementation(libs.glance) - implementation(libs.glance.appwidget) - implementation(libs.glance.material3) + api(libs.startup.runtime) // Liquid glass implementation(libs.liquid.glass) - // Custom Activity On Crash - implementation(libs.customactivityoncrash) - - // Easy Permissions - implementation(libs.easypermissions) - - // Legacy Support - implementation(libs.legacy.support.v4) - // Coroutines - implementation(libs.coroutines.android) - - implementation(projects.media3) - implementation(projects.media3Ui) - - if (isFullBuild) { - implementation(projects.crashlytics) - } else { - implementation(projects.crashlyticsEmpty) - } + api(projects.media3) + api(projects.media3Ui) } commonMain.dependencies { implementation(libs.runtime) @@ -125,8 +106,8 @@ kotlin { implementation(libs.ui.tooling.preview) // Other module - implementation(projects.common) - implementation(projects.domain) + api(projects.common) + api(projects.domain) implementation(projects.data) // Navigation Compose @@ -136,9 +117,9 @@ kotlin { implementation(libs.kotlinx.serialization.json) // Coil - implementation(libs.coil.compose) - implementation(libs.coil.network.okhttp) - implementation(libs.kmpalette.core) + api(libs.coil.compose) + api(libs.coil.network.okhttp) + api(libs.kmpalette.core) // DataStore implementation(libs.datastore.preferences) @@ -168,7 +149,7 @@ kotlin { implementation(libs.haze) implementation(libs.haze.material) - implementation(libs.cmptoast) + api(libs.cmptoast) implementation(libs.file.picker) } commonTest.dependencies { @@ -183,136 +164,22 @@ kotlin { } } -android { - val abis = arrayOf("armeabi-v7a", "arm64-v8a", "x86_64") - - namespace = "com.maxrave.simpmusic" - compileSdk = 36 - - defaultConfig { - applicationId = "com.maxrave.simpmusic" - minSdk = 26 - targetSdk = 36 - versionCode = - libs.versions.version.code - .get() - .toInt() - versionName = - libs.versions.version.name - .get() - vectorDrawables.useSupportLibrary = true - multiDexEnabled = true - - @Suppress("UnstableApiUsage") - androidResources { - localeFilters += - listOf( - "en", - "vi", - "it", - "de", - "ru", - "tr", - "fi", - "pl", - "pt", - "fr", - "es", - "zh", - "in", - "ar", - "ja", - "b+zh+Hant+TW", - "uk", - "iw", - "az", - "hi", - "th", - "nl", - "ko", - "ca", - "fa", - "bg", - ) - } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - ndk { - abiFilters.add("x86_64") - abiFilters.add("armeabi-v7a") - abiFilters.add("arm64-v8a") - } - } - - bundle { - language { - enableSplit = false - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "consumer-rules.pro", - "proguard-rules.pro", - ) - splits { - abi { - isEnable = true - reset() - isUniversalApk = true - include(*abis) - } - } - } - debug { - isMinifyEnabled = false - applicationIdSuffix = ".dev" - versionNameSuffix = "-dev" - } - } - compileOptions { - isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - // enable view binding - buildFeatures { - viewBinding = true - compose = true - buildConfig = true - } - packaging { - jniLibs.useLegacyPackaging = true - jniLibs.excludes += - listOf( - "META-INF/META-INF/DEPENDENCIES", - "META-INF/LICENSE", - "META-INF/LICENSE.txt", - "META-INF/license.txt", - "META-INF/NOTICE", - "META-INF/NOTICE.txt", - "META-INF/notice.txt", - "META-INF/ASL2.0", - "META-INF/asm-license.txt", - "META-INF/notice", - "META-INF/*.kotlin_module", - ) - resources { - excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" - } - } -} - compose.desktop { application { mainClass = "com.maxrave.simpmusic.MainKt" nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) + val listTarget = mutableListOf() + if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) { + listTarget.addAll( + listOf(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) + ) + } else { + listTarget.addAll( + listOf(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) + ) + } + targetFormats(*listTarget.toTypedArray()) modules("jdk.unsupported") packageName = "SimpMusic" macOS { @@ -362,6 +229,7 @@ compose.desktop { buildkonfig { packageName = "com.maxrave.simpmusic" + exposeObjectWithName = "BuildKonfig" defaultConfigs { val versionName = libs.versions.version.name @@ -408,105 +276,137 @@ aboutLibraries { } } -sentry { - org.set("simpmusic") - projectName.set("android") - ignoredFlavors.set(setOf("foss")) - ignoredBuildTypes.set(setOf("debug")) - autoInstallation.enabled = false - if (isFullBuild) { - val token = - try { - println("Full build detected, enabling Sentry Auth Token") - val properties = Properties() - properties.load(rootProject.file("local.properties").inputStream()) - properties.getProperty("SENTRY_AUTH_TOKEN") - } catch (e: Exception) { - println("Failed to load SENTRY_AUTH_TOKEN from local.properties: ${e.message}") - null - } - authToken.set(token ?: "") - includeProguardMapping.set(true) - autoUploadProguardMapping.set(true) - } else { - includeProguardMapping.set(false) - autoUploadProguardMapping.set(false) - uploadNativeSymbols.set(false) - includeDependenciesReport.set(false) - includeSourceContext.set(false) - includeNativeSources.set(false) +afterEvaluate { + tasks.withType { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + + if (System.getProperty("os.name").contains("Mac")) { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + } } - telemetry.set(false) -} -if (!isFullBuild) { - abstract class CleanSentryMetaTask : DefaultTask() { - @get:InputFiles - abstract val assetDirectories: ConfigurableFileCollection - - @get:Internal - abstract val buildDirectory: DirectoryProperty - - @TaskAction - fun execute() { - assetDirectories.forEach { assetDir -> - val sentryFile = File(assetDir, "sentry-debug-meta.properties") - if (sentryFile.exists()) { - sentryFile.delete() - println("Deleted: ${sentryFile.absolutePath}") - } + fun packAppImage(isRelease: Boolean) { + val appName = "SimpMusic" + val appDirSrc = project.file("appimage") + val packageOutput = + if (isRelease) { + layout.buildDirectory + .dir("compose/binaries/main-release/app/$appName") + .get() + .asFile + } else { + layout.buildDirectory + .dir("compose/binaries/main/app/$appName") + .get() + .asFile } + if (!appDirSrc.exists() || !packageOutput.exists()) { + return + } - val dirName = "release/mergeReleaseAssets" - val injectDirName = "release/injectSentryDebugMetaPropertiesIntoAssetsRelease" - println("Cleaning Sentry meta files in build directories") - println("Build directory: ${buildDirectory.asFile.get().absolutePath}") - - val buildAssetsDir = File(buildDirectory.asFile.get(), "intermediates/assets/$dirName") - println("Checking directory buildAssetsDir: ${buildAssetsDir.absolutePath}") - val sentryFile = File(buildAssetsDir, "sentry-debug-meta.properties") - if (sentryFile.exists()) { - sentryFile.delete() - println("Deleted: ${sentryFile.absolutePath}") + val appimagetool = + layout.buildDirectory + .dir("tmp") + .get() + .asFile + .resolve("appimagetool-x86_64.AppImage") + + if (!appimagetool.exists()) { + downloadFile( + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage", + appimagetool, + ) } - val injectBuildAssetsDir = File(buildDirectory.asFile.get(), "intermediates/assets/$injectDirName") - println("Checking directory injectBuildAssetsDir: ${injectBuildAssetsDir.absolutePath}") - val injectSentryFile = File(injectBuildAssetsDir, "sentry-debug-meta.properties") - if (injectSentryFile.exists()) { - injectSentryFile.delete() - println("Deleted: ${injectSentryFile.absolutePath}") - val sentryFile = File(injectBuildAssetsDir, "sentry-debug-meta.properties") - sentryFile.writeText("") - println("✓ Overwritten: ${sentryFile.absolutePath}") + if (!appimagetool.canExecute()) { + appimagetool.setExecutable(true) } - } - } - tasks.whenTaskAdded { - if (name.contains("injectSentryDebugMetaPropertiesIntoAssetsRelease")) { - val cleanSentryMetaTaskName = "cleanSentryMetaForRelease" - val cleanSentryMetaTask = - tasks.register(cleanSentryMetaTaskName) { - assetDirectories.from(android.sourceSets.flatMap { it.assets.srcDirs }) - buildDirectory.set(layout.buildDirectory) + val appDir = + if (isRelease) { + layout.buildDirectory + .dir("appimage/main-release/$appName.AppDir") + .get() + .asFile + } else { + layout.buildDirectory + .dir("appimage/main/$appName.AppDir") + .get() + .asFile } - tasks.named(name).configure { - finalizedBy(cleanSentryMetaTask) + if (appDir.exists()) { + appDir.deleteRecursively() + } + + FileUtils.copyDirectory(appDirSrc, appDir) + FileUtils.copyDirectory(packageOutput, appDir) + + val appExecutable = appDir.resolve("bin/$appName") + if (!appExecutable.canExecute()) { + appimagetool.setExecutable(true) + } + + val appRun = appDir.resolve("AppRun") + if (!appRun.canExecute()) { + appRun.setReadable(true, false) // readable by all + appRun.setWritable(true, true) // writable only by owner + appRun.setExecutable(true, false) + + println( + "Set AppRun executable permissions, readable: ${appRun.canRead()}, writable: ${appRun.canWrite()}, executable: ${appRun.canExecute()}", + ) + } + + // Use ProcessBuilder instead of exec {} to avoid capturing project reference + val process = ProcessBuilder( + appimagetool.canonicalPath, + "$appName.AppDir", + "$appName-x86_64.AppImage" + ) + .directory(appDir.parentFile) + .apply { environment()["ARCH"] = "x86_64" } // TODO: 支持arm64 + .inheritIO() + .start() + + val exitCode = process.waitFor() + if (exitCode != 0) { + throw GradleException("appimagetool failed with exit code $exitCode") } } + + tasks.findByName("packageAppImage")?.doLast { + packAppImage(false) + } + tasks.findByName("packageReleaseAppImage")?.doLast { + packAppImage(true) } } -afterEvaluate { - tasks.withType { - jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") +// Mark JPackage tasks as not compatible with configuration cache +// This must be done outside afterEvaluate to work properly +tasks.withType().configureEach { + notCompatibleWithConfigurationCache("Compose Desktop JPackage tasks are not yet compatible with configuration cache") +} - if (System.getProperty("os.name").contains("Mac")) { - jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") +private fun downloadFile( + url: String, + destFile: File, +) { + val destParent = destFile.parentFile + destParent.mkdirs() + + if (destFile.exists()) { + destFile.delete() + } + + println("Download $url") + URI(url).toURL().openStream().use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) } } + println("Download finish") } \ No newline at end of file diff --git a/composeApp/proguard-desktop-rules.pro b/composeApp/proguard-desktop-rules.pro index 2cd808960..b8cdc1053 100644 --- a/composeApp/proguard-desktop-rules.pro +++ b/composeApp/proguard-desktop-rules.pro @@ -251,5 +251,9 @@ -keep class org.simpmusic.lyrics.models.** { *; } -keep class com.simpmusic.lyrics.parser.** { *; } +-keep class com.google.re2j.** { *; } +-dontwarn com.google.re2j.Matcher +-dontwarn com.google.re2j.Pattern + -keep class * extends androidx.room.RoomDatabase { (); } -keep class androidx.datastore.preferences.** { *; } \ No newline at end of file diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 299752bb2..000000000 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.android.kt index 6666d4d36..471a9755c 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.android.kt @@ -2,12 +2,12 @@ package com.maxrave.simpmusic.expect import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import androidx.appcompat.app.AppCompatActivity import androidx.core.net.toUri -import com.maxrave.simpmusic.MainActivity import org.koin.mp.KoinPlatform.getKoin actual fun openUrl(url: String) { - val context: MainActivity = getKoin().get() + val context: AppCompatActivity = getKoin().get() val browserIntent = Intent( Intent.ACTION_VIEW, @@ -21,7 +21,7 @@ actual fun shareUrl( title: String, url: String, ) { - val context: MainActivity = getKoin().get() + val context: AppCompatActivity = getKoin().get() val shareIntent = Intent(Intent.ACTION_SEND) shareIntent.type = "text/plain" shareIntent.putExtra(Intent.EXTRA_TEXT, url) diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.android.kt new file mode 100644 index 000000000..38efc2820 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.android.kt @@ -0,0 +1,6 @@ +package com.maxrave.simpmusic.expect + +// No-op on Android - mini player is desktop only +actual fun toggleMiniPlayer() { + // Do nothing on Android +} diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/Worker.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/Worker.android.kt deleted file mode 100644 index cde1fa7c6..000000000 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/Worker.android.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.maxrave.simpmusic.expect - -import android.content.Context -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import com.maxrave.simpmusic.service.test.notification.NotifyWork -import org.koin.mp.KoinPlatform.getKoin -import java.util.concurrent.TimeUnit - -actual fun startWorker() { - val context: Context = getKoin().get() - val request = - PeriodicWorkRequestBuilder( - 12L, - TimeUnit.HOURS, - ).addTag("Worker Test") - .setConstraints( - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(), - ).build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - "Artist Worker", - ExistingPeriodicWorkPolicy.KEEP, - request, - ) -} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/Cookies.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/Cookies.android.kt index a11da4f94..7d615848d 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/Cookies.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/Cookies.android.kt @@ -16,7 +16,14 @@ import androidx.compose.ui.viewinterop.AndroidView actual fun createWebViewCookieManager(): WebViewCookieManager = object : WebViewCookieManager { - override fun getCookie(url: String): String = CookieManager.getInstance().getCookie(url) + override fun getCookie(url: String): String { + val cookie = CookieManager.getInstance() + return if (cookie.hasCookies()) { + cookie.getCookie(url) + } else { + "" + } + } override fun removeAllCookies() { CookieManager.getInstance().removeAllCookies(null) diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 0aa2e9b72..3f2a39678 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -303,7 +303,6 @@ الترميز عدد تشغيل/عرض %1$d إعجابـ(ات), %2$d عدم إعجابـ(ات) - تم النشر في %1$s أقل Spotify تسجيل الدخول إلى Spotify @@ -356,5 +355,6 @@ جاري التحميل جاري التحديث الذهاب إلى صفحة تسجيل الدخول + سرعة التحميل %1$s diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml index e9ca3a390..a3fe1205c 100644 --- a/composeApp/src/commonMain/composeResources/values-az/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml @@ -289,7 +289,6 @@ Kodlama Səsləndirmə/Baxış Sayı %1$d bəyənmə, %2$d bəyənməmə - %1$s tarixində yayımlandı Daha az Spotify Spotify-a daxil olun diff --git a/composeApp/src/commonMain/composeResources/values-bg/strings.xml b/composeApp/src/commonMain/composeResources/values-bg/strings.xml index 98d5c1afb..8fc34dad7 100644 --- a/composeApp/src/commonMain/composeResources/values-bg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bg/strings.xml @@ -289,7 +289,6 @@ Кодек Изпълни/Виж броя %1$d харесване(ия), %2$d нехаресване(ия) - Публикуване на %1$s По-малко Spotify Вход в Spotify diff --git a/composeApp/src/commonMain/composeResources/values-ca/strings.xml b/composeApp/src/commonMain/composeResources/values-ca/strings.xml index 7c57b591d..7c77ec9eb 100644 --- a/composeApp/src/commonMain/composeResources/values-ca/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ca/strings.xml @@ -7,9 +7,9 @@ %1$s • %2$s %1$s pistes %2$s %1$s visualitzacions - Esborra l\'historial + Esborra l'historial Agradats - M\'agrada + M'agrada Hola, fragment en blanc Descarrega Descarregat @@ -26,14 +26,14 @@ Llistes preferides No hi ha llistes de reproducció preferides Llistes de reproducció baixades - No s\'ha baixat cap llista de reproducció + No s'ha baixat cap llista de reproducció Les vostres llistes de reproducció locals - No s\'han afegit llistes de reproducció + No s'han afegit llistes de reproducció Afegit recentment Cerca cançons, artistes, àlbums, llistes de reproducció i molt més Tot el que necessiteu La restauració ha fallat - S\'ha restaurat amb èxit + S'ha restaurat amb èxit Cercar Cançons Àlbums @@ -46,7 +46,7 @@ Segueix Reintentar Eliminar descàrrega - Comparteix l\'enllaç + Comparteix l'enllaç Disponible en línia No disponible Tu @@ -56,10 +56,10 @@ Bona nit Seleccions ràpides La cançó no està disponible - a les llistes d\'èxits + a les llistes d'èxits El nom de la llista de reproducció no pot estar buit - Aquesta aplicació ha d\'accedir a la teva notificació - S\'ha compartit + Aquesta aplicació ha d'accedir a la teva notificació + S'ha compartit Línia sincronitzada No sincronitzat Llista de reproducció buida @@ -92,7 +92,7 @@ Sobre COMENÇEM AMB UNA RÀDIO ESCOLLIM UNA LLISTA DE REPRODUCCIÓ PER A TU - + Gènere QUÈ ÉS LA MILLOR ELECCIÓ AVUI Gràfic @@ -117,7 +117,7 @@ Versió Github maxrave-dev - Compra\'m un cafè + Compra'm un cafè PayPal Títol Àlbum @@ -134,22 +134,22 @@ Àlbums Singles Els fans també poden agradar - SimpMusic s\'ha aturat. Disculpeu aquest problema.\nEnvieu el registre d\'error al desenvolupador + SimpMusic s'ha aturat. Disculpeu aquest problema.\nEnvieu el registre d'error al desenvolupador Bloqueig Envia - Informar d\'una fallada + Informar d'una fallada Suprimeix aquesta cançó de la llista de reproducció Inica la sessió Inicieu la sessió a YouTube Inicieu la sessió per obtenir dades personals Sessió iniciada correctament - No s\'ha pogut iniciar la sessió + No s'ha pogut iniciar la sessió Tanqueu la sessió de YouTube Sessió iniciada Sessió tancada Llista de reproducció - Reinicieu l\'aplicació per aplicar el canvi - Equilibra la sonoritat de l\'àudio + Reinicieu l'aplicació per aplicar el canvi + Equilibra la sonoritat de l'àudio Normalitza el volum Àudio El teu dispositiu no té equalitzador @@ -160,22 +160,22 @@ Tipus de mímica Taxa de bits Advertència - Si canvies d\'idioma, es tancarà l\'aplicació. N\'estàs segur? + Si canvies d'idioma, es tancarà l'aplicació. N'estàs segur? Aquest enllaç no és compatible Afegit a la llista de reproducció Descripció Reproducció - Desa l\'estat de reproducció + Desa l'estat de reproducció Desa el mode de reproducció i repetició Saltar en silenci No salteu cap part de música Temps esgotat, comprovi la connexió a internet (%1$s) - Desa l\'última reproducció + Desa l'última reproducció Desa la darrera pista reproduïda i la cua Nova actualització disponible Comproveu si hi ha actualitzacions Darrera comprovació a %1$s - S\'està comprovant + S'està comprovant SponsorBlock: Omet %1$s segment Patrocinador Autopromoció @@ -193,17 +193,17 @@ Desat SponsorBlock és un sistema de col·laboració oberta per saltar parts molestes dels vídeos de YouTube.\nMés informació: https://sponsor.ajay.app/\nA SimpMusic, SponsorBlock només està disponible quan el dispositiu està en línia Omet la part del patrocinador del vídeo - S\'ha produït un error inesperat. Disculpeu les molèsties. - Reinicieu l\'aplicació + S'ha produït un error inesperat. Disculpeu les molèsties. + Reinicieu l'aplicació Ajuda a SimpMusic lyrics a omplir la base de dades de lletres de cançons Envia les lletres de cançons del dispositiu a la base de dades de SimpMusic lyrics - Tanca l\'aplicació - Detalls de l\'error - Detalls de l\'error + Tanca l'aplicació + Detalls de l'error + Detalls de l'error Tanca Copia al porta-retalls - S\'ha copiat al porta-retalls - Informació d\'error + S'ha copiat al porta-retalls + Informació d'error La teva versió és la més nova Temporitzador de repòs: %1$s minut(s) Temporitzador @@ -235,8 +235,8 @@ Sincronitzant "Unsync this playlist? This playlist will not be removed from YouTube Music " Unsyncing - Afegeix a la llista de reproducció que t\'agrada de YouTube - Afegeix a la llista de reproducció que t\'agrada de YouTube + Afegeix a la llista de reproducció que t'agrada de YouTube + Afegeix a la llista de reproducció que t'agrada de YouTube Update playlist from YouTube Music Backup create success Backup create success @@ -248,7 +248,7 @@ Correu electrònic Contrasenya Si us plau, introdueix el teu correu electrònic i la teva contrasenya - Type language code (Eg: en) (2 letters). If you want to set same with SimpMusic\'s language, please make edit box empty. + Type language code (Eg: en) (2 letters). If you want to set same with SimpMusic's language, please make edit box empty. Invalid language code Lyrics proporcionada per Spotify Limit Player Cache @@ -257,7 +257,7 @@ Descripció i llicències Autor Una altre applicació - Base de dades d\'aplicacions + Base de dades d'aplicacions Espai lliure Suggeriment Recarregar @@ -270,8 +270,8 @@ Detector de problemes Llistes de reproducció destacades Destacat a - Qualitat d\'àudio - Reprodueix el vídeo per a la pista de vídeo en lloc de només l\'àudio + Qualitat d'àudio + Reprodueix el vídeo per a la pista de vídeo en lloc de només l'àudio Com ara vídeo musical, vídeo de lletres, podcasts i molt més Qualitat del vídeo @@ -298,7 +298,6 @@ Còdec Recompte de reproduccions/visualitzacions %1$d like(s), %2$d dislike(s) - Publicat a %1$s Menys Spotify Inicieu sessió a Spotify @@ -309,14 +308,14 @@ Mostra Spotify Canvas a SimpMusic Lyrics proporcionada per Spotify Mode fora de línia - S\'ha afegit a YouTube M\'agrada - S\'ha eliminat de YouTube M\'agrada + S'ha afegit a YouTube M'agrada + S'ha eliminat de YouTube M'agrada Obtén el límit de dades de casa Cap àlbum Giny del jugador Feu lliscar el dit per mostrar el botó de saltar i tancar Mostra les cançons suggerides per AI en funció de la teva llista de reproducció - Afegeix a la llista de reproducció que t\'agrada de YouTube + Afegeix a la llista de reproducció que t'agrada de YouTube Desconnecteu-vos des de Spotify Eliminar Els millors vídeos @@ -351,12 +350,12 @@ Actualitzat Actualitzat a la llista de reproducció de YouTube No es pot afegir a la llista de reproducció de YouTube - No s\'ha pogut eliminar de la llista de reproducció de YouTube + No s'ha pogut eliminar de la llista de reproducció de YouTube Carregant Actualitzant - Ves a la pàgina d\'inici de sessió - YouTube Music ara requereix que iniciï la sessió per a transmetre música, les instàncies de Piped poden no funcionar correctament en algun moment. Hauria d\'iniciar la sessió a YouTube per tindre una millor experiència amb SimpMusic - Memòria cau d\'Spotify Canvas + Ves a la pàgina d'inici de sessió + YouTube Music ara requereix que iniciï la sessió per a transmetre música, les instàncies de Piped poden no funcionar correctament en algun moment. Hauria d'iniciar la sessió a YouTube per tindre una millor experiència amb SimpMusic + Memòria cau d'Spotify Canvas Esborra la memòria cau de canvas Proxy Utilitzant el proxy per evitar el bloqueig de contingut regional @@ -367,7 +366,7 @@ Amfitrió invàlid Port del Proxy Port invàlid - Per favor, introdueixi l\'amfitrió del Proxy + Per favor, introdueixi l'amfitrió del Proxy Per favor, introdueixi el port del Proxy Cinc segons Lletra proporcionada per LRCLIB @@ -375,7 +374,7 @@ Cua de reproducció sense fi No ho tornis a mostrar Busca actualitzacions automàticament - Busca actualitzacions cada cop que l\'aplicació s\'obre + Busca actualitzacions cada cop que l'aplicació s'obre Efecte de desenfocament amb la lletra a pantalla completa Effecte de desenfoc del fons de la pantalla de lletra de la cançó a pantalla completa @@ -384,10 +383,10 @@ Desenfoca el fons del reproductor Efecte de desenfocament del fons de la pantalla \"Reproduint\" Fusionant àudio i vídeo - S\'ha produït un error + S'ha produït un error Descarregant àudio: %1$s Descarregant vídeo: %1$s - D\'acord + D'acord " a la carpeta Descàrregues" Velocitat de la descàrrega: %1$s Mescla @@ -398,42 +397,42 @@ Si està gaudint de SimpMusic, doni-li una estrella a GitHub o deixi una ressenya a "Si t'agrada el meu treball, considera" invitar-me a un cafè. - Efecte d\'atenuació de l\'àudio + Efecte d'atenuació de l'àudio Desactiva - Durada de l\'efecte d\'atenuació de l\'àudio + Durada de l'efecte d'atenuació de l'àudio Durada Nombre invàlid Dades del visitant recuperades DataSyncId recuperat Recupera dades de YouTube - No s\'ha trobat cap resultat + No s'ha trobat cap resultat IA - Proveïdor d\'IA + Proveïdor d'IA OpenAI Gemini - La seva clau de l\'API de la IA - Clau de l\'API de la IA invàlida - ID de model d\'IA personalitzat + La seva clau de l'API de la IA + Clau de l'API de la IA invàlida + ID de model d'IA personalitzat Els models per defecte són \"gemini-2.0-flash\" i \"gpt-4o\" Invàlid - Per favor, introdueixi l\'ID de model exacte del seu proveïdor d\'IA + Per favor, introdueixi l'ID de model exacte del seu proveïdor d'IA Podcasts preferits No hi ha podcasts preferits - El seu paràmetre sp_dc de la cookie d\'Spotify + El seu paràmetre sp_dc de la cookie d'Spotify La seva cookie de YouTube No pot estar buit Processant Lloc web Descarrega aquesta cançó/vídeo al seu dispositiu Finalitzar el servei en sortir - Atura la reproducció en segon pla en sortir de l\'aplicació + Atura la reproducció en segon pla en sortir de l'aplicació Crossfade (BETA) Activat (%1$ds) Desactivat Durada del crosssfade Estableixi la duració del crossfade en segons (%1$ds) Idioma de la traducció dels subtítols de YouTube - Escrigui el codi d\'idioma (per exemple: ca) (dues lletres). Aquest és l\'idioma per a la traducció dels subtítols de YouTube preferent + Escrigui el codi d'idioma (per exemple: ca) (dues lletres). Aquest és l'idioma per a la traducció dels subtítols de YouTube preferent Utilitza un nom anònim per contribuir Nom de contribuïdor Email de contribuïdor @@ -444,16 +443,16 @@ Restauració en progrés Fer una còpia de seguretat de les dades descarregades Fer una còpia de seguretat de les dades de les cançons descarregades - No s\'han trobat llistes de reproducció locals, per favor, creï\'n una primer. - Fes un llistat de totes les cookies d\'aquesta web - S\'ha copiat al porta-retalls - Canal d\'actualitzacions + No s'han trobat llistes de reproducció locals, per favor, creï'n una primer. + Fes un llistat de totes les cookies d'aquesta web + S'ha copiat al porta-retalls + Canal d'actualitzacions Activa liquid glass (BETA) - Activa l\'efecte liquid glass d\'iOS en algunes posicions La seva biblioteca Reprodueix el contingut explícit Activa per reproduir contingut explícit El contingut explícit serà saltat Mescla per a vostè - No s\'han trobat mescles + No s'han trobat mescles + A causa de les limitacions de Compose Multiplatform, no puc implementar WebView a l’entorn d’escriptori.\nLlegeix això per obtenir més informació sobre com iniciar sessió a l’escriptori:\n diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 2eb1f7db7..c2395415f 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -72,6 +72,8 @@ Inhaltsland Ändern Qualität + Download-Qualität + Video-Download-Qualität Download-Cache leeren Titelbild-Cache leeren Hinzufügen @@ -120,6 +122,7 @@ Spendiere mir einen Kaffee PayPal Titel + Individuelle Reinfolge Album YouTube URL Download-URL @@ -173,6 +176,7 @@ Zuletzt gespielt speichern Zuletzt gespielten Song und Wiedergabeliste speichern Neues Update verfügbar + Version %1$s verfügbar\nRelease: %2$s\n Nach Updates suchen Zuletzt gesucht um %1$s Suche nach Updates... @@ -299,7 +303,6 @@ Codec Aufrufe %1$d mag ich, %2$d mag ich nicht - Veröffentlicht am %1$s Weniger Spotify Bei Spotify anmelden @@ -311,7 +314,7 @@ Songtexte bereitgestellt von Spotify Offline-Modus Zu YouTube \"Gefällt mir\" Playlist hinzugefügt - Entfernt von YouTube Liked + Entfernt von YouTube \"Gefällt mir\" Playlist Datenlimit für Startseite festlegen Kein Album Spieler-Widget @@ -326,7 +329,7 @@ Neue Alben Benachrichtigung Vor %1$d Monaten - Vor %d Tagen + Vor %1$d Tagen Vor %1$d Stunden Keine Benachrichtigung Erstellt am: %1$s @@ -383,7 +386,7 @@ Wiedergabegeschwindigkeit Tonhöhe Player Hintergrund verwischen - Verwischt den Hintergrund des \"spielt gerade\" Bildschirmeffekts + Verwischt den Hintergrund des ''spielt gerade'' Bildschirmeffekts Audio und Video zusammenfügen Es ist ein Fehler aufgetreten Audio wird heruntergeladen: %1$s @@ -424,6 +427,7 @@ Keine Lieblings-Podcasts Dein sp_dc Param von Spotify Cookie Dein YouTube Cookie + Dein Discord-Token Darf nicht leer sein In Bearbeitung Webseite @@ -452,11 +456,24 @@ In die Zwischenablage kopiert Update-Kanal Liquid-Glass-Effekt aktivieren (BETA) - Aktivieren Sie den Liquid-Glass-Effekt von iOS in bestimmten Positionen (Android 13+). Deine Bibliothek Expliziten Inhalt abspielen Aktivieren, um explizite Inhalte abzuspielen Expliziter Inhalt blockiert Mix für Dich Keine Mixes gefunden + Aufgrund der Einschränkungen von Compose Multiplattform, kann ich WebView nicht auf dem Desktop implementieren.\nLies dies, um mehr zu erfahren, wie du dich im Desktop einloggst:\n + Blog-Beitrag öffnen + Discord-Integration + Bei Discord anmelden + Melde dich an, um Discord Rich-Präsenz zu aktivieren + Aktivitätsstatus aktivieren + Medien-Session-Informationen auf deinem Discord-Profil anzeigen + YouTube \"Gefällt mir\"-Musik + Dienst weiterlaufen lassen + Musikplayer-Dienst aktiv halten, um zu verhindern, dass er vom System beendet wird + Zeige deine YouTube-Playlisten, auch wenn du offline bist + Speichern Sie Ihre YouTube-Playlist lokal und zeigen Sie sie auch offline an. + Favoriten durch YouTube-Likes ersetzen, wenn angemeldet + Nur YouTube-Likes verwenden (keine Unterstützung für Likes von Songs im Offline-Modus) diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index a254ae304..96eeb2709 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -60,6 +60,7 @@ El nombre de la lista de reproducción no puede estar vacío Esta aplicación necesita tener permiso para crear notificaciones Compartido + Palabra por palabra Línea sincronizada Sin sincronizar La lista está vacía @@ -72,6 +73,8 @@ Región del contenido Cambiar Calidad + Calidad de descarga + Calidad de descarga de vídeo Limpiar caché de descargas Limpiar cache de imágenes Añadir @@ -120,6 +123,7 @@ Cómprame un café PayPal Título + Orden personalizado Álbum URL de YouTube Descargar URL @@ -173,6 +177,7 @@ Guardar última reproducción Guardar última pista reproducida y cola Nueva actualización disponible + Versión %1$s disponible\nVerión: %2$s\n Buscar actualizaciones Última comprobación a las %1$s Comprobando @@ -424,6 +429,7 @@ No hay podcasts favoritos Tu parámetro sp_dc de la cookie de Spotify Tu Cookie de YouTube + Tu token de Discord No puede estar vacío Procesando Página web @@ -447,16 +453,64 @@ Restauración en curso Respaldar datos descargados Respaldar todos los datos de canciones descargadas + Copia de seguridad automática + Realiza una copia de seguridad automática de tus datos en la carpeta Descargas/SimpMusic + Frecuencia de copias de seguridad + Diariamente + Semanalmente + Mensualmente + Mantener copias de seguridad + Mantener las últimas %1$s copias + Última copia de seguridad + Nunca + Copia de seguridad automática completada correctamente + Copia de seguridad automática fallida No se encontraron listas de reproducción locales, crea una primero Listar todas las cookies de esta página Copiado al portapapeles Actualizar canal Habilitar efecto de vidrio líquido (BETA) - Activar efecto de vidrio líquido de iOS en algunas areas (Android 12 o superior) + Activa el efecto de vidrio líquido de iOS en algunas posiciones (Android 13+) Mi biblioteca Reproducir contenido explícito Habilitar la reproducción de contenido explícito Omitir contenido explícito Mezclar para ti No se encontraron mezclas + Debido a las limitaciones de Compose Multiplatform, no puedo implementar WebView en el ordenador.\nLee esto para aprender más cómo iniciar sesión en el escritorio:\n + Abrir entrada del blog + Integración con Discord + Iniciar sesión en Discord + Inicia sesión para habilitar la presencia enriquecida de Discord + Habilitar presencia enriquecida + Mostrar información de sesión multimedia en tu perfil de Discord + Música de YouTube con Me gusta + Mantener el servicio activo + Mantener el servicio de reproductor de música activo para evitar que el sistema lo cierre + Seguir mostrando tu lista de reproducción de YouTube sin conexión + Guardar tu lista de reproducción de YT y mantener para mostrar sin conexión + Reemplazar Favorito por Me Gusta de YouTube al iniciar sesión + Solo usar \"Me gusta\" de YouTube (no es compatible con una canción sin conexión) + Este año + Últimos 90 días + Últimos 30 días + Últimos 7 días + reproducciones + Mejor canción + Canciones reproducidas + Tiempo total de escucha + segundos + Tus reproducidas recientemente + Tus mejores artistas + Tus mejores álbumes + Tus mejores canciones + Rango de fechas + Por favor reproduce música para ver analíticas + Valorar traducción + Valorar letra + Vota por letra + Voto positivo + Voto negativo + ¡Gracias por votar esta letra! + Error al enviar diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index f97798d80..58165c5f3 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -72,6 +72,8 @@ کشور محتوا تغییر کیفیت + کیفیت دانلود + کیفیت دانلود ویدیو پاک‌سازی حافظه پنهان دانلود شده پاک‌سازی حافظه پنهان تصاویر کوچک افزودن @@ -120,6 +122,7 @@ برای من یک قهوه بخر پی‌پال عنوان + مرتب‌سازی سفارشی آلبوم URL یوتیوب URL دریافت @@ -171,9 +174,11 @@ نگه‌داری حالت تصادفی و تکرار رد کردن سکوت رد کردن بخش بدون موسیقی + زمان استراحت، اتصال اینترنت (%1$s) را بررسی کنید نگه‌داری آخرین پخش شده نگه‌داری آخرین آهنگ پخش شده و صف به‌روزرسانی جدید موجود است + نسخه %1$s در دسترس است \n انتشار:%2$s\n بررسی به‌روزرسانی آخرین بررسی در %1$s در حال بررسی @@ -200,6 +205,8 @@ یک خطای غیرمنتظره رخ داده است. از بابت ناراحتی عذرخواهی می‌کنیم. برنامه را دوباره راه‌اندازی کنید + به SimpMusic کمک کنید تا پایگاه داده متن ترانه را بسازد + متن ترانه‌های ذخیره شده خود را به پایگاه داده متن SimpMusic ارسال کنید برنامه را ببندید ریزگان خطا ریزگان خطا @@ -247,6 +254,8 @@ ایجاد پشتیبان با موفقیت انجام شد ایجاد پشتیبان ناموفق بود تأمین‌کننده اصلی متن ترانه + استفاده از ترجمه متن ترانه با هوش مصنوعی + فعال کردن ترجمه متن ترانه با استفاده از هوش مصنوعی زبان ترجمه پیروی از زبان برنامه رایانامه @@ -266,6 +275,10 @@ فضای خالی پیشنهاد بارگذاری دوباره + مرتب‌سازی براساس + ترتیب + ابتدا قدیمی تر + ابتدا جدیدتر تاریخ افزوده شدن یک برنامه موسیقی ساده که از هسته یوتیوب موزیک بهره میبرد\n\nSimpMusic یک پروژه متن‌باز برای پخش موسیقی از یوتیوب و یوتیوب موزیک بدون آگهی و ردیابی است. SimpMusic ویژگی‌های زیادی ارائه می‌دهد:\n\n - پخش موسیقی \n - متن ترانه همگام‌سازی شده\n - داده‌های شخصی‌سازی شده\n - و غیره… \n\nSimpMusic همیشه رایگان و بدون آگهی است\n\nساخته شده با ❤️ از Tuan Minh Nguyen Duc (maxrave-dev) ردیاب مشکلات @@ -302,7 +315,6 @@ کدک تعداد پخش/نمایش %1$d پسندیده، %2$d نپسندیده - منتشر شده در %1$s کمتر اسپاتیفای وارد اسپاتیفای شوید @@ -404,6 +416,48 @@ یک قهوه برایم بخرید. افکت محو صدا غیرفعال + مدت زمان محو شدن جلوه صوتی (میلی‌ثانیه) (برای غیرفعال کردن روی ۰ تنظیم کنید) مدت عدد نامعتبر + نقطه پایانی مختلط در دسترس نیست + نقطه پایانی رادیو در دسترس نیست + اطلاعات بازدیدکنندگان بازیابی شد + DataSyncId بازیابی شد + بازیابی اطلاعات یوتیوب + نتیجه ای پیدا نشد + هوش مصنوعی + ارائه دهنده هوش مصنوعی + OpenAI + Gemini + کلید API هوش مصنوعی شما + کلید API نامعتبر + شناسه مدل هوش مصنوعی سفارشی + مدل‌های پیش‌فرض «gemini-2.0-flash» و «gpt-4o» هستند + نا معتبر + لطفاً دقیقاً شناسه مدل ارائه دهنده هوش مصنوعی خود را وارد کنید. + پادکست‌های مورد علاقه + پادکست مورد علاقه‌ای وجود ندارد + پارامتر sp_dc شما از کوکی Spotify + کوکی یوتیوب شما + توکن دیسکورد شما + نمی تواند خالی باشد + در حال پردازش + وب‎سایت + این فایل آهنگ/ویدیو را روی دستگاه خود دانلود کنید + بستن سرویس هنگام خروج + توقف پخش موسیقی پس زمینه هنگام خروج از برنامه + درهم آمیزی(آزمایشی) + فعال شد (%1$ds) + غیرفعال + مدت زمان درهم آمیزی + مدت زمان درهم آمیزی را بر حسب ثانیه تنظیم کنید (%1$ds) + زبان ترجمه زیرنویس یوتیوب + کد زبان (مثلاً en) (دو حرف) را تایپ کنید. این زبان ترجمه ترجیحی برای زیرنویس‌های یوتیوب است. + استفاده از اسم ناشناس برای مشارکت + اسم مشارکت کننده + ایمیل مشارکت کننده + ناشناس + متن ترانه‌های SimpMusic + متن ترانه‌ها ارائه شده توسط سرویس متن ترانه‌های SimpMusic + پشتیبان گیری در حال انجام است diff --git a/composeApp/src/commonMain/composeResources/values-fi/strings.xml b/composeApp/src/commonMain/composeResources/values-fi/strings.xml index 486b998d3..870ed5ab7 100644 --- a/composeApp/src/commonMain/composeResources/values-fi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fi/strings.xml @@ -255,6 +255,7 @@ Vapaata tilaa Ehdotus Lataa uudelleen + Uusimmat ensin Lisäyspäivämäärä Yksinkertainen musiikkisovellus, joka käyttää YouTube Musicin taustajärjestelmää \n\nSimpMusic on avoimen lähdekoodin projekti musiikin suoratoistoon YouTubesta ja YouTube Musicista ilman mainoksia ja seurantaa. SimpMusic tarjoaa monia ominaisuuksia:\n\n - Musiikin suoratoisto \n - Synkronoidut sanoitukset\n - Tietojen mukauttaminen\n - jne… \n\nSimpMusic on aina ilmainen ja vailla mainoksia\n\nTehty ❤️:llä, Tuan Minh Nguyen Duc (maxrave-dev) Tikettijärjestelmä @@ -285,4 +286,5 @@ Vieras Kirjaudu ulos kaikista tileistä? Toista seuraavaksi + Kirjaudu sisään Spotifyhin diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 33687fc67..7d9cd08c7 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -60,6 +60,7 @@ Le nom de la playlist ne peut pas être vide Cette application doit accéder à vos notifications Partagé + Mot par mot Ligne synchronisée Non synchronisé La playlist est vide @@ -72,6 +73,8 @@ Pays du contenu Changer Qualité + Qualité de téléchargement + Qualité de téléchargement vidéo Effacer le cache téléchargé Effacer le cache des miniatures Ajouter @@ -120,6 +123,7 @@ Offrez-moi un café ! ☕ PayPal Titre + Commande personnalisée Album URL YouTube Télécharger l'URL @@ -173,6 +177,7 @@ Sauvegarder la dernière lecture Sauvegarder le dernier titre joué et la file d'attente Nouvelle mise à jour disponible ! + Version %1$s disponible\nSortie : %2$s\n Vérifier la mise à jour Dernière vérification : %1$s Vérification @@ -424,6 +429,7 @@ Aucun podcast préféré Votre paramètre sp_dc du cookie Spotify Votre cookie YouTube + Votre jeton Discord Ne peut pas être vide Traitement Site web @@ -447,16 +453,64 @@ Restauration en cours Sauvegarder les données téléchargées Sauvegarder toutes les données des titres téléchargés + Sauvegarde automatique + Sauvegardez automatiquement vos données dans le dossier Téléchargements/SimpMusic + Fréquence des sauvegardes + Quotidienne + Hebdomadaire + Mensuelle + Conserver des sauvegardes + Conserver les %1$s dernières sauvegardes + Dernière sauvegarde + Jamais + Sauvegarde automatique terminée avec succès + Échec de la sauvegarde automatique Aucune playlist locale trouvée, veuillez d'abord en créer une. Lister tous les cookies de cette page Copié dans le presse-papiers Mettre à jour la chaîne Activer le verre liquide (Bêta) - Activer l'effet verre liquide d'iOS dans certaines positions (Android 12+) + Activer l'effet verre liquide d'iOS dans certaines positions (Android 13+) Votre Bibliothèque Lire le contenu explicite Activer la lecture de contenu explicite Le contenu explicite est ignoré Mix pour vous Aucun mix trouvé + En raison des limitations de Compose Multiplatform, je ne peux pas implémenter WebView sur ordinateur.\nConsultez ce document pour en savoir plus sur la connexion sur ordinateur :\n + Ouvrir l'article de blog + Intégration Discord + Connectez-vous à Discord + Connectez-vous pour activer la présence enrichie Discord + Activer une présence riche + Afficher les informations de la session média sur votre profil Discord + Musique aimée par YouTube + Maintenir le service en vie + Maintenez le service du lecteur de musique actif pour éviter qu'il ne soit arrêté par le système + Continuez à afficher votre playlist YouTube même hors ligne + Enregistrez votre playlist YouTube en local et conservez-la pour la visionner hors ligne + Remplacer « Favori » par « J’aime » sur YouTube lors de la connexion + Utilisation uniquement des vidéos aimées sur YouTube (ne prend pas en charge les titres hors ligne) + Cette année + 90 derniers jours + 30 derniers jours + 7 derniers jours + joués + Meilleur titre + Titres joués + Durée totale d'écoute + secondes + Vos titres récemment joués + Vos artistes préférés + Vos albums préférés + Vos titres préférés + Période + Veuillez écouter de la musique pour voir les statistiques. + Évaluer la traduction + Évaluer les paroles + Votez pour les paroles + Vote positif + Vote négatif + Merci d'avoir voté pour ces paroles ! + Échec de la soumission diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 35df4360c..b78faa8a4 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -299,7 +299,6 @@ कोडेक प्ले/व्यू काउंट %1$d पसंद, %2$d नापसंद - %1$s को प्रकाशित कम स्पॉटिफाए Spotify में लॉग इन करें @@ -451,7 +450,6 @@ क्लिपबोर्ड पर कॉपी किया गया चैनल अपडेट करें लिक्विड ग्लास सक्षम करें (बीटा) - IOS के लिक्विड ग्लास इफ़ेक्ट को किसी भी स्थिति में सक्षम करें (Android 13+) आपकी लाइब्रेरी आपत्तिजनक कंटेंट चलाएं आपत्तिजनक कंटेंट चलाने की अनुमति दें diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 4c799209c..9d8293129 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -46,7 +46,7 @@ Segui Riprova Download rimosso - Condividi l\'URL + Condividi l'URL Disponibile online Non disponibile Tu @@ -72,6 +72,8 @@ Paese del contenuto Cambia Qualità + Qualità di download + Qualità download video Pulisci la cache dei Download Pulisci la cache delle copertine Aggiungi @@ -120,6 +122,7 @@ Comprami un caffè PayPal Titolo + Ordine custom Album URL YouTube URL Download @@ -148,19 +151,19 @@ Autenticato Disconnesso Playlist - Riavvia l\'app per applicare la modifica + Riavvia l'app per applicare la modifica Bilancia il volume dei contenuti multimediali Normalizza il volume Audio - Il tuo dispositivo non ha l\'equalizzatore + Il tuo dispositivo non ha l'equalizzatore Equalizzatore a sistema aperto - Usa l\'equalizzatore del tuo sistema + Usa l'equalizzatore del tuo sistema Fornitore di dati in streaming (Piped) Itag Tipo Mime Bitrate Attenzione - La modifica della lingua chiuderà l\'app. Sei sicuro? + La modifica della lingua chiuderà l'app. Sei sicuro? Questo collegamento non è supportato Aggiunto alla playlist Descrizione @@ -170,9 +173,10 @@ Salta silenzioso Non saltare alcuna parte musicale Tempo scaduto, controlla la connessione internet (%1$s) - Salva l\'ultima riproduzione - Salva l\'ultima traccia riprodotta e la coda + Salva l'ultima riproduzione + Salva l'ultima traccia riprodotta e la coda Nuovo aggiornamento disponibile + Versione %1$s disponibile\n Rilascio: %2$s\n Ricerca aggiornamenti Ultimo controllo alle %1$s Controllo @@ -194,24 +198,24 @@ Salvato SponsorBlock è un sistema di crowdsourcing per saltare parti fastidiose dei video di YouTube.\nUlteriori informazioni: https://sponsor.ajay.app/\nIn SimpMusic, SponsorBlock è disponibile solo quando il dispositivo è online Salta la parte dello sponsor del video - Si è verificato un errore imprevisto. Ci dispiace per l\'inconvenienza. - Riavvia l\'app + Si è verificato un errore imprevisto. Ci dispiace per l'inconvenienza. + Riavvia l'app Aiuta SimpMusic a costruire il database dei testi Invia i tuoi testi salvati al database dei testi di SimpMusic - Chiudi l\'app + Chiudi l'app Dettagli errore Dettagli errore Chiudi Copia negli appunti Copiato negli appunti - Informazioni sull\'errore + Informazioni sull'errore La tua versione è la più recente Timer sospensione - %1$s minuto Timer sospensione Tempo del conto alla rovescia (minuti) Imposta Interruzione del timer di spegnimento completata - Si prega di impostare l\'ora nel formato corretto + Si prega di impostare l'ora nel formato corretto Rimuovere il timer di spegnimento? Bentornato, @@ -243,9 +247,9 @@ Creazione della copia non riuscita Fornitore principale di testi Usa Traduzione Testi AI - Abilita la traduzione dei testi utilizzando l\'IA + Abilita la traduzione dei testi utilizzando l'IA Lingua di traduzione - Segui la lingua dell\'app + Segui la lingua dell'app Email Password Per favore inserisci la tua email e la password @@ -258,7 +262,7 @@ Descrizione e licenze Autore Altre app - Database dell\'app + Database dell'app Spazio libero Suggerimento Ricarica @@ -299,7 +303,6 @@ Codec Conteggio Riproduzioni/Visualizzazioni %1$d mi piace, %2$d non mi piace - Pubblicato il %1$s Meno Spotify Accedi a Spotify @@ -356,7 +359,7 @@ Caricamento Aggiornando Vai alla pagina di accesso - YouTube Music ora richiede di accedere per riprodurre musica, quindi l\'istanza Piped potrebbe occasionalmente non funzionare. Si consiglia di accedere a YouTube per ottenere un\'esperienza migliore con SimpMusic. + YouTube Music ora richiede di accedere per riprodurre musica, quindi l'istanza Piped potrebbe occasionalmente non funzionare. Si consiglia di accedere a YouTube per ottenere un'esperienza migliore con SimpMusic. Cache di Spotify Canvas Pulisci la cache di Spotify Canvas Proxy @@ -376,17 +379,17 @@ Coda infinita Non mostrare più Controllo automatico degli aggiornamenti - Controllo degli aggiornamenti all\'apertura dell\'app + Controllo degli aggiornamenti all'apertura dell'app Sfoca effetto testo a schermo intero - Sfoca lo sfondo dell\'effetto schermo intero del testo + Sfoca lo sfondo dell'effetto schermo intero del testo Velocità di riproduzione Tono Sfoca lo sfondo del lettore - Sfoca lo sfondo dell\'effetto della schermata di riproduzione + Sfoca lo sfondo dell'effetto della schermata di riproduzione Unione di audio e video - C\'è stato un problema - Download dell\'audio in corso: %1$s + C'è stato un problema + Download dell'audio in corso: %1$s Download del video in corso: %1$s OK " alla cartella dei Download" @@ -404,8 +407,8 @@ Durata effetto dissolvenza audio (ms) (Imposta a 0 per disattivare) Durata Numero non valido - L\'endpoint casuale non è disponibile - L\'endpoint radio non è disponibile + L'endpoint casuale non è disponibile + L'endpoint radio non è disponibile Dati del visitatore recuperati DataSyncId recuperato Recupera dati da YouTube @@ -419,17 +422,18 @@ ID modello AI personalizzato I modelli predefiniti sono \"gemini-2.0-flash\" e \"gpt-4o\" Non valido - Inserisci l\'esatto modello ID dal tuo fornitore AI. + Inserisci l'esatto modello ID dal tuo fornitore AI. Podcast Preferiti Nessun Podcast preferito Il tuo sp_dc param dei cookie di Spotify Il Tuo Cookie Di YouTube + il tuo token di discord Il campo non può essere vuoto In elaborazione Sito Web Scarica questa canzone/file video sul tuo dispositivo - Termina il servizio all\'uscita - Ferma il riproduttore musicale in background all\'uscita dall\'app + Termina il servizio all'uscita + Ferma il riproduttore musicale in background all'uscita dall'app Dissolvenza incrociata (BETA) Abilitato (%1$ds) Disabilitato @@ -452,11 +456,24 @@ Copiato negli appunti Aggiorna il canale Abilita liquid glass (BETA) - Abilita l\'effetto liquid glass di iOS in alcuni punti (Android 13+) La tua libreria Riproduci contenuti espliciti Abilita la riproduzione di contenuti espliciti Il contenuto esplicito è saltato Mix per te Nessun mix trovato + A causa delle limitazioni di Compose Multiplatform, non posso implementare WebView sul desktop.\nLeggi questo per saperne di più su come accedere al desktop:\n + Apri post del blog + integrazione discord + accedi su discord + + Abilita Rich Presence + Mostra le info della tua sessione nel tuo profilo di Discord + \"mi piace\" musica di Youtube + Tieni il servizio attivo + Mantieni attivo il servizio del lettore musicale per evitare che venga chiuso dal sistema + mostra la tua playlist di youtube quando offline + salva la tua playlist di youtube in locale e tienila per averla offline + combina i preferiti di youtube con i mi piace quando accedi + Usando solo i mi piace Youtube (non supporta come una canzone quando offline) diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 234bf63f2..370a592bb 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -72,6 +72,8 @@ コンテンツの対象国 変更 音質 + ダウンロード品質 + 動画ダウンロード品質 ダウンロードしたキャッシュを消去 サムネイルのキャッシュを消去 追加 @@ -169,6 +171,7 @@ シャッフルとリピートの設定を保存 無音部分を飛ばす 音楽でない部分を飛ばします + タイムアウトしました。インターネット接続を確認してください (%1$s) 最後に再生したものを保存 最後に再生した曲とキューを保存 最新版があります @@ -195,6 +198,8 @@ 動画の協賛シーンなどをスキップします 予期しないエラーが発生しました。ご迷惑をおかけして申し訳ありません。 アプリを再起動 + SimpMusicの歌詞データベースの構築を手伝う + 保存した歌詞をSimpMusicの歌詞データベースに送信します アプリを終了 エラーの詳細 エラーの詳細 @@ -240,6 +245,7 @@ バックアップの作成に失敗しました メインにする歌詞の提供元 AIによる歌詞翻訳を使用 + AIを使用した歌詞の翻訳を有効にします 翻訳言語 アプリの言語に従う メールアドレス @@ -294,7 +300,6 @@ コー​​デック 再生/表示回数 %1$d 高評価/ %2$d 低評価 - %1$s 少なく表示 Spotify Spotify にログインする @@ -412,12 +417,14 @@ AI の API キー API キーが無効です AI のモデル ID を指定 + デフォルトのモデルは \"gemini-2.0-flash\" と \"gpt-4o\" 無効です AI 提供元によるモデルIDを正確に入力します。 お気に入りのポッドキャスト お気に入りのポッドキャストなし Spotifyクッキーのsp_dc引数 YouTubeクッキー + あなたのDiscordトークン 空にはできません 処理中 サイト @@ -431,4 +438,39 @@ クロスフェードの長さを秒単位で設定 (%1$ds) YouTube字幕 翻訳言語 言語コード (例 en) (2 文字) これは、YouTube の字幕に適する翻訳言語です。 + 匿名で貢献する + 貢献者名 + 貢献者のメールアドレス + 匿名 + SimpMusic Lyrics + SimpMusic Lyricsが提供する歌詞 + バックアップ中 + 復元中 + ダウンロードしたデータをバックアップする + ダウンロードしたすべての曲のデータをバックアップします + ローカルプレイリストが見つかりませんでした。最初に作成してください + このページのすべてのクッキーを一覧表示 + クリップボードにコピーしました + 更新チャンネル + Liquid Glassを有効にする (ベータ版) + あなたのライブラリ + 明示的なコンテンツを再生 + 明示的なコンテンツの再生を有効にします + 明示的なコンテンツをスキップします + あなたのミックス + ミックスが見つかりませんでした + Compose Multiplatformの制限のため、デスクトップ上にWebViewを実装できません。\nデスクトップでのログイン方法については、こちらをご覧ください:\n + ブログ記事を開く + Discord連携 + Discordにログイン + ログインしてDiscord Rich Presenceを有効にします + Rich Presence を有効にする + Discordのプロフィールにメディアセッション情報を表示します + YouTubeで高く評価した音楽 + サービスを有効したままにする + システムによって終了されるのを避けるために音楽プレイヤーのサービスを有効したままにします + オフライン時にYouTubeプレイリストを表示する + オフライン時にYouTubeプレイリストをローカルに保存して表示します + ログイン時にお気に入りをYouTubeの高評価に置き換える + YouTubeの高評価のみを使用する (オフライン時はサポートしていません) diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 1aea8894e..7a29b92b7 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -298,7 +298,6 @@ 코덱 재생/조회수 좋아요 %1$d, 싫어요 %2$d - 발매일: %1$s 간단히 Spotify Spotify에 로그인 @@ -451,7 +450,6 @@ 클립보드에 복사 완료 업데이트 채널 리퀴드 글래스 켜기 (베타) - 일부 영역에 iOS 리퀴드 글래스 효과 적용 (Android 12 이상) 내 라이브러리 연령 제한 콘텐츠 재생 연령 제한 콘텐츠 재생 활성화 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 9c03814c4..58b435e9e 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -72,6 +72,7 @@ Land van Inhoud Aanpassen Kwaliteit + Downloadkwaliteit Download-Cache wissen Miniatuur-Cache wissen Toevoegen @@ -187,12 +188,13 @@ Opslaan Opgeslagen Er is een onverwachte fout opgetreden. Sorry voor het ongemak. + Kopiëren naar klembord Ja Welkom terug, Upload je luistergeschiedenis naar de YouTube-muziekserver, het zal de aanbeveling van YT Music systeem verbeteren. Het werkt alleen als je ingelogd bent Stuur luistergegevens terug naar Google - Video\'s + Video's Je YouTube-afspeellijsten Geen YouTube-afspeellijsten nummer(s) diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 1a5d54e7a..ddbccc198 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -184,7 +184,7 @@ Muzyka poza tematem Podświetlenie POI Blok sponsora - Włącz SponsorBlock\'a + Włącz SponsorBlock'a Wybierz zachowanie segmentu pomijanego Jakie segmenty zostaną pominięte Zapisz @@ -290,7 +290,6 @@ Kodek Liczba odtworzeń/wyświetleń %1$d polubień, %2$d nielubi - Opublikowano %1$s Mniej Spotify Zaloguj się do Spotify diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index bee7e8033..6837b651d 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -72,6 +72,7 @@ Conteúdo do País Alterar Qualidade + Download qualidade Limpar o Cache Baixado Limpar Cache de Miniaturas Adicionar @@ -249,7 +250,7 @@ Email Senha Por favor entre com seu e-mail e senha - Type language code (Eg: pt) (2 letters). If you want to set same with SimpMusic\'s language, please make edit box empty. + Type language code (Eg: pt) (2 letters). If you want to set same with SimpMusic's language, please make edit box empty. Código de idioma inválido Letras fornecidas pelo YouTube Limitar cache do jogador @@ -299,7 +300,6 @@ Codec Quantidade de visualizações %1$d curtida(s), %2$d não gostei(s) - Publicado em %1$s Menos Spotify Entrar no Spotify @@ -359,7 +359,7 @@ O YouTube Music agora requer o acesso à conta para fazer a transmissão de músicas, O Piped pode em algum momento não funcionar também. Logue no YouTube para ter uma melhor experiência com a SimpMusic. Cache de Tela Spotify Limpar cache da tela - \'Proxy\' + 'Proxy' Usando Proxy para ignorar o bloqueio de conteúdo do país Tipo de Proxy HTTP @@ -424,6 +424,7 @@ Nenhum \"podcast\" favorito Seu parâmetro sp_dc do cookie do Spotify Seu cookie do YouTube + Seu Discord token Não pode estar vazio Processando Site @@ -452,7 +453,6 @@ Copiado para a área de transferência Canal De Atualizações Enable liquid glass (BETA) - Enable iOS\'s liquid glass effect in some position (Android 13+) Your library Play explicit content Enable to play explicit content diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index e8ab4b343..b92746b0d 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -72,6 +72,8 @@ Страна контента Изменить Качество + Качество загрузки + Качество загрузки видео Очистить скачанный кэш Очистить кэш миниатюр Добавить @@ -120,6 +122,7 @@ Купите мне кофе PayPal Название + Свой порядок Альбом YouTube URL URL Скачивания @@ -173,6 +176,7 @@ Сохранить последнюю воспроизводимую Сохранить последний воспроизведенный трек и очередь Доступно новое обновление + Версия %1$s доступна\nВерсия:%2$s\n Проверить на наличие обновлений Последняя проверка %1$s Выполняется проверка @@ -301,7 +305,6 @@ Кодек Количество просмотров %1$d лайков, %2$d дизлайков - Опубликовано %1$s Меньше Spotify Войти в Spotify @@ -426,6 +429,7 @@ Нет любимых подкастов Ваш sp_dc параметр с Spotify cookie Ваш YouTube Cookie + Ваш Discord токен Не может быть пустым Обработка Веб-сайт @@ -454,11 +458,25 @@ Скопировано в буфер обмена Канал обновлений Включить эффект жидкого стекла (БЕТА) - Включить эффект жидкого стекла из iOS в некоторых местах (Android 13+) Ваша библиотека Воспроизводить контент с нецензурной лексикой Разрешить воспроизведение контента с нецензурной лексикой Неприемлемый контент пропущен Миксы для вас Не найдено миксов + В связи с ограничениями Compose Multiplatform, WebView невозможно реализовать на настольных устройствах.\n +Ознакомьтесь с этим руководством, чтобы узнать, как выполнить вход на настольной версии:\n + Читать в блоге + Интеграция Discord + Войти в Discord + Войдите, чтобы включить Discord Rich Presence + Включить Rich Presence + Показывать информацию о медиа-сессии в вашем профиле Discord + Любимые треки YouTube + Сохранять работу сервиса + Держать музыкальный плеер активным + Показывать плейлист YouTube офлайн + Сохранить плейлист YouTube и использовать офлайн + Заменить Избранное на Понравившиеся в YouTube при входе + Использовать только Понравившиеся на YouTube (лайки песен офлайн не поддерживаются) diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 16d9a2acd..d066e05dd 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -298,7 +298,6 @@ โคเดก เล่น/ดูจำนวน %1$d ชอบ, %2$d ไม่ชอบ - เผยแพร่ที่ %1$s น้อยลง Spotify เข้าสู่ระบบ Spotify @@ -451,7 +450,6 @@ คัดลอกไปยังคลิปบอร์ดแล้ว ช่องทางอัพเดต เปิดใช้งาน Liquid Glass (เบต้า) - เปิดใช้งานเอฟเฟ็กต์ Liquid Glass ของ iOS ในบางตำแหน่ง (Android 12 ขึ้นไป) คลังเพลงของคุณ เล่นเนื้อหาที่ไม่เหมาะสม เปิดใช้งานการเล่นเนื้อหาที่ไม่เหมาะสม diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 01a60b851..da0d7a60d 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -46,7 +46,7 @@ Takip Et Tekrar Dene İndirme kaldırıldı - URL\'yi paylaş + URL'yi paylaş Çevrimiçi olarak mevcut Mevcut değil Sen @@ -65,7 +65,7 @@ Oynatma listesi boş Seçenek Açıklama yok - Arama\'da + Arama'da Oynatıcı Önbelleğini Temizle İptal Temizle @@ -121,7 +121,7 @@ PayPal Başlık Albüm - YouTube URL\'si + YouTube URL'si İndirme Bağlantısı Şimdi Çalıyor Kuyruk @@ -132,7 +132,7 @@ Ne dinlemek istiyorsun? Şarkılar Albümler - Single\'lar + Single'lar Benzer sanatçılar SimpMusic çöktü. Bu sorun için özür dileriz.\nÇökme günlüğünü geliştiriciye gönderin Çökme @@ -140,11 +140,11 @@ Çökmeyi Raporla Bu şarkıyı çalma listesinden sil Oturum aç - YouTube\'da oturum açın + YouTube'da oturum açın Kişisel verileri almak için oturum açın Oturum açma başarılı Giriş başarısız oldu - YouTube\'dan çıkış yap + YouTube'dan çıkış yap Oturum açıldı Oturum kapatıldı Oynatma listesi @@ -187,12 +187,12 @@ İlgi Çekici Vurgu Dolgu SponsorBlock - SponsorBlock\'u etkinleştir + SponsorBlock'u etkinleştir Kısım atlama davranışını seçin Hangi kısımlar atlanacak Kaydet Kaydedildi - SponsorBlock, YouTube videolarının rahatsız edici bölümlerini atlamak için kitle kaynaklı bir sistemdir.\nDaha fazla bilgi: https://sponsor.ajay.app/\nSimpMusic\'te SponsorBlock yalnızca cihaz çevrimiçi olduğunda kullanılabilir + SponsorBlock, YouTube videolarının rahatsız edici bölümlerini atlamak için kitle kaynaklı bir sistemdir.\nDaha fazla bilgi: https://sponsor.ajay.app/\nSimpMusic'te SponsorBlock yalnızca cihaz çevrimiçi olduğunda kullanılabilir Videonun tanıtım bölümünü atla Beklenmedik bir hata oluştu. Verdiğimiz rahatsızlıktan dolayı özür dileriz. Uygulamayı yeniden başlat @@ -216,14 +216,14 @@ Evet Tekrar hoş geldiniz Dinleme geçmişinizi YouTube Müzik sunucusuna yükleyin, YT Müzik öneri sistemini daha iyi hale getirecektir. Sadece giriş yapıldığında çalışır - Dinleme verilerini Google\'a geri gönder + Dinleme verilerini Google'a geri gönder Videolar YouTube Oynatma Listeleriniz YouTube Oynatma Listesi yok parça(lar) Yerel oynatma listesine kaydet Yerel Oynatma Listesi Eklendi - YouTube Music\'e eşitleyin + YouTube Music'e eşitleyin YouTube Müzik ile eşitlendi Şarkı Radyo @@ -238,7 +238,7 @@ Eşitlemesiz YouTube Oynatma Listesine eklendi YouTube Oynatma Listesinden kaldırıldı - Oynatma listesini YouTube Müzik\'ten güncelle + Oynatma listesini YouTube Müzik'ten güncelle Yedekleme başarıyla oluşturuldu Yedekleme oluşturma başarısız Ana Şarkı Sözü Sağlayıcısı @@ -249,7 +249,7 @@ E-Posta Şifre Lütfen e-posta ve şifrenizi girin - Dil kodunu yazın (Örn: en) (2 harf). SimpMusic\'in dili ile aynı şekilde ayarlamak istiyorsanız, lütfen düzenleme kutusunu boş bırakın. + Dil kodunu yazın (Örn: en) (2 harf). SimpMusic'in dili ile aynı şekilde ayarlamak istiyorsanız, lütfen düzenleme kutusunu boş bırakın. Geçersiz dil kodu Şarkı sözleri YouTube tarafından sağlanmıştır Oynatıcı Önbelleğini Sınırla @@ -267,13 +267,13 @@ Eskiden Yeniye Yeniden Eskiye Tarih eklendi - Arka uç olarak YouTube Music\'i kullanan basit bir müzik uygulaması \n\nSimpMusic, reklamlar ve izleme olmadan YouTube ve YouTube Music\'ten müzik akışı sağlayan açık kaynaklı bir projedir. SimpMusic birçok özellik sunar:\n\n - Müzik akışı \n - Senkronize şarkı sözleri\n - Verileri kişiselleştirin\n - vb… \n\nSimpMusic her zaman ücretsizdir ve reklam içermez\n\nTuan Minh Nguyen Duc\'tan ❤️ ile oluşturun (maxrave-dev) + Arka uç olarak YouTube Music'i kullanan basit bir müzik uygulaması \n\nSimpMusic, reklamlar ve izleme olmadan YouTube ve YouTube Music'ten müzik akışı sağlayan açık kaynaklı bir projedir. SimpMusic birçok özellik sunar:\n\n - Müzik akışı \n - Senkronize şarkı sözleri\n - Verileri kişiselleştirin\n - vb… \n\nSimpMusic her zaman ücretsizdir ve reklam içermez\n\nTuan Minh Nguyen Duc'tan ❤️ ile oluşturun (maxrave-dev) Sorun Takibi Öne Çıkan Oynatma Listeleri Bulunduğu listeler Ses Kalitesi Sadece ses yerine video parçası için video oynatın - Müzik Videosu, Şarkı Sözü Videosu, Podcast\'ler ve daha fazlası gibi + Müzik Videosu, Şarkı Sözü Videosu, Podcast'ler ve daha fazlası gibi Video Kalitesi %d şarkılar @@ -299,7 +299,6 @@ Kodek Oynatma/Görüntüleme Sayısı %1$d beğeni %2$d beğenmeme - %1$s tarihinde yayınlandı Daha az Spotify Spotify ile giriş yap @@ -307,18 +306,18 @@ Spotify Şarkı Sözlerini Etkinleştir Çeviri olmadan daha fazla şarkı sözü Canvas Etkinleştir - SimpMusic\'te Spotify Canvas\'ı Göster + SimpMusic'te Spotify Canvas'ı Göster Şarkı sözleri Spotify tarafından sağlanmıştır Çevrimdışı mod - YouTube Beğenilenler\'e eklendi - YouTube Beğenilenler\'den kaldırıldı + YouTube Beğenilenler'e eklendi + YouTube Beğenilenler'den kaldırıldı İnternet kota limitini getir Albüm yok Oynatma aracı Atla ve kapat düğmesini göstermek için kaydırın Çalma listenize göre yapay zekanın önerdiği şarkıları göster YouTube Beğenilen Oynatma Listesine Ekle - Spotify\'dan çıkış yap + Spotify'dan çıkış yap Sil En İyi Videolar Öne Çıkanlar @@ -356,7 +355,7 @@ Yükleniyor Güncelleniyor Giriş sayfasına git - YouTube Music artık müzik akışı için oturum açmanızı gerektiriyor, Piped Instance bazen de çalışmıyor. SimpMusic ile daha iyi bir deneyim elde etmek için YouTube\'da oturum açmalısınız. + YouTube Music artık müzik akışı için oturum açmanızı gerektiriyor, Piped Instance bazen de çalışmıyor. SimpMusic ile daha iyi bir deneyim elde etmek için YouTube'da oturum açmalısınız. Spotify Canvas Önbelleği Canvas önbelleğini temizle Proxy @@ -395,8 +394,8 @@ Başka versiyon Daha sonra Yıldız bırak - SimpMusic\'in keyfini çıkarın - SimpMusic\'i kullanmaktan hoşlanıyorsanız, GitHub\'da SimpMusic\'i yıldızlayın veya + SimpMusic'in keyfini çıkarın + SimpMusic'i kullanmaktan hoşlanıyorsanız, GitHub'da SimpMusic'i yıldızlayın veya "Eğer çalışmalarımı seviyorsanız" bana bir kavhe ısmarla. Ses efektini soldur @@ -420,7 +419,7 @@ Varsayılan modeller \"gemini-2.0-flash\" ve \"gpt-4o\" Geçersiz . - Favori Podcast\'ler + Favori Podcast'ler Favori podcast yok Spotify çerezinizin sp_dc parametresi YouTube Çereziniz @@ -452,7 +451,6 @@ Panoya kopyalandı Kanalı güncelle Sıvı camı etkinleştir (BETA) - iOS\'un sıvı cam efektini bazı konumlarda etkinleştirin (Android 13+) Kitaplığınız Uygunsuz içeriği oynat Uygunsuz içeriği oynatmayı etkinleştir diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index d1e16efa5..82224ca80 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -72,6 +72,8 @@ Країна контенту Змінити Якість + Якість завантаження + Якість завантаження відео Очистити кеш завантажених Очистити кеш мініатюр Додати @@ -104,7 +106,7 @@ Всі Відео Контент - Пам\'ять + Пам'ять Кеш плеєра Кеш завантажених Кеш мініатюр @@ -120,6 +122,7 @@ Купіть мені каву PayPal Назва + Власний порядок Альбом YouTube URL Завантажити URL @@ -173,6 +176,7 @@ Зберегти останнє відтворене Зберегти останній відтворений трек і чергу Доступне нове оновлення + Доступна версія %1$s\nРеліз: %2$s\n Перевірити наявність оновлень Остання перевірка о %1$s Перевірка @@ -301,7 +305,6 @@ Кодек К-сть відтворень/переглядів %1$d вподобань, %2$d невподобань - Опубліковано в %1$s Менше Spotify Увійти в Spotify @@ -372,7 +375,7 @@ Недійсний порт Введіть проксі-сервер Введіть проксі-порт - П\'ять секунд + П'ять секунд Тексти пісень надано LRCLIB Спочатку синхронізуйте цей список відтворення з YouTube Music Нескінченна черга @@ -386,7 +389,7 @@ Тон Розмити фон плеєра Розмиття фону екрана, що зараз відтворюється - Об\'єднання аудіо та відео + Об'єднання аудіо та відео Сталася помилка Завантаження аудіо: %1$s Завантаження відео: %1$s @@ -426,6 +429,7 @@ Немає улюблених подкастів Ваш sp_dc параметр з Spotify cookie Ваш YouTube cookie + Ваш Discord токен Не може бути порожнім Обробка Веб-сайт @@ -439,8 +443,8 @@ Встановити тривалість затухання в секундах (%1$d) Мова перекладу субтитрів YouTube Введіть код мови (Eg: en) (2 букви). Це бажана мова перекладу для YouTube субтитрів. - Використовувати анонімне ім\'я для співпраці - Ім\'я учасника + Використовувати анонімне ім'я для співпраці + Ім'я учасника Електронна пошта учасника Анонімно Тексти пісень SimpMusic @@ -454,11 +458,24 @@ Скопійовано в буфер обміну Канал оновлень Увімкнути liquid glass (БЕТА) - Вмикає ефект liquid glass iOS в деяких місцях (Android 13+) Ваша бібліотека Відтворювати відвертий контент Ввімкнути відтворення відвертого контенту Відвертий контент пропускається Мікс для вас Мікси не знайдено + Через обмеження Compose Multiplatform, WebView на ПК недоступний.\nДокладніше про вхід на ПК\n + Відкрити блог + Інтеграція з Discord + Увійти в Discord + Увійдіть, щоб увімкнути Discord Rich Presence + Увімкнути Rich Presence + Показувати інформацію про медіа-сеанс у профілі Discord + Вподобана музика YouTube + Підтримувати службу активною + Утримувати музичний плеєр активним, щоб не бути закритим системою + Показувати ваш YouTube-плейлист офлайн + Зберегти свій плейліст YouTube локально й переглядати офлайн + Замінювати «Обране» на «Вподобане YouTube» після входу в обліковий запис + Використовує лише «Вподобане YouTube» (без підтримки вподобання пісень офлайн) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 925802175..53c8fd5bc 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -30,7 +30,7 @@ Danh sách phát nội bộ của bạn Không có danh sách phát nào được thêm Đã thêm gần đây - Tìm kiếm bài hát, nghệ sĩ, album, danh sách phát và thêm nữa + Tìm kiếm bài hát, nghệ sĩ, album, danh sách phát và hơn thế nữa Tất cả mọi thứ bạn cần Khôi phục thất bại Khôi phục thành công @@ -60,6 +60,7 @@ Tên danh sách phát không thể để trống Ứng dụng này cần quyền để tạo thông báo Đã được chia sẻ + Từng từ một Đã được đồng bộ Không được đồng bộ Danh sách phát này trống @@ -451,6 +452,18 @@ Đang tiến hành khôi phục Sao lưu dữ liệu đã tải xuống Sao lưu tất cả các bài hát đã tải xuống + Tự động sao lưu + Tự động sao lưu dữ liệu vào thư mục Downloads/SimpMusic + Tần suất sao lưu + Hàng ngày + Hàng tuần + Hàng tháng + Giữ bản sao lưu + Giữ %1$s bản sao lưu cuối cùng + Sao lưu lần cuối + Không bao giờ + Tự động sao lưu hoàn thành thành công + Tự động sao lưu thất bại Không tìm thấy bất kỳ danh sách phát nội bộ nào, hãy tạo một danh sách phát trước Danh sách cookie của trang này Đã sao chép vào bộ nhớ tạm @@ -475,4 +488,28 @@ Bảo vệ dịch vụ phát nhạc sống tránh khỏi bị hệ thống tắt Giữ danh sách phát YouTube của bạn hiển thị khi offline Lưu danh sách phát YT lại và hiển thị khi offline + Thay đổi Yêu Thích bằng đã thích ở Youtube khi đã đăng nhập + Chỉ sử dụng cho đã thích ở Youtube (không hỗ trợ như một bài hát khi đang ngoại tuyến) + Năm nay + 90 ngày qua + 30 ngày qua + 7 ngày qua + lượt nghe + Bài hát hàng đầu + Bài hát đã nghe + Tổng số thời gian nghe + giây + Bài hát đã nghe gần đây + Nghệ sĩ hàng đầu của bạn + Album hàng đầu của bạn + Bài hát hàng đầu của bạn + Khoảng thời gian + Hãy nghe một vài bài hát để xem thống kê + Đánh giá bản dịch + Đánh giá lời bài hát + Bình chọn cho lời bài hát + Ủng hộ + Không thích + Cảm ơn vì đã bình chọn cho lời bài hát! + Gửi không thành công diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index bc2f9ca97..675f51ef5 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -72,6 +72,8 @@ 內容國家 更改 音質 + + 清除下載快取 清除縮圖快取 加入 @@ -298,7 +300,6 @@ 編解碼器 播放/觀看次數 %1$d 喜歡, %2$d 不喜歡 - 發佈於%1$s 收起 Spotify 使用Spotify帳號登入 @@ -451,7 +452,6 @@ 已複製至剪貼簿 更新頻道 啟用 Liquid Glass 效果(測試版) - 在某些地方啟用 iOS 的 Liquid Glass(液態玻璃)效果(僅適用於 Android 13+ 版本) 我的音樂庫 播放露骨內容 允許播放露骨內容 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 2a4395578..3b07c7658 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -72,6 +72,8 @@ 内容国家/地区 更改 音质 + 下载音质 + 视频下载质量 清除已下载缓存 清除缩略图缓存 添加 @@ -120,6 +122,7 @@ 请我喝杯咖啡(捐赠) PayPal 标题 + 自定义顺序 专辑 YouTube 链接 下载链接 @@ -173,6 +176,7 @@ 保存上次播放 保存上次播放的曲目和队列 有可用的更新 + 版本 %1$s 可用\n发布版本:%2$s\n 检查更新 上次检查于 %1$s 检查中 @@ -197,6 +201,7 @@ 发生意外错误。很抱歉给您带来不便。 重启应用 帮助SimpMusic 歌词建立歌词数据库 + 将您保存的歌词发送到 SimpMusic 歌词数据库中 关闭应用 错误信息 错误信息 @@ -242,6 +247,7 @@ 备份创建失败 主歌词提供商 使用 AI 歌词翻译 + 启用AI歌词翻译 翻译语言 跟随应用语言 邮箱 @@ -296,17 +302,40 @@ Codec 播放/查看计数 %1$d 赞(s), %2$d 踩(s) - 发布于 %1$s 减少 Spotify 登录Spotify + 登录以获取 Spotify Lyrics, Canvas 及更多 启用 Spotify 歌词 更多没有翻译的歌词 + 启用 Canvas + 在 SimpMusic 中显示 Spotify Canvas + 由 Spotify 提供的歌词 + 离线模式 + 添加到 YouTube 赞过 + 已从 YouTube 赞过中移除 + 无专辑 + 播放器小部件 + 滑动以显示跳过和关闭按钮 + 基于您的播放列表显示AI推荐的歌曲 + 添加到 YouTube 赞过的播放列表 + 登出Spotify + 删除 + 热门视频 + 热门趋势 + 新单曲 + 新专辑 + 通知 + %1$d 个月前 + %1$d 天前 + %1$d 小时前 + 无通知 创建于 %1$s 未知 单曲循环 列表循环 关闭循环播放 + YouTube 字幕 界面 如果你喜欢我的作品,支持我一下 放松 @@ -314,16 +343,55 @@ 鼓舞 难过 爱情 + 感觉不错 健身 派对 通勤 专注 更新 已添加到 YouTube 歌单 + 无法添加到 YouTube 播放列表 + 无法从 YouTube 播放列表中删除歌曲 + 加载中 + 更新中 + 转到登录页面 + YouTube Music现在要求您登录以串流音乐,Piped实例有时也无法正常工作。 您应该登录到 YouTube 以获得更好的 SimpMusic 体验。 + Spotify Canvas 缓存 + 清除 canvas 缓存 + 代理 + 使用代理服务器来绕过国家内容封锁 + 代理类型 + HTTP + Socks + 代理服务器主机域名 + 无效的主机 + 代理端口 + 无效的端口 + 请输入您的代理服务器 + 请输入您的代理端口 + 5秒 + 歌词由 LRCLIB 提供 + 首先将此播放列表同步到 YouTube 音乐 不再显示 自动检查更新 + 当您打开应用程序时检查更新 + 模糊全屏歌词特效 + 播放速度 + 播放界面背景模糊 其他版本 "如果你喜欢我的工作,请考虑: " + 音频淡入淡出 + 停用 - 随机终端不可用 + 随机播放功能暂不可用 + 获取 YouTube 数据 + 未找到结果 + AI + AI 提供商 + OpenAI + Gemini + 自定义 AI 模型 ID + 无效的 + 最喜爱的播客 + 没有最喜爱的播客 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3fadddd9a..a8fea29c6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -458,6 +458,18 @@ Restore in progress Backup downloaded data Backup all downloaded songs's data + Auto backup + Automatically backup your data to Downloads/SimpMusic folder + Backup frequency + Daily + Weekly + Monthly + Keep backups + Keep last %1$s backups + Last backup + Never + Auto backup completed successfully + Auto backup failed No local playlists found, please create one first List all cookies of this page Copied to clipboard @@ -484,4 +496,26 @@ Save your YT playlist to local and keep to show when offline Replace Favorite by YouTube Liked when logged in Using only YouTube Liked (not support like a song when offline) + This year + Last 90 days + Last 30 days + Last 7 days + plays + Top song + Songs played + Total listened time + seconds + Your recently played + Your top artists + Your top albums + Your top tracks + Date range + Please play some music to see analytics + Rate translation + Rate lyrics + Vote for lyrics + Upvote + Downvote + Thank you for voting this lyrics! + Failed to submit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt index 92eaa974a..625877196 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt @@ -1,6 +1,7 @@ package com.maxrave.simpmusic.di import com.maxrave.simpmusic.viewModel.AlbumViewModel +import com.maxrave.simpmusic.viewModel.AnalyticsViewModel import com.maxrave.simpmusic.viewModel.ArtistViewModel import com.maxrave.simpmusic.viewModel.HomeViewModel import com.maxrave.simpmusic.viewModel.LibraryDynamicPlaylistViewModel @@ -138,4 +139,12 @@ val viewModelModule = get(), ) } + viewModel { + AnalyticsViewModel( + get(), + get(), + get(), + get() + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.kt new file mode 100644 index 000000000..24b4feb86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.kt @@ -0,0 +1,7 @@ +package com.maxrave.simpmusic.expect + +/** + * Platform-specific function to toggle mini player window. + * Only implemented on Desktop (JVM), no-op on other platforms. + */ +expect fun toggleMiniPlayer() diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/Worker.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/Worker.kt deleted file mode 100644 index 4ef7ff3b5..000000000 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/Worker.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.maxrave.simpmusic.expect - -expect fun startWorker() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/RichSyncParser.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/RichSyncParser.kt index f44b917a4..f7822d027 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/RichSyncParser.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/RichSyncParser.kt @@ -42,8 +42,8 @@ fun parseRichSyncWords( println("[parseRichSyncWords] Input preview: ${words.take(100)}") // Strategy: Find all timestamps first, then extract text between them - // Regex to match timestamp only: - val timestampRegex = Regex("""<(\d{2}):(\d{2})\.(\d{2})>""") + // Regex to match timestamp only: or + val timestampRegex = Regex("""<(\d{2}):(\d{2})\.(\d{2,3})>""") val wordTimings = mutableListOf() @@ -51,13 +51,16 @@ fun parseRichSyncWords( val timestamps = timestampRegex.findAll(words).toList() timestamps.forEachIndexed { index, match -> - val (minutes, seconds, centiseconds) = match.destructured + val (minutes, seconds, fraction) = match.destructured // Convert time to milliseconds + // If 2 digits (centiseconds): multiply by 10 to get ms (e.g., 62 -> 620ms) + // If 3 digits (milliseconds): use as-is (e.g., 620 -> 620ms) + val fractionMs = fraction.toLongOrNull() ?: 0L val timeMs = (minutes.toLongOrNull() ?: 0L) * 60000L + (seconds.toLongOrNull() ?: 0L) * 1000L + - (centiseconds.toLongOrNull() ?: 0L) * 10L + if (fraction.length == 2) fractionMs * 10L else fractionMs // Extract text after this timestamp until the next timestamp (or end of string) val startPos = match.range.last + 1 diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt index f7139f92a..49247b493 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt @@ -192,20 +192,26 @@ fun Modifier.angledGradientBackground( in 0f..gamma, in (2 * PI - gamma)..2 * PI -> { x / cos(alpha) } + // ray from centre cuts the top edge of the rectangle in gamma..(PI - gamma).toFloat() -> { y / sin(alpha) } + // ray from centre cuts the left edge of the rectangle in (PI - gamma)..(PI + gamma) -> { x / -cos(alpha) } + // ray from centre cuts the bottom edge of the rectangle in (PI + gamma)..(2 * PI - gamma) -> { y / -sin(alpha) } + // default case (which shouldn't really happen) - else -> hypot(x, y) + else -> { + hypot(x, y) + } } val centerOffsetX = cos(alpha) * gradientLength / 2 @@ -228,53 +234,61 @@ fun Modifier.angledGradientBackground( // Angle Gradient Background without size fun GradientOffset(angle: GradientAngle): GradientOffset = when (angle) { - GradientAngle.CW45 -> + GradientAngle.CW45 -> { GradientOffset( start = Offset.Zero, end = Offset.Infinite, ) + } - GradientAngle.CW90 -> + GradientAngle.CW90 -> { GradientOffset( start = Offset.Zero, end = Offset(0f, Float.POSITIVE_INFINITY), ) + } - GradientAngle.CW135 -> + GradientAngle.CW135 -> { GradientOffset( start = Offset(Float.POSITIVE_INFINITY, 0f), end = Offset(0f, Float.POSITIVE_INFINITY), ) + } - GradientAngle.CW180 -> + GradientAngle.CW180 -> { GradientOffset( start = Offset(Float.POSITIVE_INFINITY, 0f), end = Offset.Zero, ) + } - GradientAngle.CW225 -> + GradientAngle.CW225 -> { GradientOffset( start = Offset.Infinite, end = Offset.Zero, ) + } - GradientAngle.CW270 -> + GradientAngle.CW270 -> { GradientOffset( start = Offset(0f, Float.POSITIVE_INFINITY), end = Offset.Zero, ) + } - GradientAngle.CW315 -> + GradientAngle.CW315 -> { GradientOffset( start = Offset(0f, Float.POSITIVE_INFINITY), end = Offset(Float.POSITIVE_INFINITY, 0f), ) + } - else -> + else -> { GradientOffset( start = Offset.Zero, end = Offset(Float.POSITIVE_INFINITY, 0f), ) + } } /** @@ -345,7 +359,7 @@ fun LazyListState.animateScrollAndCentralizeItem( if (itemInfo != null) { val center = this@animateScrollAndCentralizeItem.layoutInfo.viewportEndOffset / 2 val childCenter = itemInfo.offset + itemInfo.size / 2 - this@animateScrollAndCentralizeItem.animateScrollBy((childCenter - center / 1.5f).toFloat(), tween(800)) + this@animateScrollAndCentralizeItem.animateScrollBy((childCenter - center).toFloat(), tween(300)) } else { this@animateScrollAndCentralizeItem.animateScrollToItem(index) } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FiveImagesComponent.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FiveImagesComponent.kt new file mode 100644 index 000000000..1c7e6099a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FiveImagesComponent.kt @@ -0,0 +1,348 @@ +package com.maxrave.simpmusic.ui.component + +import androidx.compose.foundation.MarqueeAnimationMode +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.maxrave.simpmusic.ui.theme.typo + +@Composable +fun FiveImagesComponent( + modifier: Modifier, + images: List, +) { + if (images.isEmpty()) { + return + } + Column(modifier) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(2f) + .clickable { + images.first().onClick() + }, + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(images.first().imageUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(images.first().imageUrl) + .crossfade(550) + .build(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = + Modifier + .align(Alignment.Center) + .fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Transparent, + Color.Black.copy( + alpha = 0.4f, + ), + Color.Black, + ), + ), + ), + ) + Column( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = images.first().title, + style = typo().labelSmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + Text( + text = images.first().subtitle, + style = typo().bodySmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + images.first().thirdTitle?.let { + Text( + text = it, + style = typo().bodySmall, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + } + } + } + if (images.size < 3) { + return@Column + } + Row(Modifier.fillMaxWidth()) { + images.subList(1, 3).forEach { image -> + Box( + Modifier + .fillMaxWidth(0.5f) + .aspectRatio(1f) + .weight(1f) + .clickable { + image.onClick() + }, + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(image.imageUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(image.imageUrl) + .crossfade(550) + .build(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = + Modifier + .align(Alignment.Center) + .fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Transparent, + Color.Black.copy( + alpha = 0.4f, + ), + Color.Black, + ), + ), + ), + ) + Column( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = image.title, + style = typo().labelSmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + Text( + text = image.subtitle, + style = typo().bodySmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + image.thirdTitle?.let { + Text( + text = it, + style = typo().bodySmall, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + } + } + } + } + } + if (images.size < 5) { + return@Column + } + Row { + images.subList(3, 5).forEach { image -> + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .weight(1f) + .clickable { + image.onClick() + }, + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(image.imageUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(image.imageUrl) + .crossfade(550) + .build(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = + Modifier + .align(Alignment.Center) + .fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Transparent, + Color.Black.copy( + alpha = 0.4f, + ), + Color.Black, + ), + ), + ), + ) + Column( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = image.title, + style = typo().labelSmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + Text( + text = image.subtitle, + style = typo().bodySmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + image.thirdTitle?.let { + Text( + text = it, + style = typo().bodySmall, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + } + } + } + } + } + } +} + +data class ImageData( + val imageUrl: String, + val title: String, + val subtitle: String, + val thirdTitle: String? = null, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FullWidthItems.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FullWidthItems.kt index bf7f41aa2..9f9904916 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FullWidthItems.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FullWidthItems.kt @@ -114,6 +114,7 @@ fun SongFullWidthItems( onClickListener: ((videoId: String) -> Unit)? = null, onAddToQueue: ((videoId: String) -> Unit)? = null, modifier: Modifier, + rightView: @Composable (() -> Unit)? = null, ) { val maxOffset = 360f val coroutineScope = rememberCoroutineScope() @@ -317,6 +318,9 @@ fun SongFullWidthItems( ) } } + if (rightView != null) { + rightView() + } if (onMoreClickListener != null) { RippleIconButton(resId = Res.drawable.baseline_more_vert_24, fillMaxSize = false) { val videoId = track?.videoId ?: songEntity?.videoId @@ -455,6 +459,7 @@ fun SuggestItems( fun PlaylistFullWidthItems( data: PlaylistType, onClickListener: (() -> Unit)? = null, + rightView: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Box( @@ -616,6 +621,9 @@ fun PlaylistFullWidthItems( ) } } + if (rightView != null) { + rightView() + } } } } @@ -624,6 +632,7 @@ fun PlaylistFullWidthItems( fun ArtistFullWidthItems( data: ArtistType, onClickListener: (() -> Unit)? = null, + rightView: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val (name: String, thumbnails: String?) = @@ -701,6 +710,9 @@ fun ArtistFullWidthItems( ).focusable(), ) } + if (rightView != null) { + rightView() + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt index fb70217eb..3e77de135 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage @@ -197,7 +198,8 @@ fun LyricsView( } LaunchedEffect(key1 = currentLineIndex, key2 = currentLineHeight) { if (currentLineIndex > -1 && currentLineHeight > 0 && - (lyricsData.lyrics.syncType == "LINE_SYNCED" || lyricsData.lyrics.syncType == "RICH_SYNCED")) { + (lyricsData.lyrics.syncType == "LINE_SYNCED" || lyricsData.lyrics.syncType == "RICH_SYNCED") + ) { val boxEnd = listState.layoutInfo.viewportEndOffset val boxStart = listState.layoutInfo.viewportStartOffset val viewPort = boxEnd - boxStart @@ -305,7 +307,10 @@ fun LyricsView( val parsedLine = remember(words, line.startTimeMs, line.endTimeMs) { val result = parseRichSyncWords(words, line.startTimeMs, line.endTimeMs) - Logger.d(TAG, "Line $index parseRichSyncWords result: ${if (result != null) "${result.words.size} words" else "null"}") + Logger.d( + TAG, + "Line $index parseRichSyncWords result: ${if (result != null) "${result.words.size} words" else "null"}", + ) result } @@ -461,6 +466,7 @@ fun RichSyncLyricsLineItem( translatedWords: String?, currentTimeMs: Long, isCurrent: Boolean, + customFontSize: TextUnit? = null, modifier: Modifier = Modifier, ) { // Performance optimization: derive current word index based on timeline @@ -497,6 +503,7 @@ fun RichSyncLyricsLineItem( isActive = isCurrent && index == currentWordIndex, isPast = isCurrent && index < currentWordIndex, isCurrent = isCurrent, + customFontSize = customFontSize, ) } } @@ -535,14 +542,21 @@ private fun AnimatedWord( isActive: Boolean, isPast: Boolean, isCurrent: Boolean, + customFontSize: TextUnit? = null, ) { // Smooth color transition with animation val color by animateColorAsState( targetValue = when { - !isCurrent -> Color.LightGray.copy(alpha = 0.35f) // Non-current line - isPast -> Color.White.copy(alpha = 0.7f) // Past words - isActive -> Color.White // Current word - full brightness + !isCurrent -> Color.LightGray.copy(alpha = 0.35f) + + // Non-current line + isPast -> Color.White.copy(alpha = 0.7f) + + // Past words + isActive -> Color.White + + // Current word - full brightness else -> Color.LightGray.copy(alpha = 0.5f) // Future words }, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), @@ -551,7 +565,10 @@ private fun AnimatedWord( Text( text = word, - style = typo().headlineLarge, + style = + typo().headlineLarge.copy( + fontSize = customFontSize ?: typo().headlineLarge.fontSize, + ), color = color, ) } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt new file mode 100644 index 000000000..dcbc11074 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt @@ -0,0 +1,174 @@ +package com.maxrave.simpmusic.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ThumbDown +import androidx.compose.material.icons.rounded.ThumbDownAlt +import androidx.compose.material.icons.rounded.ThumbUp +import androidx.compose.material.icons.rounded.ThumbUpAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.VoteData +import com.maxrave.simpmusic.viewModel.VoteState +import org.jetbrains.compose.resources.stringResource +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.cancel +import simpmusic.composeapp.generated.resources.downvote +import simpmusic.composeapp.generated.resources.rate_lyrics +import simpmusic.composeapp.generated.resources.rate_translated_lyrics +import simpmusic.composeapp.generated.resources.upvote +import simpmusic.composeapp.generated.resources.vote_for_lyrics + +@Composable +fun VoteLyricsDialog( + canVoteLyrics: Boolean, + canVoteTranslatedLyrics: Boolean, + lyricsVoteState: VoteData?, + translatedLyricsVoteState: VoteData?, + onVoteLyrics: (upvote: Boolean) -> Unit, + onVoteTranslatedLyrics: (upvote: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + stringResource(Res.string.cancel), + style = typo().bodySmall, + ) + } + }, + title = { + Text( + stringResource(Res.string.vote_for_lyrics), + style = typo().labelSmall, + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Vote for original lyrics + if (canVoteLyrics && lyricsVoteState != null) { + VoteRow( + label = stringResource(Res.string.rate_lyrics), + voteState = lyricsVoteState, + onUpvote = { onVoteLyrics(true) }, + onDownvote = { onVoteLyrics(false) }, + ) + } + + // Vote for translated lyrics + if (canVoteTranslatedLyrics && translatedLyricsVoteState != null) { + VoteRow( + label = stringResource(Res.string.rate_translated_lyrics), + voteState = translatedLyricsVoteState, + onUpvote = { onVoteTranslatedLyrics(true) }, + onDownvote = { onVoteTranslatedLyrics(false) }, + ) + } + } + }, + ) +} + +@Composable +private fun VoteRow( + label: String, + voteState: VoteData, + onUpvote: () -> Unit, + onDownvote: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = label, + style = typo().bodySmall, + ) + Text( + text = "ID: ${voteState.id}", + style = typo().bodySmall, + ) + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Vote: ${voteState.vote}", + style = typo().bodySmall, + ) + when (voteState.state) { + is VoteState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + + is VoteState.Success -> { + Icon( + imageVector = if (voteState.state.upvote) Icons.Rounded.ThumbUpAlt else Icons.Rounded.ThumbDownAlt, + contentDescription = null, + tint = Color.Cyan, + modifier = Modifier.size(24.dp), + ) + } + + is VoteState.Error -> { + Text( + text = voteState.state.message, + style = typo().bodySmall, + color = Color.Red, + ) + } + + is VoteState.Idle -> { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = onUpvote, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.ThumbUp, + contentDescription = stringResource(Res.string.upvote), + modifier = Modifier.size(20.dp), + ) + } + IconButton( + onClick = onDownvote, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.ThumbDown, + contentDescription = stringResource(Res.string.downvote), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/home/AnalyticsDestination.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/home/AnalyticsDestination.kt new file mode 100644 index 000000000..22c034815 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/home/AnalyticsDestination.kt @@ -0,0 +1,6 @@ +package com.maxrave.simpmusic.ui.navigation.destination.home + +import kotlinx.serialization.Serializable + +@Serializable +object AnalyticsDestination \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/HomeScreenGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/HomeScreenGraph.kt index 563fdff4c..939e16605 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/HomeScreenGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/HomeScreenGraph.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.maxrave.simpmusic.ui.navigation.destination.home.AnalyticsDestination import com.maxrave.simpmusic.ui.navigation.destination.home.CreditDestination import com.maxrave.simpmusic.ui.navigation.destination.home.MoodDestination import com.maxrave.simpmusic.ui.navigation.destination.home.NotificationDestination @@ -14,6 +15,7 @@ import com.maxrave.simpmusic.ui.screen.home.MoodScreen import com.maxrave.simpmusic.ui.screen.home.NotificationScreen import com.maxrave.simpmusic.ui.screen.home.RecentlySongsScreen import com.maxrave.simpmusic.ui.screen.home.SettingScreen +import com.maxrave.simpmusic.ui.screen.home.analytics.AnalyticsScreen import com.maxrave.simpmusic.ui.screen.other.CreditScreen fun NavGraphBuilder.homeScreenGraph( @@ -50,4 +52,10 @@ fun NavGraphBuilder.homeScreenGraph( innerPadding = innerPadding, ) } + composable { + AnalyticsScreen( + navController = navController, + innerPadding = innerPadding, + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 70ccf88a2..e22cd6f96 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -40,6 +40,9 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Speaker import androidx.compose.material3.Card @@ -93,6 +96,7 @@ import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.utils.connectArtists import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform +import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.PlatformBackdrop import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape import com.maxrave.simpmusic.expect.ui.toImageBitmap @@ -157,7 +161,7 @@ fun MiniPlayer( thumbnail.readPixels(buffer) } } catch (e: Exception) { - Logger.e(TAG, "Error getting pixels from layer: ${e.localizedMessage}") + Logger.e(TAG, "Error getting pixels from layer: ${e.message}") } val averageLuminance = (0 until 25).sumOf { index -> @@ -807,7 +811,32 @@ fun MiniPlayer( sharedViewModel.onUIEvent(UIEvent.ToggleLike) } Spacer(Modifier.width(8.dp)) - Icon(Icons.Rounded.Speaker, "") + // Desktop mini player button (JVM only) + if (getPlatform() == Platform.Desktop) { + IconButton(onClick = { toggleMiniPlayer() }) { + Icon( + imageVector = Icons.Outlined.OpenInNew, + contentDescription = "Mini Player", + ) + } + } + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.Filled.VolumeUp + } else { + Icons.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + ) + } Spacer(Modifier.width(4.dp)) var isVolumeSliding by rememberSaveable { mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt index dd8b1e153..ab50b696b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt @@ -234,6 +234,7 @@ fun RecentlySongsScreen( } } }, + actions = {}, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 202d6b803..ce9d16dea 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -163,11 +163,14 @@ import simpmusic.composeapp.generated.resources.anonymous import simpmusic.composeapp.generated.resources.app_name import simpmusic.composeapp.generated.resources.audio import simpmusic.composeapp.generated.resources.author +import simpmusic.composeapp.generated.resources.auto_backup +import simpmusic.composeapp.generated.resources.auto_backup_description import simpmusic.composeapp.generated.resources.auto_check_for_update import simpmusic.composeapp.generated.resources.auto_check_for_update_description import simpmusic.composeapp.generated.resources.backup import simpmusic.composeapp.generated.resources.backup_downloaded import simpmusic.composeapp.generated.resources.backup_downloaded_description +import simpmusic.composeapp.generated.resources.backup_frequency import simpmusic.composeapp.generated.resources.balance_media_loudness import simpmusic.composeapp.generated.resources.baseline_arrow_back_ios_new_24 import simpmusic.composeapp.generated.resources.baseline_close_24 @@ -196,6 +199,7 @@ import simpmusic.composeapp.generated.resources.contributor_email import simpmusic.composeapp.generated.resources.contributor_name import simpmusic.composeapp.generated.resources.custom_ai_model_id import simpmusic.composeapp.generated.resources.custom_model_id_messages +import simpmusic.composeapp.generated.resources.daily import simpmusic.composeapp.generated.resources.database import simpmusic.composeapp.generated.resources.default_models import simpmusic.composeapp.generated.resources.description_and_licenses @@ -222,6 +226,8 @@ import simpmusic.composeapp.generated.resources.invalid_api_key import simpmusic.composeapp.generated.resources.invalid_host import simpmusic.composeapp.generated.resources.invalid_language_code import simpmusic.composeapp.generated.resources.invalid_port +import simpmusic.composeapp.generated.resources.keep_backups +import simpmusic.composeapp.generated.resources.keep_backups_format import simpmusic.composeapp.generated.resources.keep_service_alive import simpmusic.composeapp.generated.resources.keep_service_alive_description import simpmusic.composeapp.generated.resources.keep_your_youtube_playlist_offline @@ -229,6 +235,7 @@ import simpmusic.composeapp.generated.resources.keep_your_youtube_playlist_offli import simpmusic.composeapp.generated.resources.kill_service_on_exit import simpmusic.composeapp.generated.resources.kill_service_on_exit_description import simpmusic.composeapp.generated.resources.language +import simpmusic.composeapp.generated.resources.last_backup import simpmusic.composeapp.generated.resources.last_checked_at import simpmusic.composeapp.generated.resources.limit_player_cache import simpmusic.composeapp.generated.resources.log_in_to_discord @@ -241,6 +248,8 @@ import simpmusic.composeapp.generated.resources.lyrics import simpmusic.composeapp.generated.resources.main_lyrics_provider import simpmusic.composeapp.generated.resources.manage_your_youtube_accounts import simpmusic.composeapp.generated.resources.maxrave_dev +import simpmusic.composeapp.generated.resources.monthly +import simpmusic.composeapp.generated.resources.never import simpmusic.composeapp.generated.resources.no_account import simpmusic.composeapp.generated.resources.normalize_volume import simpmusic.composeapp.generated.resources.open_system_equalizer @@ -301,6 +310,7 @@ import simpmusic.composeapp.generated.resources.version_format import simpmusic.composeapp.generated.resources.video_download_quality import simpmusic.composeapp.generated.resources.video_quality import simpmusic.composeapp.generated.resources.warning +import simpmusic.composeapp.generated.resources.weekly import simpmusic.composeapp.generated.resources.what_segments_will_be_skipped import simpmusic.composeapp.generated.resources.you_can_see_the_content_below_the_bottom_bar import simpmusic.composeapp.generated.resources.youtube_account @@ -375,6 +385,7 @@ fun SettingScreen( val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() val videoDownloadQuality by viewModel.videoDownloadQuality.collectAsStateWithLifecycle() val keepYoutubePlaylistOffline by viewModel.keepYouTubePlaylistOffline.collectAsStateWithLifecycle() + val localTrackingEnabled by viewModel.localTrackingEnabled.collectAsStateWithLifecycle(initialValue = false) val combineLocalAndYouTubeLiked by viewModel.combineLocalAndYouTubeLiked.collectAsStateWithLifecycle() val playVideo by viewModel.playVideoInsteadOfAudio.map { it == TRUE }.collectAsStateWithLifecycle(initialValue = false) val videoQuality by viewModel.videoQuality.collectAsStateWithLifecycle() @@ -414,6 +425,10 @@ fun SettingScreen( val helpBuildLyricsDatabase by viewModel.helpBuildLyricsDatabase.collectAsStateWithLifecycle() val contributor by viewModel.contributor.collectAsStateWithLifecycle() val backupDownloaded by viewModel.backupDownloaded.collectAsStateWithLifecycle() + val autoBackupEnabled by viewModel.autoBackupEnabled.collectAsStateWithLifecycle() + val autoBackupFrequency by viewModel.autoBackupFrequency.collectAsStateWithLifecycle() + val autoBackupMaxFiles by viewModel.autoBackupMaxFiles.collectAsStateWithLifecycle() + val autoBackupLastTime by viewModel.autoBackupLastTime.collectAsStateWithLifecycle() val updateChannel by viewModel.updateChannel.collectAsStateWithLifecycle() val enableLiquidGlass by viewModel.enableLiquidGlass.collectAsStateWithLifecycle() val discordLoggedIn by viewModel.discordLoggedIn.collectAsStateWithLifecycle() @@ -704,6 +719,11 @@ fun SettingScreen( subtitle = stringResource(Res.string.keep_your_youtube_playlist_offline_description), switch = (keepYoutubePlaylistOffline to { viewModel.setKeepYouTubePlaylistOffline(it) }), ) + SettingItem( + title = "Local tracking listening history", + subtitle = "Log your listening history to local database", + switch = (localTrackingEnabled to { viewModel.setLocalTrackingEnabled(it) }), + ) /* SettingItem( title = stringResource(Res.string.combine_local_and_youtube_liked_songs), @@ -1641,6 +1661,86 @@ fun SettingScreen( subtitle = stringResource(Res.string.backup_downloaded_description), switch = (backupDownloaded to { viewModel.setBackupDownloaded(it) }), ) + // Auto Backup (Android only) + if (getPlatform() == Platform.Android) { + SettingItem( + title = stringResource(Res.string.auto_backup), + subtitle = stringResource(Res.string.auto_backup_description), + switch = (autoBackupEnabled to { viewModel.setAutoBackupEnabled(it) }), + ) + AnimatedVisibility(visible = autoBackupEnabled) { + Column { + SettingItem( + title = stringResource(Res.string.backup_frequency), + subtitle = when (autoBackupFrequency) { + DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY -> stringResource(Res.string.daily) + DataStoreManager.AUTO_BACKUP_FREQUENCY_WEEKLY -> stringResource(Res.string.weekly) + DataStoreManager.AUTO_BACKUP_FREQUENCY_MONTHLY -> stringResource(Res.string.monthly) + else -> stringResource(Res.string.daily) + }, + onClick = { + viewModel.setAlertData( + SettingAlertState( + title = runBlocking { getString(Res.string.backup_frequency) }, + selectOne = SettingAlertState.SelectData( + listSelect = listOf( + (autoBackupFrequency == DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY) to runBlocking { getString(Res.string.daily) }, + (autoBackupFrequency == DataStoreManager.AUTO_BACKUP_FREQUENCY_WEEKLY) to runBlocking { getString(Res.string.weekly) }, + (autoBackupFrequency == DataStoreManager.AUTO_BACKUP_FREQUENCY_MONTHLY) to runBlocking { getString(Res.string.monthly) }, + ), + ), + confirm = runBlocking { getString(Res.string.change) } to { state -> + val frequency = when (state.selectOne?.getSelected()) { + runBlocking { getString(Res.string.daily) } -> DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY + runBlocking { getString(Res.string.weekly) } -> DataStoreManager.AUTO_BACKUP_FREQUENCY_WEEKLY + runBlocking { getString(Res.string.monthly) } -> DataStoreManager.AUTO_BACKUP_FREQUENCY_MONTHLY + else -> DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY + } + viewModel.setAutoBackupFrequency(frequency) + }, + dismiss = runBlocking { getString(Res.string.cancel) }, + ), + ) + }, + ) + SettingItem( + title = stringResource(Res.string.keep_backups), + subtitle = stringResource(Res.string.keep_backups_format, "$autoBackupMaxFiles"), + onClick = { + viewModel.setAlertData( + SettingAlertState( + title = runBlocking { getString(Res.string.keep_backups) }, + selectOne = SettingAlertState.SelectData( + listSelect = listOf( + (autoBackupMaxFiles == 3) to "3", + (autoBackupMaxFiles == 5) to "5", + (autoBackupMaxFiles == 10) to "10", + (autoBackupMaxFiles == 15) to "15", + ), + ), + confirm = runBlocking { getString(Res.string.change) } to { state -> + val maxFiles = state.selectOne?.getSelected()?.toIntOrNull() ?: 5 + viewModel.setAutoBackupMaxFiles(maxFiles) + }, + dismiss = runBlocking { getString(Res.string.cancel) }, + ), + ) + }, + ) + SettingItem( + title = stringResource(Res.string.last_backup), + subtitle = if (autoBackupLastTime == 0L) { + stringResource(Res.string.never) + } else { + DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(autoBackupLastTime)) + }, + ) + } + } + } SettingItem( title = stringResource(Res.string.backup), subtitle = stringResource(Res.string.save_all_your_playlist_data), diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/analytics/AnalyticsScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/analytics/AnalyticsScreen.kt new file mode 100644 index 000000000..cd4c761dd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/analytics/AnalyticsScreen.kt @@ -0,0 +1,949 @@ +package com.maxrave.simpmusic.ui.screen.home.analytics + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.maxrave.common.Config +import com.maxrave.domain.data.entities.SongEntity +import com.maxrave.domain.mediaservice.handler.PlaylistType +import com.maxrave.domain.mediaservice.handler.QueueData +import com.maxrave.domain.utils.LocalResource +import com.maxrave.domain.utils.connectArtists +import com.maxrave.domain.utils.toArrayListTrack +import com.maxrave.domain.utils.toTrack +import com.maxrave.logger.Logger +import com.maxrave.simpmusic.extension.getScreenSizeInfo +import com.maxrave.simpmusic.extension.getStringBlocking +import com.maxrave.simpmusic.ui.component.CenterLoadingBox +import com.maxrave.simpmusic.ui.component.EndOfPage +import com.maxrave.simpmusic.ui.component.FiveImagesComponent +import com.maxrave.simpmusic.ui.component.ImageData +import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet +import com.maxrave.simpmusic.ui.component.SongFullWidthItems +import com.maxrave.simpmusic.ui.navigation.destination.home.RecentlySongsDestination +import com.maxrave.simpmusic.ui.navigation.destination.library.LibraryDynamicPlaylistDestination +import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination +import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination +import com.maxrave.simpmusic.ui.screen.library.LibraryDynamicPlaylistType +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.AnalyticsUiState +import com.maxrave.simpmusic.viewModel.AnalyticsViewModel +import com.maxrave.simpmusic.viewModel.SharedViewModel +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.flow.map +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.char +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.artists +import simpmusic.composeapp.generated.resources.date_range +import simpmusic.composeapp.generated.resources.last_30_days +import simpmusic.composeapp.generated.resources.last_7_days +import simpmusic.composeapp.generated.resources.last_90_days +import simpmusic.composeapp.generated.resources.lower_plays +import simpmusic.composeapp.generated.resources.more +import simpmusic.composeapp.generated.resources.no_data_analytics +import simpmusic.composeapp.generated.resources.seconds +import simpmusic.composeapp.generated.resources.songs_played +import simpmusic.composeapp.generated.resources.this_year +import simpmusic.composeapp.generated.resources.top_song +import simpmusic.composeapp.generated.resources.total_listened_time +import simpmusic.composeapp.generated.resources.your_recently_played +import simpmusic.composeapp.generated.resources.your_top_albums +import simpmusic.composeapp.generated.resources.your_top_artists +import simpmusic.composeapp.generated.resources.your_top_tracks + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun AnalyticsScreen( + innerPadding: PaddingValues, + navController: NavController, + analyticsViewModel: AnalyticsViewModel = koinViewModel(), + sharedViewModel: SharedViewModel = koinInject(), +) { + val density = LocalDensity.current + val screenSizeInfo = getScreenSizeInfo() + val uiState by analyticsViewModel.analyticsUIState.collectAsStateWithLifecycle() + val playingTrack by sharedViewModel.nowPlayingState.map { it?.track?.videoId }.collectAsState(null) + + var currentItem by remember { + mutableStateOf(null) + } + var itemBottomSheetShow by remember { + mutableStateOf(false) + } + + val onItemMoreClick: (song: SongEntity) -> Unit = { + currentItem = it + itemBottomSheetShow = true + } + + val onArtistClick: (channelId: String) -> Unit = { + navController.navigate( + ArtistDestination( + channelId = it, + ), + ) + } + + val onAlbumClick: (browseId: String) -> Unit = { + navController.navigate( + AlbumDestination( + browseId = it, + ), + ) + } + + if (itemBottomSheetShow && currentItem != null) { + val track = currentItem ?: return + NowPlayingBottomSheet( + onDismiss = { + itemBottomSheetShow = false + currentItem = null + }, + navController = navController, + song = track, + ) + } + + LaunchedEffect(uiState) { + Logger.d( + "AnalyticsScreen", + "UI State updated: ${uiState.scrobblesCount.data}, ${uiState.artistCount.data}, ${uiState.totalListenTimeInSeconds.data}", + ) + Logger.d("AnalyticsScreen", "Top Tracks: ${uiState.topTracks.data?.joinToString { it.second.title }}") + Logger.d("AnalyticsScreen", "Top Artists: ${uiState.topArtists.data?.joinToString { it.second.name }}") + Logger.d("AnalyticsScreen", "Top Albums: ${uiState.topAlbums.data?.joinToString { it.second.title }}") + Logger.d("AnalyticsScreen", "Recently Played: ${uiState.recentlyRecord.data?.joinToString { it.second.title }}") + Logger.d("AnalyticsScreen", "Scrobbles line chart data: ${uiState.scrobblesLineChart.data}") + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = + Modifier + .fillMaxSize(), + ) { + // Header item + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .height((screenSizeInfo.hDP / 2.5).dp), + ) { + when (val topTracks = uiState.topTracks) { + is LocalResource.Success if (!topTracks.data.isNullOrEmpty()) -> { + val topTrack = topTracks.data?.firstOrNull() ?: return@Box + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(topTrack.second.thumbnails) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(topTrack.second.thumbnails) + .crossfade(550) + .build(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = + Modifier + .align(Alignment.Center) + .fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy( + alpha = 0.8f, + ), + Color.Black, + ), + startY = (screenSizeInfo.hPX / 2.5f) * 3 / 4, // Gradient applied to wrap the title only + ), + ), + ) + Column( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(Res.string.top_song), + style = typo().titleLarge, + color = Color.White, + maxLines = 1, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + topTrack.second.title, + style = typo().labelMedium, + color = Color.White, + maxLines = 1, + ) + Text( + topTrack.second.artistName?.connectArtists() ?: "", + style = typo().bodyMedium, + maxLines = 1, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Listened time", + style = typo().bodyMedium, + color = Color.White, + maxLines = 1, + ) + Text( + "${topTrack.first.totalListeningTime} seconds", + style = typo().bodyLarge, + maxLines = 1, + ) + } + } + } + } + + is LocalResource.Loading -> { + CenterLoadingBox( + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + Box(Modifier.fillMaxSize()) { + Text( + stringResource(Res.string.no_data_analytics), + modifier = Modifier.align(Alignment.Center), + style = typo().bodyLarge, + ) + } + } + } + } + } + + item { + Row(Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + when (val scrobblesCount = uiState.scrobblesCount) { + is LocalResource.Success -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(Res.string.songs_played), + style = typo().bodyMedium, + textDecoration = TextDecoration.Underline, + color = Color.White, + maxLines = 1, + ) + Text( + "${scrobblesCount.data ?: 0}", + style = typo().bodyLarge, + maxLines = 1, + ) + } + } + + else -> {} + } + + when (val artistCount = uiState.artistCount) { + is LocalResource.Success -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(Res.string.artists), + style = typo().bodyMedium, + textDecoration = TextDecoration.Underline, + color = Color.White, + maxLines = 1, + ) + Text( + "${artistCount.data ?: 0}", + style = typo().bodyLarge, + maxLines = 1, + ) + } + } + + else -> {} + } + + when (val totalPlayedTime = uiState.totalListenTimeInSeconds) { + is LocalResource.Success -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(Res.string.total_listened_time), + style = typo().bodyMedium, + textDecoration = TextDecoration.Underline, + color = Color.White, + maxLines = 1, + ) + Text( + "${totalPlayedTime.data ?: 0} ${stringResource(Res.string.seconds)}", + style = typo().bodyLarge, + maxLines = 1, + ) + } + } + + else -> {} + } + } + } + + item { + when (val recentlyRecord = uiState.recentlyRecord) { + is LocalResource.Success if (!recentlyRecord.data.isNullOrEmpty()) -> { + val records = recentlyRecord.data ?: return@item + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(Res.string.your_recently_played), + style = typo().labelMedium, + color = Color.White, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + navController.navigate(RecentlySongsDestination) + }, + colors = + ButtonDefaults + .textButtonColors() + .copy( + contentColor = Color.White, + ), + ) { + Text(stringResource(Res.string.more), style = typo().bodySmall) + } + } + + records.forEach { pair -> + val song = pair.second.toTrack() + SongFullWidthItems( + track = song, + isPlaying = song.videoId == playingTrack, + modifier = Modifier.fillMaxWidth(), + onMoreClickListener = { + onItemMoreClick(pair.second) + }, + onClickListener = { + val targetList = records.map { it.second } + val playTrack = pair.second + with(sharedViewModel) { + setQueueData( + QueueData.Data( + listTracks = targetList.toArrayListTrack(), + firstPlayedTrack = playTrack.toTrack(), + playlistId = null, + playlistName = getStringBlocking(Res.string.your_recently_played), + playlistType = PlaylistType.RADIO, + continuation = null, + ), + ) + loadMediaItem( + playTrack.toTrack(), + Config.PLAYLIST_CLICK, + targetList.indexOf(playTrack).coerceAtLeast(0), + ) + } + }, + onAddToQueue = { + sharedViewModel.addListToQueue( + arrayListOf(song), + ) + }, + rightView = { + Text( + text = + pair.first.timestamp.format( + LocalDateTime.Format { + hour() + char(':') + minute() + chars(" - ") + day() + char(' ') + monthName( + MonthNames.ENGLISH_FULL, + ) + char(' ') + year() + }, + ), + style = typo().bodySmall, + ) + }, + ) + } + } + } + + else -> {} + } + } + + item { + // Top artists + when (uiState.topArtists) { + is LocalResource.Success if (!uiState.topArtists.data.isNullOrEmpty()) -> { + val artists = + uiState.topArtists.data?.let { + if (it.size > 5) it.subList(0, 5) else it + } ?: return@item + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(Res.string.your_top_artists), + style = typo().labelMedium, + color = Color.White, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + navController.navigate( + LibraryDynamicPlaylistDestination( + type = LibraryDynamicPlaylistType.TopArtists.toStringParams(), + ), + ) + }, + colors = + ButtonDefaults + .textButtonColors() + .copy( + contentColor = Color.White, + ), + ) { + Text(stringResource(Res.string.more), style = typo().bodySmall) + } + } + FiveImagesComponent( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + images = + artists.map { + ImageData( + imageUrl = it.second.thumbnails ?: "", + title = it.second.name, + subtitle = "${it.first.playCount} ${stringResource(Res.string.lower_plays)}", + onClick = { + onArtistClick(it.second.channelId) + }, + ) + }, + ) + } + } + + else -> {} + } + } + + item { + // Top albums + when (uiState.topAlbums) { + is LocalResource.Success if (!uiState.topAlbums.data.isNullOrEmpty()) -> { + val albums = + uiState.topAlbums.data?.let { + if (it.size > 5) it.subList(0, 5) else it + } ?: return@item + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(Res.string.your_top_albums), + style = typo().labelMedium, + color = Color.White, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + navController.navigate( + LibraryDynamicPlaylistDestination( + type = LibraryDynamicPlaylistType.TopAlbums.toStringParams(), + ), + ) + }, + colors = + ButtonDefaults + .textButtonColors() + .copy( + contentColor = Color.White, + ), + ) { + Text(stringResource(Res.string.more), style = typo().bodySmall) + } + } + FiveImagesComponent( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + images = + albums.map { + ImageData( + imageUrl = it.second.thumbnails ?: "", + title = it.second.title, + subtitle = it.second.artistName?.connectArtists() ?: "", + thirdTitle = "${it.first.playCount} ${stringResource(Res.string.lower_plays)}", + onClick = { + onAlbumClick(it.second.browseId) + }, + ) + }, + ) + } + } + + else -> {} + } + } + + item { + // Top tracks + when (val topTracks = uiState.topTracks) { + is LocalResource.Success if (!topTracks.data.isNullOrEmpty()) -> { + val tracks = + topTracks.data?.let { + if (it.size > 5) it.subList(0, 5) else it + } ?: return@item + val maxPlays = tracks.maxOf { it.first.playCount } + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(Res.string.your_top_tracks), + style = typo().labelMedium, + color = Color.White, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + navController.navigate( + LibraryDynamicPlaylistDestination( + type = LibraryDynamicPlaylistType.TopTracks.toStringParams(), + ), + ) + }, + colors = + ButtonDefaults + .textButtonColors() + .copy( + contentColor = Color.White, + ), + ) { + Text(stringResource(Res.string.more), style = typo().bodySmall) + } + } + + tracks.forEach { pair -> + val song = pair.second.toTrack() + SongFullWidthItems( + track = song, + isPlaying = song.videoId == playingTrack, + modifier = Modifier.fillMaxWidth(), + onMoreClickListener = { + onItemMoreClick(pair.second) + }, + onClickListener = { + val targetList = tracks.map { it.second } + val playTrack = pair.second + with(sharedViewModel) { + setQueueData( + QueueData.Data( + listTracks = targetList.toArrayListTrack(), + firstPlayedTrack = playTrack.toTrack(), + playlistId = null, + playlistName = getStringBlocking(Res.string.your_top_tracks), + playlistType = PlaylistType.RADIO, + continuation = null, + ), + ) + loadMediaItem( + playTrack.toTrack(), + Config.PLAYLIST_CLICK, + targetList.indexOf(playTrack).coerceAtLeast(0), + ) + } + }, + onAddToQueue = { + sharedViewModel.addListToQueue( + arrayListOf(song), + ) + }, + rightView = { + Column( + modifier = Modifier.fillMaxWidth(0.4f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "${pair.first.totalListeningTime} ${stringResource(Res.string.seconds)}", + style = typo().bodySmall, + ) + Box(Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier + .wrapContentHeight() + .fillMaxWidth(pair.first.playCount.toFloat() / maxPlays) + .clip(CircleShape) + .background(Color.DarkGray), + ) { + Text( + text = "", + style = typo().bodySmall, + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 4.dp), + ) + } + Text( + text = "${pair.first.playCount} ${stringResource(Res.string.lower_plays)}", + style = typo().bodySmall, + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 4.dp), + ) + } + } + }, + ) + } + } + } + + else -> {} + } + } + + item { + when (uiState.scrobblesLineChart) { + is LocalResource.Success if (!uiState.scrobblesLineChart.data.isNullOrEmpty()) -> { + val data = uiState.scrobblesLineChart.data ?: return@item + val maxPlays = data.maxOf { it.second } + Column( + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(Res.string.date_range), + style = typo().labelMedium, + color = Color.White, + ) + Row( + modifier = Modifier.padding(top = 12.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + data.map { it.first }.forEach { chartType -> + Box( + modifier = Modifier.height(32.dp), + contentAlignment = Alignment.CenterStart, + ) { + when (chartType) { + is AnalyticsUiState.ChartType.Day -> { + Text( + text = + chartType.day.format( + LocalDate.Format { + day() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + year() + }, + ), + style = typo().bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + + is AnalyticsUiState.ChartType.Month -> { + Text( + text = "${chartType.month} - ${chartType.year}", + style = typo().bodySmall, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + data.map { it.second }.forEach { playCount -> + Box( + modifier = Modifier.height(32.dp), + ) { + Box(Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier + .fillMaxHeight() + .fillMaxWidth(playCount.toFloat() / maxPlays) + .padding(vertical = 4.dp) + .clip(CircleShape) + .background(Color.DarkGray), + ) { + Text( + text = "", + style = typo().bodySmall, + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 4.dp), + ) + } + Text( + text = "$playCount ${stringResource(Res.string.lower_plays)}", + style = typo().bodySmall, + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 8.dp), + ) + } + } + } + } + } + } + } + + else -> {} + } + } + + item { + EndOfPage() + } + } + + var dayRangeMenuExpanded by rememberSaveable { mutableStateOf(false) } + // Top App Bar with haze effect + TopAppBar( + modifier = + Modifier + .align(Alignment.TopCenter), + title = {}, + navigationIcon = { + Box( + modifier = + Modifier + .clip(CircleShape) + .wrapContentSize() + .align(Alignment.TopStart) + .padding( + 12.dp, + ), + ) { + IconButton( + onClick = { navController.navigateUp() }, + colors = + IconButtonDefaults.iconButtonColors().copy( + containerColor = + Color.DarkGray.copy( + alpha = 0.8f, + ), + contentColor = + Color.White.copy( + alpha = 0.6f, + ), + ), + ) { + Icon(Icons.Default.ArrowBackIosNew, "Back") + } + } + }, + actions = { + Box( + modifier = + Modifier + .clip(CircleShape) + .wrapContentSize() + .padding( + 12.dp, + ), + ) { + IconButton( + onClick = { + dayRangeMenuExpanded = !dayRangeMenuExpanded + }, + colors = + IconButtonDefaults.iconButtonColors().copy( + containerColor = + Color.DarkGray.copy( + alpha = 0.8f, + ), + contentColor = + Color.White.copy( + alpha = 0.6f, + ), + ), + ) { + Box { + Icon(Icons.Rounded.CalendarToday, "Analytics", tint = Color.White) + Text( + when (uiState.dayRange) { + AnalyticsUiState.DayRange.LAST_7_DAYS -> "7d" + AnalyticsUiState.DayRange.LAST_30_DAYS -> "30d" + AnalyticsUiState.DayRange.LAST_90_DAYS -> "90d" + AnalyticsUiState.DayRange.THIS_YEAR -> "1y" + }, + style = typo().bodySmall.copy(fontSize = 8.sp), + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } + } + DropdownMenu( + expanded = dayRangeMenuExpanded, + onDismissRequest = { dayRangeMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.last_7_days), style = typo().labelSmall) }, + onClick = { + analyticsViewModel.setDayRange(AnalyticsUiState.DayRange.LAST_7_DAYS) + dayRangeMenuExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.last_30_days), style = typo().labelSmall) }, + onClick = { + analyticsViewModel.setDayRange(AnalyticsUiState.DayRange.LAST_30_DAYS) + dayRangeMenuExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.last_90_days), style = typo().labelSmall) }, + onClick = { + analyticsViewModel.setDayRange(AnalyticsUiState.DayRange.LAST_90_DAYS) + dayRangeMenuExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.this_year), style = typo().labelSmall) }, + onClick = { + analyticsViewModel.setDayRange(AnalyticsUiState.DayRange.THIS_YEAR) + dayRangeMenuExpanded = false + }, + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryDynamicPlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryDynamicPlaylistScreen.kt index e8427fd70..fe742d5c2 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryDynamicPlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryDynamicPlaylistScreen.kt @@ -1,6 +1,7 @@ package com.maxrave.simpmusic.ui.screen.library import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -36,17 +38,26 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import com.maxrave.common.Config import com.maxrave.domain.data.entities.ArtistEntity import com.maxrave.domain.data.entities.SongEntity +import com.maxrave.domain.mediaservice.handler.PlaylistType +import com.maxrave.domain.mediaservice.handler.QueueData +import com.maxrave.domain.utils.LocalResource +import com.maxrave.domain.utils.toArrayListTrack import com.maxrave.domain.utils.toTrack import com.maxrave.logger.Logger +import com.maxrave.simpmusic.extension.getStringBlocking import com.maxrave.simpmusic.ui.component.ArtistFullWidthItems import com.maxrave.simpmusic.ui.component.EndOfPage import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet +import com.maxrave.simpmusic.ui.component.PlaylistFullWidthItems import com.maxrave.simpmusic.ui.component.RippleIconButton import com.maxrave.simpmusic.ui.component.SongFullWidthItems +import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.AnalyticsViewModel import com.maxrave.simpmusic.viewModel.LibraryDynamicPlaylistViewModel import com.maxrave.simpmusic.viewModel.SharedViewModel import dev.chrisbanes.haze.hazeEffect @@ -65,8 +76,13 @@ import simpmusic.composeapp.generated.resources.baseline_search_24 import simpmusic.composeapp.generated.resources.downloaded import simpmusic.composeapp.generated.resources.favorite import simpmusic.composeapp.generated.resources.followed +import simpmusic.composeapp.generated.resources.lower_plays import simpmusic.composeapp.generated.resources.most_played import simpmusic.composeapp.generated.resources.search +import simpmusic.composeapp.generated.resources.seconds +import simpmusic.composeapp.generated.resources.your_top_albums +import simpmusic.composeapp.generated.resources.your_top_artists +import simpmusic.composeapp.generated.resources.your_top_tracks @OptIn(ExperimentalHazeMaterialsApi::class) @Composable @@ -76,6 +92,7 @@ fun LibraryDynamicPlaylistScreen( navController: NavController, type: String, viewModel: LibraryDynamicPlaylistViewModel = koinViewModel(), + analyticsViewModel: AnalyticsViewModel = koinViewModel(), sharedViewModel: SharedViewModel = koinInject(), ) { val nowPlayingVideoId by viewModel.nowPlayingVideoId.collectAsStateWithLifecycle() @@ -93,6 +110,10 @@ fun LibraryDynamicPlaylistScreen( var tempMostPlayed by rememberSaveable { mutableStateOf(emptyList()) } val downloaded by viewModel.listDownloadedSong.collectAsStateWithLifecycle() var tempDownloaded by rememberSaveable { mutableStateOf(emptyList()) } + val analyticsUIState by analyticsViewModel.analyticsUIState.collectAsStateWithLifecycle() + var tempTopTracks by rememberSaveable { mutableStateOf(analyticsUIState.topTracks.data ?: emptyList()) } + var tempTopArtists by rememberSaveable { mutableStateOf(analyticsUIState.topArtists.data ?: emptyList()) } + var tempTopAlbums by rememberSaveable { mutableStateOf(analyticsUIState.topAlbums.data ?: emptyList()) } val hazeState = rememberHazeState( blurEnabled = true, @@ -108,6 +129,21 @@ fun LibraryDynamicPlaylistScreen( Logger.w("LibraryDynamicPlaylistScreen", "Check tempMostPlayed: $tempMostPlayed") tempDownloaded = downloaded.filter { it.title.contains(query, ignoreCase = true) } Logger.w("LibraryDynamicPlaylistScreen", "Check tempDownloaded: $tempDownloaded") + tempTopTracks = + analyticsUIState.topTracks.data + ?.filter { it.second.title.contains(query, ignoreCase = true) } + ?: emptyList() + Logger.w("LibraryDynamicPlaylistScreen", "Check tempTopTracks: $tempTopTracks") + tempTopArtists = + analyticsUIState.topArtists.data + ?.filter { it.second.name.contains(query, ignoreCase = true) } + ?: emptyList() + Logger.w("LibraryDynamicPlaylistScreen", "Check tempTopArtists: $tempTopArtists") + tempTopAlbums = + analyticsUIState.topAlbums.data + ?.filter { it.second.title.contains(query, ignoreCase = true) } + ?: emptyList() + Logger.w("LibraryDynamicPlaylistScreen", "Check tempTopAlbums: $tempTopAlbums") } LazyColumn( @@ -143,31 +179,169 @@ fun LibraryDynamicPlaylistScreen( }, ) } + } else if (type == LibraryDynamicPlaylistType.TopArtists) { + when (analyticsUIState.topArtists) { + is LocalResource.Success if (!analyticsUIState.topArtists.data.isNullOrEmpty()) -> { + val data = analyticsUIState.topArtists.data ?: emptyList() + items( + if (query.isNotEmpty() && showSearchBar) { + tempTopArtists + } else { + data + }, + key = { it.first.hashCode() }, + ) { artist -> + ArtistFullWidthItems( + artist.second, + rightView = { + Box(Modifier.padding(horizontal = 8.dp)) { + Text( + text = "${artist.first.playCount} ${stringResource(Res.string.lower_plays)}", + style = typo().bodySmall, + ) + } + }, + onClickListener = { + navController.navigate( + ArtistDestination( + channelId = artist.second.channelId, + ), + ) + }, + ) + } + } + + else -> {} + } + } else if (type == LibraryDynamicPlaylistType.TopAlbums) { + when (analyticsUIState.topAlbums) { + is LocalResource.Success if (!analyticsUIState.topAlbums.data.isNullOrEmpty()) -> { + val data = analyticsUIState.topAlbums.data ?: emptyList() + items( + if (query.isNotEmpty() && showSearchBar) { + tempTopAlbums + } else { + data + }, + key = { it.first.hashCode() }, + ) { album -> + PlaylistFullWidthItems( + album.second, + rightView = { + Box(Modifier.padding(horizontal = 8.dp)) { + Text( + text = "${album.first.playCount} ${stringResource(Res.string.lower_plays)}", + style = typo().bodySmall, + ) + } + }, + onClickListener = { + navController.navigate( + AlbumDestination( + browseId = album.second.browseId, + ), + ) + }, + ) + } + } + + else -> {} + } + } else if (type == LibraryDynamicPlaylistType.TopTracks) { + when (analyticsUIState.topTracks) { + is LocalResource.Success if (!analyticsUIState.topTracks.data.isNullOrEmpty()) -> { + val data = analyticsUIState.topTracks.data ?: emptyList() + items( + if (query.isNotEmpty() && showSearchBar) { + tempTopTracks + } else { + data + }, + key = { it.hashCode() }, + ) { song -> + SongFullWidthItems( + songEntity = song.second, + isPlaying = song.second.videoId == nowPlayingVideoId, + modifier = Modifier.fillMaxWidth(), + onMoreClickListener = { + chosenSong = song.second + showBottomSheet = true + }, + onClickListener = { videoId -> + val targetList = data.map { it.second } + val playTrack = song.second + with(sharedViewModel) { + setQueueData( + QueueData.Data( + listTracks = targetList.toArrayListTrack(), + firstPlayedTrack = playTrack.toTrack(), + playlistId = null, + playlistName = getStringBlocking(Res.string.your_top_tracks), + playlistType = PlaylistType.RADIO, + continuation = null, + ), + ) + loadMediaItem( + playTrack.toTrack(), + Config.PLAYLIST_CLICK, + targetList.indexOf(playTrack).coerceAtLeast(0), + ) + } + }, + onAddToQueue = { + sharedViewModel.addListToQueue( + arrayListOf(song.second.toTrack()), + ) + }, + rightView = { + Column( + modifier = Modifier.wrapContentWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "${song.first.totalListeningTime} ${stringResource(Res.string.seconds)}", + style = typo().bodySmall, + ) + Text( + text = "${song.first.playCount} ${stringResource(Res.string.lower_plays)}", + style = typo().bodySmall, + ) + } + }, + ) + } + } + + else -> {} + } } else { items( when (type) { - LibraryDynamicPlaylistType.Downloaded -> + LibraryDynamicPlaylistType.Downloaded -> { if (query.isNotEmpty() && showSearchBar) { tempDownloaded } else { downloaded } + } - LibraryDynamicPlaylistType.Favorite -> + LibraryDynamicPlaylistType.Favorite -> { if (query.isNotEmpty() && showSearchBar) { tempFavorite } else { favorite } + } - LibraryDynamicPlaylistType.MostPlayed -> + LibraryDynamicPlaylistType.MostPlayed -> { if (query.isNotEmpty() && showSearchBar) { tempMostPlayed } else { mostPlayed } - - else -> emptyList() + } }, key = { it.hashCode() }, ) { song -> @@ -295,12 +469,21 @@ sealed class LibraryDynamicPlaylistType { data object Downloaded : LibraryDynamicPlaylistType() + data object TopTracks : LibraryDynamicPlaylistType() + + data object TopArtists : LibraryDynamicPlaylistType() + + data object TopAlbums : LibraryDynamicPlaylistType() + fun name(): StringResource = when (this) { Favorite -> Res.string.favorite Followed -> Res.string.followed MostPlayed -> Res.string.most_played Downloaded -> Res.string.downloaded + TopAlbums -> Res.string.your_top_albums + TopArtists -> Res.string.your_top_artists + TopTracks -> Res.string.your_top_tracks } // For serialization and navigation @@ -310,6 +493,9 @@ sealed class LibraryDynamicPlaylistType { Followed -> "followed" MostPlayed -> "most_played" Downloaded -> "downloaded" + TopAlbums -> "top_albums" + TopArtists -> "top_artists" + TopTracks -> "top_tracks" } companion object { @@ -319,6 +505,9 @@ sealed class LibraryDynamicPlaylistType { "followed" -> Followed "most_played" -> MostPlayed "downloaded" -> Downloaded + "top_albums" -> TopAlbums + "top_artists" -> TopArtists + "top_tracks" -> TopTracks else -> throw IllegalArgumentException("Unknown type: $this") } } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryScreen.kt index ba21c35d9..7c43bdc7a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -24,9 +25,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoGraph import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -49,6 +54,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import coil3.compose.AsyncImage @@ -67,6 +73,7 @@ import com.maxrave.simpmusic.ui.component.LibraryItem import com.maxrave.simpmusic.ui.component.LibraryItemState import com.maxrave.simpmusic.ui.component.LibraryItemType import com.maxrave.simpmusic.ui.component.LibraryTilingBox +import com.maxrave.simpmusic.ui.navigation.destination.home.AnalyticsDestination import com.maxrave.simpmusic.ui.theme.transparent import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.LibraryViewModel @@ -415,12 +422,31 @@ fun LibraryScreen( TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, ), + actions = { + IconButton( + onClick = { + navController.navigate(AnalyticsDestination) + }, + ) { + Box { + Icon(Icons.Rounded.AutoGraph, "Analytics", tint = Color.White) + Text( + "NEW", + Modifier.align(Alignment.BottomEnd), + style = + typo().bodySmall.copy( + fontSize = 5.sp, + ), + ) + } + } + }, navigationIcon = { AnimatedVisibility( !accountThumbnail.isNullOrEmpty(), modifier = Modifier.padding(horizontal = 12.dp), enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkVertically() + exit = fadeOut() + shrinkVertically(), ) { AsyncImage( model = @@ -438,7 +464,7 @@ fun LibraryScreen( .clip(CircleShape), ) } - } + }, ) Row( modifier = diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt index 6bae6e8e9..d89319d77 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt @@ -100,6 +100,7 @@ fun SpotifyLoginScreen( } val state = rememberWebViewState() + val cookieManager = createWebViewCookieManager() Box(modifier = Modifier.fillMaxSize().hazeSource(state = hazeState)) { Column { @@ -160,27 +161,25 @@ fun SpotifyLoginScreen( } }, ) { url -> - createWebViewCookieManager() - .getCookie(url) - .takeIf { - it.isNotEmpty() - }?.let { cookie -> - val cookies = - cookie.split("; ").map { - val (key, value) = it.split("=") - key to value - } - viewModel.setFullSpotifyCookies(cookies) - } - if (url == Config.SPOTIFY_ACCOUNT_URL) { - createWebViewCookieManager() - .getCookie(url) + val cookie = cookieManager.getCookie(url) + cookie.takeIf { + it.isNotEmpty() + }?.let { cookie -> + val cookies = + cookie.split("; ").map { + val (key, value) = it.split("=") + key to value + } + viewModel.setFullSpotifyCookies(cookies) + } + if (Regex("^https://accounts\\.spotify\\.com/[a-z]{2}(-[a-zA-Z]{2})?/status$").matches(url)) { + cookie .takeIf { it.isNotEmpty() }?.let { viewModel.saveSpotifySpdc(it) } - createWebViewCookieManager().removeAllCookies() + cookieManager.removeAllCookies() } } } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index 4fc7c94e6..fa6a17e69 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt @@ -56,11 +56,14 @@ import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.SubtitlesOff import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material.icons.rounded.AddCircleOutline import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Forward5 import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.Replay5 +import androidx.compose.material.icons.rounded.ThumbUp +import androidx.compose.material.icons.rounded.ThumbsUpDown import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -125,6 +128,7 @@ import com.kmpalette.rememberPaletteState import com.maxrave.common.Config.MAIN_PLAYER import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform +import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.MediaPlayerView import com.maxrave.simpmusic.expect.ui.MediaPlayerViewWithSubtitle import com.maxrave.simpmusic.extension.GradientAngle @@ -133,6 +137,7 @@ import com.maxrave.simpmusic.extension.KeepScreenOn import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette import com.maxrave.simpmusic.extension.getScreenSizeInfo +import com.maxrave.simpmusic.extension.getStringBlocking import com.maxrave.simpmusic.extension.isElementVisible import com.maxrave.simpmusic.extension.parseTimestampToMilliseconds import com.maxrave.simpmusic.extension.rememberIsInPipMode @@ -148,6 +153,7 @@ import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout import com.maxrave.simpmusic.ui.component.QueueBottomSheet +import com.maxrave.simpmusic.ui.component.VoteLyricsDialog import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination import com.maxrave.simpmusic.ui.theme.blackMoreOverlay @@ -175,6 +181,7 @@ import simpmusic.composeapp.generated.resources.artists import simpmusic.composeapp.generated.resources.baseline_fullscreen_24 import simpmusic.composeapp.generated.resources.baseline_more_vert_24 import simpmusic.composeapp.generated.resources.description +import simpmusic.composeapp.generated.resources.downvote import simpmusic.composeapp.generated.resources.holder import simpmusic.composeapp.generated.resources.holder_video import simpmusic.composeapp.generated.resources.like_and_dislike @@ -186,11 +193,16 @@ import simpmusic.composeapp.generated.resources.lyrics_provider_youtube import simpmusic.composeapp.generated.resources.now_playing_upper import simpmusic.composeapp.generated.resources.offline_mode import simpmusic.composeapp.generated.resources.published_at +import simpmusic.composeapp.generated.resources.rate_lyrics +import simpmusic.composeapp.generated.resources.rate_translated_lyrics import simpmusic.composeapp.generated.resources.rich_synced import simpmusic.composeapp.generated.resources.show import simpmusic.composeapp.generated.resources.spotify_lyrics_provider import simpmusic.composeapp.generated.resources.unsynced +import simpmusic.composeapp.generated.resources.upvote import simpmusic.composeapp.generated.resources.view_count +import simpmusic.composeapp.generated.resources.vote_error +import simpmusic.composeapp.generated.resources.vote_submitted import kotlin.math.roundToLong private const val TAG = "NowPlayingScreen" @@ -276,6 +288,8 @@ fun NowPlayingScreenContent( val likeStatus by sharedViewModel.likeStatus.collectAsStateWithLifecycle() val shouldShowVideo by sharedViewModel.getVideo.collectAsStateWithLifecycle() + val translatedVoteState by sharedViewModel.translatedVoteState.collectAsStateWithLifecycle() + val lyricsVoteState by sharedViewModel.lyricsVoteState.collectAsStateWithLifecycle() // State val isInPipMode = rememberIsInPipMode() @@ -301,6 +315,10 @@ fun NowPlayingScreenContent( mutableStateOf(false) } + var showVoteDialog by rememberSaveable { + mutableStateOf(false) + } + var shouldShowToolbar by remember { mutableStateOf(false) } @@ -515,6 +533,37 @@ fun NowPlayingScreenContent( ) } + // Vote Dialog + if (showVoteDialog) { + val canVoteLyrics = + screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC && + screenDataState.lyricsData + ?.lyrics + ?.simpMusicLyrics != null + val canVoteTranslatedLyrics = + screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && + screenDataState.lyricsData + ?.translatedLyrics + ?.first + ?.simpMusicLyrics != null + + VoteLyricsDialog( + canVoteLyrics = canVoteLyrics, + canVoteTranslatedLyrics = canVoteTranslatedLyrics, + lyricsVoteState = lyricsVoteState, + translatedLyricsVoteState = translatedVoteState, + onVoteLyrics = { upvote -> + sharedViewModel.voteLyrics(upvote) + }, + onVoteTranslatedLyrics = { upvote -> + sharedViewModel.voteTranslatedLyrics(upvote) + }, + onDismiss = { + showVoteDialog = false + }, + ) + } + val hazeState = rememberHazeState( blurEnabled = true, @@ -731,6 +780,16 @@ fun NowPlayingScreenContent( } }, actions = { + // Desktop mini player button (JVM only) + if (getPlatform() == Platform.Desktop) { + IconButton(onClick = { toggleMiniPlayer() }) { + Icon( + imageVector = Icons.Outlined.OpenInNew, + contentDescription = "Mini Player", + tint = Color.White, + ) + } + } IconButton(onClick = { showSheet = true }) { @@ -1483,6 +1542,35 @@ fun NowPlayingScreenContent( AIBadge() } Spacer(modifier = Modifier.weight(1f)) + // Vote button - only show if lyrics or translated lyrics from SimpMusic + val canVoteLyrics = + screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC && + screenDataState.lyricsData + ?.lyrics + ?.simpMusicLyrics != null + val canVoteTranslatedLyrics = + screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && + screenDataState.lyricsData + ?.translatedLyrics + ?.first + ?.simpMusicLyrics != null + if (canVoteLyrics || canVoteTranslatedLyrics) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + IconButton( + onClick = { + showVoteDialog = true + }, + ) { + Icon( + imageVector = Icons.Rounded.ThumbsUpDown, + contentDescription = stringResource(Res.string.rate_lyrics), + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { TextButton( onClick = { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/utils/ComposeResUtils.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/utils/ComposeResUtils.kt new file mode 100644 index 000000000..37064e985 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/utils/ComposeResUtils.kt @@ -0,0 +1,28 @@ +package com.maxrave.simpmusic.utils + +import org.jetbrains.compose.resources.getString +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.explicit_content_blocked +import simpmusic.composeapp.generated.resources.new_albums +import simpmusic.composeapp.generated.resources.new_singles +import simpmusic.composeapp.generated.resources.this_app_needs_to_access_your_notification +import simpmusic.composeapp.generated.resources.time_out_check_internet_connection_or_change_piped_instance_in_settings + +object ComposeResUtils { + suspend fun getResString(type: StringType): String = + when (type) { + StringType.EXPLICIT_CONTENT_BLOCKED -> getString(Res.string.explicit_content_blocked) + StringType.NOTIFICATION_REQUEST -> getString(Res.string.this_app_needs_to_access_your_notification) + StringType.TIME_OUT_ERROR -> getString(Res.string.time_out_check_internet_connection_or_change_piped_instance_in_settings) + StringType.NEW_SINGLES -> getString(Res.string.new_singles) + StringType.NEW_ALBUMS -> getString(Res.string.new_albums) + } + + enum class StringType { + EXPLICIT_CONTENT_BLOCKED, + NOTIFICATION_REQUEST, + TIME_OUT_ERROR, + NEW_SINGLES, + NEW_ALBUMS, + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt new file mode 100644 index 000000000..c67adb831 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt @@ -0,0 +1,454 @@ +package com.maxrave.simpmusic.viewModel + +import androidx.lifecycle.viewModelScope +import com.maxrave.domain.data.entities.AlbumEntity +import com.maxrave.domain.data.entities.ArtistEntity +import com.maxrave.domain.data.entities.SongEntity +import com.maxrave.domain.data.entities.analytics.PlaybackEventEntity +import com.maxrave.domain.data.entities.analytics.query.TopPlayedAlbum +import com.maxrave.domain.data.entities.analytics.query.TopPlayedArtist +import com.maxrave.domain.data.entities.analytics.query.TopPlayedTracks +import com.maxrave.domain.extension.now +import com.maxrave.domain.extension.startTimestampOfThisYear +import com.maxrave.domain.repository.AlbumRepository +import com.maxrave.domain.repository.AnalyticsRepository +import com.maxrave.domain.repository.ArtistRepository +import com.maxrave.domain.repository.SongRepository +import com.maxrave.domain.utils.LocalResource +import com.maxrave.domain.utils.Resource +import com.maxrave.simpmusic.viewModel.base.BaseViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime + +class AnalyticsViewModel( + private val analyticsRepository: AnalyticsRepository, + private val songRepository: SongRepository, + private val artistRepository: ArtistRepository, + private val albumRepository: AlbumRepository, +) : BaseViewModel() { + private val _analyticsUIState: MutableStateFlow = + MutableStateFlow(AnalyticsUiState()) + val analyticsUIState: StateFlow get() = _analyticsUIState.asStateFlow() + + init { + getScrobblesCount() + getArtistCount() + getTotalListenTime() + getRecentlyRecord() + getDataForDayRange(analyticsUIState.value.dayRange) + } + + private fun getDataForDayRange(dayRange: AnalyticsUiState.DayRange) { + getTopTracks(dayRange) + getTopArtists(dayRange) + getTopAlbums(dayRange) + getScrobblesLineChart(dayRange) + } + + private fun getScrobblesCount() { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + scrobblesCount = LocalResource.Loading(), + ) + } + analyticsRepository.getTotalPlaybackEventCount().collect { count -> + _analyticsUIState.update { + it.copy( + scrobblesCount = LocalResource.Success(count), + ) + } + } + } + } + + private fun getArtistCount() { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + artistCount = LocalResource.Loading(), + ) + } + analyticsRepository.getTotalEventArtistCount().collect { count -> + _analyticsUIState.update { + it.copy( + artistCount = LocalResource.Success(count), + ) + } + } + } + } + + private fun getTotalListenTime() { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + totalListenTimeInSeconds = LocalResource.Loading(), + ) + } + analyticsRepository.getTotalListeningTimeInSeconds().collect { total -> + _analyticsUIState.update { + it.copy( + totalListenTimeInSeconds = LocalResource.Success(total), + ) + } + } + } + } + + private fun getTopTracks(dayRange: AnalyticsUiState.DayRange) { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + topTracks = LocalResource.Loading(), + ) + } + if (dayRange == AnalyticsUiState.DayRange.THIS_YEAR) { + analyticsRepository + .queryTopPlayedSongsInRange( + startTimestamp = startTimestampOfThisYear(), + endTimestamp = now(), + ).collect { topPlayedTracks -> + topPlayedTracks + .mapNotNull { + val song = songRepository.getSongById(it.videoId).lastOrNull() ?: return@mapNotNull null + it to song + }.let { pairs -> + _analyticsUIState.update { + it.copy( + topTracks = LocalResource.Success(pairs), + ) + } + } + } + } else { + val x = + when (dayRange) { + AnalyticsUiState.DayRange.LAST_7_DAYS -> 7 + AnalyticsUiState.DayRange.LAST_30_DAYS -> 30 + AnalyticsUiState.DayRange.LAST_90_DAYS -> 90 + } + analyticsRepository.queryTopPlayedSongsLastXDays(x).collect { topPlayedTracks -> + topPlayedTracks + .mapNotNull { + val song = songRepository.getSongById(it.videoId).lastOrNull() ?: return@mapNotNull null + it to song + }.let { pairs -> + log("Top played tracks: $pairs") + _analyticsUIState.update { + it.copy( + topTracks = LocalResource.Success(pairs), + ) + } + } + } + } + } + } + + private fun getTopArtists(dayRange: AnalyticsUiState.DayRange) { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + topArtists = LocalResource.Loading(), + ) + } + if (dayRange == AnalyticsUiState.DayRange.THIS_YEAR) { + analyticsRepository + .queryTopArtistsInRange( + startTimestamp = startTimestampOfThisYear(), + endTimestamp = now(), + ).collect { topPlayedArtists -> + topPlayedArtists + .mapNotNull { topPlayedArtist -> + val artist = + artistRepository.getArtistById(topPlayedArtist.channelId).lastOrNull() + ?: getArtistFromYouTube(topPlayedArtist.channelId) + ?: return@mapNotNull null + topPlayedArtist to artist + }.let { pairs -> + log("Top played artists: $pairs") + _analyticsUIState.update { + it.copy( + topArtists = LocalResource.Success(pairs), + ) + } + } + } + } else { + val x = + when (dayRange) { + AnalyticsUiState.DayRange.LAST_7_DAYS -> 7 + AnalyticsUiState.DayRange.LAST_30_DAYS -> 30 + AnalyticsUiState.DayRange.LAST_90_DAYS -> 90 + } + analyticsRepository.queryTopArtistsLastXDays(x).collect { topPlayedArtists -> + topPlayedArtists + .mapNotNull { topPlayedArtist -> + val artist = + artistRepository.getArtistById(topPlayedArtist.channelId).lastOrNull() + ?: getArtistFromYouTube(topPlayedArtist.channelId) + ?: return@mapNotNull null + topPlayedArtist to artist + }.let { pairs -> + _analyticsUIState.update { + it.copy( + topArtists = LocalResource.Success(pairs), + ) + } + } + } + } + } + } + + private suspend fun getArtistFromYouTube(channelId: String): ArtistEntity? = + artistRepository + .getArtistData(channelId) + .lastOrNull() + ?.takeIf { + it is Resource.Success && it.data != null + }.let { it?.data } + ?.let { + val entity = + ArtistEntity( + channelId = channelId, + name = it.name, + thumbnails = it.thumbnails?.lastOrNull()?.url, + followed = false, + followedAt = null, + inLibrary = now(), + ) + artistRepository.insertArtist(entity) + entity + } + + private fun getTopAlbums(dayRange: AnalyticsUiState.DayRange) { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + topAlbums = LocalResource.Loading(), + ) + } + if (dayRange == AnalyticsUiState.DayRange.THIS_YEAR) { + analyticsRepository + .queryTopAlbumsInRange( + startTimestamp = startTimestampOfThisYear(), + endTimestamp = now(), + ).collect { topPlayedAlbums -> + topPlayedAlbums + .mapNotNull { + val album = albumRepository.getAlbum(it.albumBrowseId).lastOrNull() ?: return@mapNotNull null + it to album + }.let { pairs -> + _analyticsUIState.update { + it.copy( + topAlbums = LocalResource.Success(pairs), + ) + } + } + } + } else { + val x = + when (dayRange) { + AnalyticsUiState.DayRange.LAST_7_DAYS -> 7 + AnalyticsUiState.DayRange.LAST_30_DAYS -> 30 + AnalyticsUiState.DayRange.LAST_90_DAYS -> 90 + } + analyticsRepository.queryTopAlbumsLastXDays(x).collect { topPlayedAlbums -> + topPlayedAlbums + .mapNotNull { + val album = albumRepository.getAlbum(it.albumBrowseId).lastOrNull() ?: return@mapNotNull null + it to album + }.let { pairs -> + log("Top played albums: $pairs") + _analyticsUIState.update { + it.copy( + topAlbums = LocalResource.Success(pairs), + ) + } + } + } + } + } + } + + private fun getRecentlyRecord() { + viewModelScope.launch { + analyticsRepository + .getPlaybackEventsByOffset( + offset = 0, + limit = 5, + ).collect { events -> + events + .mapNotNull { event -> + val song = songRepository.getSongById(event.videoId).lastOrNull() ?: return@mapNotNull null + event to song + }.let { + if (it.isNotEmpty()) { + _analyticsUIState.update { state -> + state.copy( + recentlyRecord = LocalResource.Success(it), + ) + } + } + } + } + } + } + + private fun getScrobblesLineChart(dayRange: AnalyticsUiState.DayRange) { + viewModelScope.launch { + _analyticsUIState.update { + it.copy( + scrobblesLineChart = LocalResource.Loading(), + ) + } + val chartTypes = + when (dayRange) { + AnalyticsUiState.DayRange.LAST_7_DAYS -> { + (0 until 7).map { + AnalyticsUiState.ChartType.Day( + day = now().date.minus(DatePeriod(days = it)), + ) + } + } + + AnalyticsUiState.DayRange.LAST_30_DAYS -> { + (0 until 30).map { + AnalyticsUiState.ChartType.Day( + day = now().date.minus(DatePeriod(days = it)), + ) + } + } + + AnalyticsUiState.DayRange.LAST_90_DAYS -> { + (0 until 3).map { + AnalyticsUiState.ChartType.Month( + month = now().date.minus(DatePeriod(months = it)).month, + year = now().date.minus(DatePeriod(months = it)).year, + ) + } + } + + AnalyticsUiState.DayRange.THIS_YEAR -> { + val currentMonth = now().date.month + (1..currentMonth.number).map { + AnalyticsUiState.ChartType.Month( + month = kotlinx.datetime.Month(it), + year = now().date.year, + ) + } + } + } + val currentTimeZone = TimeZone.currentSystemDefault() + val data = + chartTypes.map { + when (it) { + is AnalyticsUiState.ChartType.Day -> { + val startTimestamp = it.day.atStartOfDayIn(currentTimeZone).toLocalDateTime(currentTimeZone) + val endTimestamp = + it.day + .plus(DatePeriod(days = 1)) + .atStartOfDayIn(currentTimeZone) + .toLocalDateTime(currentTimeZone) + val count = + analyticsRepository + .getPlaybackEventCountInRange( + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + ).lastOrNull() ?: 0L + Pair(it, count) + } + + is AnalyticsUiState.ChartType.Month -> { + val startTimestamp = + LocalDate( + year = it.year, + month = it.month.number, + day = 1, + ).atStartOfDayIn(currentTimeZone).toLocalDateTime(currentTimeZone) + val endTimestamp = + if (it.month == kotlinx.datetime.Month.DECEMBER) { + LocalDate( + year = it.year + 1, + month = 1, + day = 1, + ).atStartOfDayIn(currentTimeZone).toLocalDateTime(currentTimeZone) + } else { + LocalDate( + year = it.year, + month = it.month.number + 1, + day = 1, + ).atStartOfDayIn(currentTimeZone).toLocalDateTime(currentTimeZone) + } + val count = + analyticsRepository + .getPlaybackEventCountInRange( + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + ).lastOrNull() ?: 0L + Pair(it, count) + } + } + } + log("Scrobbles line chart data: $data") + _analyticsUIState.update { + it.copy( + scrobblesLineChart = LocalResource.Success(data), + ) + } + } + } + + fun setDayRange(dayRange: AnalyticsUiState.DayRange) { + _analyticsUIState.update { + it.copy( + dayRange = dayRange, + ) + } + getDataForDayRange(dayRange) + } +} + +data class AnalyticsUiState( + val scrobblesCount: LocalResource = LocalResource.Loading(), + val artistCount: LocalResource = LocalResource.Loading(), + val totalListenTimeInSeconds: LocalResource = LocalResource.Loading(), + val dayRange: DayRange = DayRange.LAST_7_DAYS, + val recentlyRecord: LocalResource>> = LocalResource.Loading(), + val topTracks: LocalResource>> = LocalResource.Loading(), + val topArtists: LocalResource>> = LocalResource.Loading(), + val topAlbums: LocalResource>> = LocalResource.Loading(), + val scrobblesLineChart: LocalResource>> = LocalResource.Loading(), +) { + enum class DayRange { + LAST_7_DAYS, + LAST_30_DAYS, + LAST_90_DAYS, + THIS_YEAR, + } + + sealed class ChartType { + data class Day( + val day: LocalDate, + ) : ChartType() + + data class Month( + val month: kotlinx.datetime.Month, + val year: Int, + ) : ChartType() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ArtistViewModel.kt index 63403f2b9..a72ca2497 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ArtistViewModel.kt @@ -97,11 +97,13 @@ class ArtistViewModel( artistRepository.updateArtistInLibrary(now(), artist.channelId) delay(100) artistRepository.getArtistById(artist.channelId).collect { artistEntity -> - artist.thumbnails?.let { - artistRepository.updateArtistImage(artistEntity.channelId, it) + if (artistEntity != null) { + artist.thumbnails?.let { + artistRepository.updateArtistImage(artistEntity.channelId, it) + } + _followed.value = artistEntity.followed + log("insertArtist: ${artistEntity.followed}") } - _followed.value = artistEntity.followed - log("insertArtist: ${artistEntity.followed}") } } } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LibraryDynamicPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LibraryDynamicPlaylistViewModel.kt index c5c0f0ed8..3d1741578 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LibraryDynamicPlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LibraryDynamicPlaylistViewModel.kt @@ -93,6 +93,7 @@ class LibraryDynamicPlaylistViewModel( LibraryDynamicPlaylistType.Downloaded -> listDownloadedSong.value to listDownloadedSong.value.find { it.videoId == videoId } LibraryDynamicPlaylistType.Followed -> return LibraryDynamicPlaylistType.MostPlayed -> listMostPlayedSong.value to listMostPlayedSong.value.find { it.videoId == videoId } + else -> return } if (playTrack == null) return setQueueData( diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index 969c8e0d2..12be3fc86 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -168,6 +168,22 @@ class SettingsViewModel( private val _videoDownloadQuality = MutableStateFlow(null) val videoDownloadQuality: StateFlow = _videoDownloadQuality + private val _localTrackingEnabled = MutableStateFlow(false) + val localTrackingEnabled: StateFlow = _localTrackingEnabled + + // Auto Backup + private val _autoBackupEnabled = MutableStateFlow(false) + val autoBackupEnabled: StateFlow = _autoBackupEnabled + + private val _autoBackupFrequency = MutableStateFlow(DataStoreManager.AUTO_BACKUP_FREQUENCY_DAILY) + val autoBackupFrequency: StateFlow = _autoBackupFrequency + + private val _autoBackupMaxFiles = MutableStateFlow(5) + val autoBackupMaxFiles: StateFlow = _autoBackupMaxFiles + + private val _autoBackupLastTime = MutableStateFlow(0L) + val autoBackupLastTime: StateFlow = _autoBackupLastTime + private var _alertData: MutableStateFlow = MutableStateFlow(null) val alertData: StateFlow = _alertData @@ -249,6 +265,11 @@ class SettingsViewModel( getCombineLocalAndYouTubeLiked() getDownloadQuality() getVideoDownloadQuality() + getLocalTrackingEnabled() + getAutoBackupEnabled() + getAutoBackupFrequency() + getAutoBackupMaxFiles() + getAutoBackupLastTime() viewModelScope.launch { calculateDataFraction( cacheRepository, @@ -258,6 +279,21 @@ class SettingsViewModel( } } + private fun getLocalTrackingEnabled() { + viewModelScope.launch { + dataStoreManager.localTrackingEnabled.collect { enabled -> + _localTrackingEnabled.value = enabled == DataStoreManager.TRUE + } + } + } + + fun setLocalTrackingEnabled(enabled: Boolean) { + viewModelScope.launch { + dataStoreManager.setLocalTrackingEnabled(enabled) + getLocalTrackingEnabled() + } + } + private fun getDownloadQuality() { viewModelScope.launch { dataStoreManager.downloadQuality.collect { quality -> @@ -446,6 +482,60 @@ class SettingsViewModel( } } + // Auto Backup functions + private fun getAutoBackupEnabled() { + viewModelScope.launch { + dataStoreManager.autoBackupEnabled.collect { enabled -> + _autoBackupEnabled.value = enabled == DataStoreManager.TRUE + } + } + } + + fun setAutoBackupEnabled(enabled: Boolean) { + viewModelScope.launch { + dataStoreManager.setAutoBackupEnabled(enabled) + getAutoBackupEnabled() + } + } + + private fun getAutoBackupFrequency() { + viewModelScope.launch { + dataStoreManager.autoBackupFrequency.collect { frequency -> + _autoBackupFrequency.value = frequency + } + } + } + + fun setAutoBackupFrequency(frequency: String) { + viewModelScope.launch { + dataStoreManager.setAutoBackupFrequency(frequency) + getAutoBackupFrequency() + } + } + + private fun getAutoBackupMaxFiles() { + viewModelScope.launch { + dataStoreManager.autoBackupMaxFiles.collect { max -> + _autoBackupMaxFiles.value = max + } + } + } + + fun setAutoBackupMaxFiles(max: Int) { + viewModelScope.launch { + dataStoreManager.setAutoBackupMaxFiles(max) + getAutoBackupMaxFiles() + } + } + + private fun getAutoBackupLastTime() { + viewModelScope.launch { + dataStoreManager.autoBackupLastTime.collect { time -> + _autoBackupLastTime.value = time + } + } + } + private fun getContributorNameAndEmail() { viewModelScope.launch { combine(dataStoreManager.contributorName, dataStoreManager.contributorEmail) { name, email -> diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 64ff2744e..bd69b3964 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -60,7 +60,6 @@ import com.maxrave.logger.LogLevel import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.getDownloadFolderPath -import com.maxrave.simpmusic.expect.startWorker import com.maxrave.simpmusic.expect.ui.toByteArray import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.utils.VersionManager @@ -97,7 +96,9 @@ import simpmusic.composeapp.generated.resources.play_next import simpmusic.composeapp.generated.resources.removed_from_youtube_liked import simpmusic.composeapp.generated.resources.shared import simpmusic.composeapp.generated.resources.updated +import simpmusic.composeapp.generated.resources.vote_submitted import java.io.FileOutputStream +import java.lang.Exception import kotlin.math.abs import kotlin.reflect.KClass @@ -303,15 +304,14 @@ class SharedViewModel( Logger.w(tag, "NowPlayingState is $state") canvasJob?.cancel() _nowPlayingState.value = state - state.track?.let { track -> + state.songEntity?.let { track -> _nowPlayingScreenData.value = NowPlayingScreenData( nowPlayingTitle = track.title, artistName = track - .artists - .toListName() - .joinToString(", "), + .artistName + ?.joinToString(", ") ?: "", isVideo = false, thumbnailURL = null, canvasData = null, @@ -732,24 +732,36 @@ class SharedViewModel( fun onUIEvent(uiEvent: UIEvent) = viewModelScope.launch { when (uiEvent) { - UIEvent.Backward -> + UIEvent.Backward -> { mediaPlayerHandler.onPlayerEvent( PlayerEvent.Backward, ) + } + + UIEvent.Forward -> { + mediaPlayerHandler.onPlayerEvent(PlayerEvent.Forward) + } - UIEvent.Forward -> mediaPlayerHandler.onPlayerEvent(PlayerEvent.Forward) - UIEvent.PlayPause -> + UIEvent.PlayPause -> { mediaPlayerHandler.onPlayerEvent( PlayerEvent.PlayPause, ) + } - UIEvent.Next -> mediaPlayerHandler.onPlayerEvent(PlayerEvent.Next) - UIEvent.Previous -> + UIEvent.Next -> { + mediaPlayerHandler.onPlayerEvent(PlayerEvent.Next) + } + + UIEvent.Previous -> { mediaPlayerHandler.onPlayerEvent( PlayerEvent.Previous, ) + } + + UIEvent.Stop -> { + mediaPlayerHandler.onPlayerEvent(PlayerEvent.Stop) + } - UIEvent.Stop -> mediaPlayerHandler.onPlayerEvent(PlayerEvent.Stop) is UIEvent.UpdateProgress -> { mediaPlayerHandler.onPlayerEvent( PlayerEvent.UpdateProgress( @@ -758,8 +770,14 @@ class SharedViewModel( ) } - UIEvent.Repeat -> mediaPlayerHandler.onPlayerEvent(PlayerEvent.Repeat) - UIEvent.Shuffle -> mediaPlayerHandler.onPlayerEvent(PlayerEvent.Shuffle) + UIEvent.Repeat -> { + mediaPlayerHandler.onPlayerEvent(PlayerEvent.Repeat) + } + + UIEvent.Shuffle -> { + mediaPlayerHandler.onPlayerEvent(PlayerEvent.Shuffle) + } + UIEvent.ToggleLike -> { Logger.w(tag, "ToggleLike") mediaPlayerHandler.onPlayerEvent(PlayerEvent.ToggleLike) @@ -989,7 +1007,7 @@ class SharedViewModel( dataStoreManager.translationLanguage.first(), ) log("Removed out-of-sync translated lyrics for $videoId") - val simpMusicLyricsId = lyrics.simpMusicLyricsId + val simpMusicLyricsId = lyrics.simpMusicLyrics?.id if (lyricsProvider == LyricsProvider.SIMPMUSIC && !simpMusicLyricsId.isNullOrEmpty()) { viewModelScope.launch { lyricsCanvasRepository @@ -1034,6 +1052,14 @@ class SharedViewModel( val track = _nowPlayingState.value?.track when (isTranslatedLyrics) { true -> { + if (lyricsProvider == LyricsProvider.SIMPMUSIC) { + _translatedVoteState.value = + VoteData( + id = lyrics.simpMusicLyrics?.id ?: "", + vote = lyrics.simpMusicLyrics?.vote ?: 0, + state = VoteState.Idle, + ) + } _nowPlayingScreenData.update { it.copy( lyricsData = @@ -1066,6 +1092,14 @@ class SharedViewModel( } false -> { + if (lyricsProvider == LyricsProvider.SIMPMUSIC) { + _lyricsVoteState.value = + VoteData( + id = lyrics.simpMusicLyrics?.id ?: "", + vote = lyrics.simpMusicLyrics?.vote ?: 0, + state = VoteState.Idle, + ) + } _nowPlayingScreenData.update { it.copy( lyricsData = @@ -1135,6 +1169,7 @@ class SharedViewModel( ?.artist ?: "" } + resetLyricsVoteState() val lyricsProvider = dataStoreManager.lyricsProvider.first() if (isVideo) { getYouTubeCaption( @@ -1443,7 +1478,7 @@ class SharedViewModel( } } - fun setLyricsProvider() { + private fun setLyricsProvider() { viewModelScope.launch { val songEntity = nowPlayingState.value?.songEntity ?: return@launch val isVideo = nowPlayingState.value?.mediaItem?.isVideo() ?: false @@ -1525,11 +1560,6 @@ class SharedViewModel( fun shouldCheckForUpdate(): Boolean = runBlocking { dataStoreManager.autoCheckForUpdates.first() == TRUE } - fun runWorker() { - Logger.w("Check Worker", "Worker") - startWorker() - } - private var _downloadFileProgress = MutableStateFlow(DownloadProgress.INIT) val downloadFileProgress: StateFlow get() = _downloadFileProgress @@ -1548,7 +1578,7 @@ class SharedViewModel( fileOutputStream.write(bytesArray) fileOutputStream.close() Logger.d(tag, "Thumbnail saved to $path.jpg") - } catch (e: java.lang.Exception) { + } catch (e: Exception) { throw RuntimeException(e) } songRepository @@ -1593,6 +1623,113 @@ class SharedViewModel( } } + // Vote state for translated lyrics + private val _translatedVoteState = MutableStateFlow(null) + val translatedVoteState: StateFlow = _translatedVoteState.asStateFlow() + + // Vote state for original lyrics + private val _lyricsVoteState = MutableStateFlow(null) + val lyricsVoteState: StateFlow = _lyricsVoteState.asStateFlow() + + /** + * Vote for SimpMusic original lyrics (upvote or downvote) + * @param upvote true for upvote, false for downvote + */ + fun voteLyrics(upvote: Boolean) { + val lyricsData = _nowPlayingScreenData.value.lyricsData + val lyricsProvider = lyricsData?.lyricsProvider + val simpMusicLyricsId = lyricsData?.lyrics?.simpMusicLyrics?.id ?: return + + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) { + Logger.w(tag, "Cannot vote: not a SimpMusic lyrics or missing ID") + return + } + + viewModelScope.launch { + _lyricsVoteState.update { + it?.copy( + state = VoteState.Loading, + ) + } + lyricsCanvasRepository + .voteSimpMusicLyrics( + lyricsId = simpMusicLyricsId, + upvote = upvote, + ).collectLatest { result -> + when (result) { + is Resource.Error -> { + Logger.w(tag, "Vote SimpMusic Lyrics Error ${result.message}") + _lyricsVoteState.update { + it?.copy( + state = VoteState.Error(result.message ?: "Unknown error"), + ) + } + } + + is Resource.Success -> { + Logger.d(tag, "Vote SimpMusic Lyrics Success") + _lyricsVoteState.update { + it?.copy( + state = VoteState.Success(upvote), + ) + } + makeToast(getString(Res.string.vote_submitted)) + } + } + } + } + } + + private fun resetLyricsVoteState() { + _lyricsVoteState.value = null + _translatedVoteState.value = null + } + + /** + * Vote for SimpMusic translated lyrics (upvote or downvote) + * @param upvote true for upvote, false for downvote + */ + fun voteTranslatedLyrics(upvote: Boolean) { + val translatedLyrics = _nowPlayingScreenData.value.lyricsData?.translatedLyrics + val lyricsProvider = translatedLyrics?.second + val simpMusicLyricsId = translatedLyrics?.first?.simpMusicLyrics?.id ?: return + + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) { + Logger.w(tag, "Cannot vote: not a SimpMusic translated lyrics or missing ID") + return + } + + viewModelScope.launch { + _translatedVoteState.update { + it?.copy( + state = VoteState.Loading, + ) + } + lyricsCanvasRepository + .voteSimpMusicTranslatedLyrics( + translatedLyricsId = simpMusicLyricsId, + upvote = upvote, + ).collectLatest { result -> + when (result) { + is Resource.Error -> { + Logger.w(tag, "Vote SimpMusic Translated Lyrics Error ${result.message}") + _translatedVoteState.update { + it?.copy( + state = VoteState.Error(result.message ?: "Unknown error"), + ) + } + } + + is Resource.Success -> { + Logger.d(tag, "Vote SimpMusic Translated Lyrics Success") + _translatedVoteState.update { it?.copy(state = VoteState.Success(upvote)) } + makeToast(getString(Res.string.vote_submitted)) + } + } + } + } + } + fun shouldStopMusicService(): Boolean = runBlocking { dataStoreManager.killServiceOnExit.first() == TRUE } fun isUserLoggedIn(): Boolean = runBlocking { dataStoreManager.cookie.first().isNotEmpty() } @@ -1673,4 +1810,24 @@ data class NowPlayingScreenData( playlistName = "", ) } +} + +data class VoteData( + val id: String, + val vote: Int, + val state: VoteState, +) + +sealed class VoteState { + data object Idle : VoteState() + + data object Loading : VoteState() + + data class Success( + val upvote: Boolean, + ) : VoteState() + + data class Error( + val message: String, + ) : VoteState() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/base/BaseViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/base/BaseViewModel.kt index 906604d43..45470cec9 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/base/BaseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/base/BaseViewModel.kt @@ -36,7 +36,7 @@ abstract class BaseViewModel : /** * Tag for logging */ - protected val tag: String = javaClass.simpleName + protected val tag: String = this::class.simpleName ?: "BaseViewModel" /** * Log with viewModel tag @@ -67,7 +67,6 @@ abstract class BaseViewModel : } fun makeToast(message: String?) { - Res.string.loading showToast( message = message ?: "NO MESSAGE", duration = ToastDuration.Short, diff --git a/composeApp/src/iosMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.ios.kt b/composeApp/src/iosMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.ios.kt new file mode 100644 index 000000000..dc232648f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.ios.kt @@ -0,0 +1,6 @@ +package com.maxrave.simpmusic.expect + +// No-op on iOS - mini player is desktop only +actual fun toggleMiniPlayer() { + // Do nothing on iOS +} diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt new file mode 100644 index 000000000..89d8f5eef --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt @@ -0,0 +1,10 @@ +package com.maxrave.simpmusic.expect + +import com.maxrave.logger.Logger +import com.maxrave.simpmusic.ui.mini_player.MiniPlayerManager + +actual fun toggleMiniPlayer() { + Logger.d("MiniPlayer", "Toggle called, current state: ${MiniPlayerManager.isOpen}") + MiniPlayerManager.isOpen = !MiniPlayerManager.isOpen + Logger.d("MiniPlayer", "New state: ${MiniPlayerManager.isOpen}") +} diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/Worker.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/Worker.jvm.kt deleted file mode 100644 index f7f761a6b..000000000 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/Worker.jvm.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.maxrave.simpmusic.expect - -actual fun startWorker() { -} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt index 6c79857a6..ba1f60a58 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt @@ -18,6 +18,8 @@ import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler import com.maxrave.domain.mediaservice.handler.ToastType import com.maxrave.simpmusic.di.viewModelModule +import com.maxrave.simpmusic.ui.mini_player.MiniPlayerManager +import com.maxrave.simpmusic.ui.mini_player.MiniPlayerWindow import com.maxrave.simpmusic.utils.VersionManager import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.changeLanguageNative @@ -41,62 +43,70 @@ import simpmusic.composeapp.generated.resources.explicit_content_blocked import simpmusic.composeapp.generated.resources.time_out_check_internet_connection_or_change_piped_instance_in_settings @OptIn(ExperimentalMaterial3Api::class) -fun main() = - application { - System.setProperty("compose.swing.render.on.graphics", "true") - System.setProperty("compose.interop.blending", "true") - System.setProperty("compose.layers.type", "COMPONENT") - startKoin { - loadAllModules() - loadKoinModules(viewModelModule) +fun main() { + System.setProperty("compose.swing.render.on.graphics", "true") + System.setProperty("compose.interop.blending", "true") + System.setProperty("compose.layers.type", "COMPONENT") + + // Initialize Koin ONCE before application starts + startKoin { + loadAllModules() + loadKoinModules(viewModelModule) + } + + val language = + runBlocking { + getKoin() + .get() + .language + .first() + .substring(0..1) } - val language = - runBlocking { - getKoin() - .get() - .language - .first() - .substring(0..1) - } - changeLanguageNative(language) + changeLanguageNative(language) + + VersionManager.initialize() + if (BuildKonfig.sentryDsn.isNotEmpty()) { + Sentry.init { options -> + options.dsn = BuildKonfig.sentryDsn + options.release = "simpmusic-desktop@${VersionManager.getVersionName()}" + options.setDiagnosticLevel(SentryLevel.ERROR) + } + } + + val mediaPlayerHandler by inject(MediaPlayerHandler::class.java) + mediaPlayerHandler.showToast = { type -> + showToast( + when (type) { + ToastType.ExplicitContent -> { + runBlocking { getString(Res.string.explicit_content_blocked) } + } + + is ToastType.PlayerError -> { + runBlocking { getString(Res.string.time_out_check_internet_connection_or_change_piped_instance_in_settings, type.error) } + } + }, + ) + } + mediaPlayerHandler.pushPlayerError = { error -> + Sentry.withScope { scope -> + Sentry.captureMessage("Player Error: ${error.message}, code: ${error.errorCode}, code name: ${error.errorCodeName}") + } + } + + val sharedViewModel = getKoin().get() + if (sharedViewModel.shouldCheckForUpdate()) { + sharedViewModel.checkForUpdate() + } + + application { val windowState = rememberWindowState( size = DpSize(1280.dp, 720.dp), ) - VersionManager.initialize() - if (BuildKonfig.sentryDsn.isNotEmpty()) { - Sentry.init { options -> - options.dsn = BuildKonfig.sentryDsn - options.release = "simpmusic-desktop@${VersionManager.getVersionName()}" - options.setDiagnosticLevel(SentryLevel.ERROR) - } - } - val mediaPlayerHandler by inject(MediaPlayerHandler::class.java) - mediaPlayerHandler.showToast = { type -> - showToast( - when (type) { - ToastType.ExplicitContent -> runBlocking { getString(Res.string.explicit_content_blocked) } - is ToastType.PlayerError -> - runBlocking { getString(Res.string.time_out_check_internet_connection_or_change_piped_instance_in_settings, type.error) } - }, - ) - } - mediaPlayerHandler.pushPlayerError = { error -> - Sentry.withScope { scope -> - Sentry.captureMessage("Player Error: ${error.message}, code: ${error.errorCode}, code name: ${error.errorCodeName}") - } - } - val onExitApplication: () -> Unit = { - mediaPlayerHandler.release() - exitApplication() - } - val sharedViewModel = getKoin().get() - if (sharedViewModel.shouldCheckForUpdate()) { - sharedViewModel.checkForUpdate() - } Window( onCloseRequest = { - onExitApplication() + mediaPlayerHandler.release() + exitApplication() }, title = "SimpMusic", icon = painterResource(Res.drawable.circle_app_icon), @@ -115,8 +125,7 @@ fun main() = }, ), ) - } - .diskCachePolicy(CachePolicy.ENABLED) + }.diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .diskCache( DiskCache @@ -130,4 +139,15 @@ fun main() = App() ToastHost() } - } \ No newline at end of file + + // Mini Player Window (separate window) + if (MiniPlayerManager.isOpen) { + MiniPlayerWindow( + sharedViewModel = sharedViewModel, + onCloseRequest = { + MiniPlayerManager.isOpen = false + }, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt new file mode 100644 index 000000000..789b796a6 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt @@ -0,0 +1,898 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.MarqueeAnimationMode +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.maxrave.domain.data.model.streams.TimeLine +import com.maxrave.domain.mediaservice.handler.ControlState +import com.maxrave.simpmusic.extension.parseRichSyncWords +import com.maxrave.simpmusic.ui.component.PlayPauseButton +import com.maxrave.simpmusic.ui.component.RichSyncLyricsLineItem +import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.NowPlayingScreenData +import com.maxrave.simpmusic.viewModel.UIEvent +import org.jetbrains.compose.resources.painterResource +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.baseline_skip_next_24 +import simpmusic.composeapp.generated.resources.baseline_skip_previous_24 +import simpmusic.composeapp.generated.resources.holder + +/** + * Compact layout (< 260dp): Controls only, no artwork or text + * Perfect for very narrow windows + */ +@Composable +fun CompactMiniLayout( + controllerState: ControlState, + timeline: TimeLine, + onUIEvent: (UIEvent) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val alpha by animateFloatAsState( + targetValue = if (isHovered) 1f else 0.9f, + animationSpec = tween(200), + ) + + Surface( + modifier = + Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)) + .hoverable(interactionSource), + color = Color(0xFF1C1C1E), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Controls only - centered + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .alpha(alpha), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RippleIconButton( + resId = Res.drawable.baseline_skip_previous_24, + modifier = Modifier.size(28.dp), + tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isPreviousAvailable) { + onUIEvent(UIEvent.Previous) + } + }, + ) + + PlayPauseButton( + isPlaying = controllerState.isPlaying, + modifier = Modifier.size(36.dp), + onClick = { onUIEvent(UIEvent.PlayPause) }, + ) + + RippleIconButton( + resId = Res.drawable.baseline_skip_next_24, + modifier = Modifier.size(28.dp), + tint = if (controllerState.isNextAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isNextAvailable) { + onUIEvent(UIEvent.Next) + } + }, + ) + } + } + + // Progress bar + ProgressBar(timeline) + } + } +} + +/** + * Medium layout (260-360dp): Artwork + controls, no text + * Good balance for medium-sized windows + */ +@Composable +fun MediumMiniLayout( + nowPlayingData: NowPlayingScreenData, + controllerState: ControlState, + timeline: TimeLine, + lyricsData: NowPlayingScreenData.LyricsData?, + onUIEvent: (UIEvent) -> Unit, +) { + val artworkInteractionSource = remember { MutableInteractionSource() } + val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() + val artworkScale by animateFloatAsState( + targetValue = if (isArtworkHovered) 1.05f else 1f, + animationSpec = tween(200), + ) + + Surface( + modifier = + Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), + color = Color(0xFF1C1C1E), + ) { + BoxWithConstraints { + val showExtraButtons = maxWidth >= 300.dp + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Smaller artwork with hover effect + AsyncImage( + model = nowPlayingData.thumbnailURL, + contentDescription = "Album Art", + placeholder = painterResource(Res.drawable.holder), + error = painterResource(Res.drawable.holder), + contentScale = ContentScale.Crop, + modifier = + Modifier + .size(48.dp) + .scale(artworkScale) + .clip(RoundedCornerShape(6.dp)) + .hoverable(artworkInteractionSource), + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Controls - show extra buttons only if width >= 300dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Like button - only show if width >= 300dp + AnimatedVisibility( + visible = showExtraButtons, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + IconButton( + onClick = { onUIEvent(UIEvent.ToggleLike) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + if (controllerState.isLiked) { + Icons.Filled.Favorite + } else { + Icons.Outlined.FavoriteBorder + }, + contentDescription = "Like", + tint = + if (controllerState.isLiked) { + Color(0xFFFF4081) + } else { + Color.White.copy(alpha = 0.7f) + }, + modifier = Modifier.size(18.dp), + ) + } + } + + RippleIconButton( + resId = Res.drawable.baseline_skip_previous_24, + modifier = Modifier.size(28.dp), + tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isPreviousAvailable) { + onUIEvent(UIEvent.Previous) + } + }, + ) + + PlayPauseButton( + isPlaying = controllerState.isPlaying, + modifier = Modifier.size(36.dp), + onClick = { onUIEvent(UIEvent.PlayPause) }, + ) + + RippleIconButton( + resId = Res.drawable.baseline_skip_next_24, + modifier = Modifier.size(28.dp), + tint = if (controllerState.isNextAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isNextAvailable) { + onUIEvent(UIEvent.Next) + } + }, + ) + + // Volume button - only show if width >= 300dp + AnimatedVisibility( + visible = showExtraButtons, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.Filled.VolumeUp + } else { + Icons.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp), + ) + } + } + } + } + + // Lyrics display (if available) + if (lyricsData != null && !lyricsData.lyrics.error && lyricsData.lyrics.lines != null) { + val currentLine = + remember(timeline.current) { + lyricsData.lyrics.lines?.findLast { line -> + line.startTimeMs.toLongOrNull()?.let { it <= timeline.current } ?: false + } + } + + if (currentLine != null) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + if (lyricsData.lyrics.syncType == "RICH_SYNCED") { + val parsedLine = + remember(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) { + val result = parseRichSyncWords(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) + result + } + + if (parsedLine != null) { + RichSyncLyricsLineItem( + parsedLine = parsedLine, + translatedWords = null, + currentTimeMs = timeline.current, + isCurrent = true, + customFontSize = typo().bodySmall.fontSize, + modifier = Modifier, + ) + } + } else { + Text( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight( + align = Alignment.CenterVertically, + ).basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + textAlign = TextAlign.Center, + text = currentLine.words, + style = typo().bodySmall, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 11.sp, + ) + } + } + } + } + + // Progress bar + ProgressBar(timeline) + } + } + } +} + +/** + * Progress bar component shared across all layouts + */ +@Composable +private fun ProgressBar(timeline: TimeLine) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(3.dp) + .background(Color(0xFF2C2C2E)), + ) { + if (timeline.total > 0L && timeline.current >= 0L) { + LinearProgressIndicator( + progress = { timeline.current.toFloat() / timeline.total }, + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.Transparent, + strokeCap = StrokeCap.Round, + ) + } + } +} + +/** + * Square/Tall layout (Spotify-style): Large artwork centered with controls below + * Appears when window is square or taller (aspect ratio <= 1.3) + * Includes like/favorite and volume/mute buttons + */ +@Composable +fun SquareMiniLayout( + nowPlayingData: NowPlayingScreenData, + controllerState: ControlState, + timeline: TimeLine, + lyricsData: NowPlayingScreenData.LyricsData?, + onUIEvent: (UIEvent) -> Unit, +) { + val artworkInteractionSource = remember { MutableInteractionSource() } + val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() + val artworkScale by animateFloatAsState( + targetValue = if (isArtworkHovered) 1.03f else 1f, + animationSpec = tween(300), + ) + + Surface( + modifier = + Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), + color = Color(0xFF1C1C1E), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + // Large centered album artwork + AsyncImage( + model = nowPlayingData.thumbnailURL, + contentDescription = "Album Art", + placeholder = painterResource(Res.drawable.holder), + error = painterResource(Res.drawable.holder), + contentScale = ContentScale.Crop, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(0.85f) + .scale(artworkScale) + .clip(RoundedCornerShape(12.dp)) + .hoverable(artworkInteractionSource), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = nowPlayingData.nowPlayingTitle, + style = typo().bodyLarge, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = nowPlayingData.artistName, + style = typo().bodyMedium, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Lyrics display (if available) + if (lyricsData != null && !lyricsData.lyrics.error && lyricsData.lyrics.lines != null) { + val currentLine = + remember(timeline.current) { + lyricsData.lyrics.lines?.findLast { line -> + line.startTimeMs.toLongOrNull()?.let { it <= timeline.current } ?: false + } + } + + if (currentLine != null) { + if (lyricsData.lyrics.syncType == "RICH_SYNCED") { + val parsedLine = + remember(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) { + val result = parseRichSyncWords(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) + result + } + + if (parsedLine != null) { + RichSyncLyricsLineItem( + parsedLine = parsedLine, + translatedWords = null, + currentTimeMs = timeline.current, + isCurrent = true, + customFontSize = typo().bodySmall.fontSize, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } else { + Text( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .wrapContentHeight( + align = Alignment.CenterVertically, + ).basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + textAlign = TextAlign.Center, + text = currentLine.words, + style = typo().bodySmall, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + // Progress bar + Box( + modifier = + Modifier + .fillMaxWidth() + .height(4.dp) + .background(Color(0xFF2C2C2E), RoundedCornerShape(2.dp)), + ) { + if (timeline.total > 0L && timeline.current >= 0L) { + LinearProgressIndicator( + progress = { timeline.current.toFloat() / timeline.total }, + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.Transparent, + strokeCap = StrokeCap.Round, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Main playback controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Like/Favorite button + IconButton( + onClick = { onUIEvent(UIEvent.ToggleLike) }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = + if (controllerState.isLiked) { + Icons.Filled.Favorite + } else { + Icons.Outlined.FavoriteBorder + }, + contentDescription = "Like", + tint = + if (controllerState.isLiked) { + Color(0xFFFF4081) + } else { + Color.White.copy(alpha = 0.7f) + }, + modifier = Modifier.size(24.dp), + ) + } + + // Previous + RippleIconButton( + resId = Res.drawable.baseline_skip_previous_24, + modifier = Modifier.size(36.dp), + tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isPreviousAvailable) { + onUIEvent(UIEvent.Previous) + } + }, + ) + + // Play/Pause + PlayPauseButton( + isPlaying = controllerState.isPlaying, + modifier = Modifier.size(52.dp), + onClick = { onUIEvent(UIEvent.PlayPause) }, + ) + + // Next + RippleIconButton( + resId = Res.drawable.baseline_skip_next_24, + modifier = Modifier.size(36.dp), + tint = if (controllerState.isNextAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isNextAvailable) { + onUIEvent(UIEvent.Next) + } + }, + ) + + // Volume/Mute button + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.Filled.VolumeUp + } else { + Icons.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(24.dp), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +/** + * Empty state when no track is playing + */ +@Composable +fun EmptyMiniPlayerState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "No track playing", + style = typo().bodyMedium.copy(fontSize = 13.sp), + color = Color.White.copy(alpha = 0.6f), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Play something to see controls", + style = typo().bodySmall.copy(fontSize = 11.sp), + color = Color.White.copy(alpha = 0.4f), + ) + } + } +} + +/** + * Legacy full layout - now used only when BoxWithConstraints shows > 360dp + * Kept for backwards compatibility + */ +@Composable +fun ExpandedMiniLayout( + nowPlayingData: NowPlayingScreenData, + controllerState: ControlState, + timeline: TimeLine, + lyricsData: NowPlayingScreenData.LyricsData?, + onUIEvent: (UIEvent) -> Unit, +) { + val artworkInteractionSource = remember { MutableInteractionSource() } + val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() + val artworkScale by animateFloatAsState( + targetValue = if (isArtworkHovered) 1.08f else 1f, + animationSpec = tween(250), + ) + + Surface( + modifier = + Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), + color = Color(0xFF1C1C1E), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Main content area + Row( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Album artwork with hover animation + AsyncImage( + model = nowPlayingData.thumbnailURL, + contentDescription = "Album Art", + placeholder = painterResource(Res.drawable.holder), + error = painterResource(Res.drawable.holder), + contentScale = ContentScale.Crop, + modifier = + Modifier + .size(64.dp) + .scale(artworkScale) + .clip(RoundedCornerShape(8.dp)) + .hoverable(artworkInteractionSource), + ) + + // Track info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = nowPlayingData.nowPlayingTitle, + style = typo().bodyMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = nowPlayingData.artistName, + style = typo().bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + ) + } + + // Playback controls + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300, delayMillis = 150)), + exit = fadeOut(tween(200)), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Like button + IconButton( + onClick = { onUIEvent(UIEvent.ToggleLike) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + if (controllerState.isLiked) { + Icons.Filled.Favorite + } else { + Icons.Outlined.FavoriteBorder + }, + contentDescription = "Like", + tint = + if (controllerState.isLiked) { + Color(0xFFFF4081) + } else { + Color.White.copy(alpha = 0.7f) + }, + modifier = Modifier.size(20.dp), + ) + } + + RippleIconButton( + resId = Res.drawable.baseline_skip_previous_24, + modifier = Modifier.size(28.dp), + tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isPreviousAvailable) { + onUIEvent(UIEvent.Previous) + } + }, + ) + + PlayPauseButton( + isPlaying = controllerState.isPlaying, + modifier = Modifier.size(40.dp), + onClick = { + onUIEvent(UIEvent.PlayPause) + }, + ) + + RippleIconButton( + resId = Res.drawable.baseline_skip_next_24, + modifier = Modifier.size(32.dp), + tint = if (controllerState.isNextAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isNextAvailable) { + onUIEvent(UIEvent.Next) + } + }, + ) + + // Volume button + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.Filled.VolumeUp + } else { + Icons.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + + // Lyrics display below thumbnail row + if (lyricsData != null && !lyricsData.lyrics.error && lyricsData.lyrics.lines != null) { + val currentLine = + remember(timeline.current) { + lyricsData.lyrics.lines?.findLast { line -> + line.startTimeMs.toLongOrNull()?.let { it <= timeline.current } ?: false + } + } + + if (currentLine != null) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(bottom = 8.dp), + ) { + if (lyricsData.lyrics.syncType == "RICH_SYNCED") { + val parsedLine = + remember(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) { + val result = parseRichSyncWords(currentLine.words, currentLine.startTimeMs, currentLine.endTimeMs) + result + } + + if (parsedLine != null) { + RichSyncLyricsLineItem( + parsedLine = parsedLine, + translatedWords = null, + currentTimeMs = timeline.current, + isCurrent = true, + customFontSize = typo().bodySmall.fontSize, + modifier = Modifier, + ) + } + } else { + Text( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight( + align = Alignment.CenterVertically, + ).basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + textAlign = TextAlign.Center, + text = currentLine.words, + style = typo().bodySmall, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 11.sp, + ) + } + } + } + } + + // Progress bar + Box( + modifier = + Modifier + .fillMaxWidth() + .height(3.dp) + .background(Color(0xFF2C2C2E)), + ) { + if (timeline.total > 0L && timeline.current >= 0L) { + LinearProgressIndicator( + progress = { timeline.current.toFloat() / timeline.total }, + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.Transparent, + strokeCap = StrokeCap.Round, + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerManager.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerManager.kt new file mode 100644 index 000000000..31abafa6a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerManager.kt @@ -0,0 +1,13 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Manager for the mini player window state. + * Controls whether the mini player window is open or closed. + */ +object MiniPlayerManager { + var isOpen by mutableStateOf(false) +} diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt new file mode 100644 index 000000000..03c67abcd --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt @@ -0,0 +1,186 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.maxrave.simpmusic.viewModel.SharedViewModel +import java.awt.Cursor +import java.awt.MouseInfo + +/** + * Root composable for the mini player window content. + * Automatically switches between layouts based on window width: + * - < 260dp: Compact (controls only) + * - 260-360dp: Medium (artwork + controls) + * - > 360dp: Full (artwork + info + controls) + * + * Shows placeholder when no track is playing. + * Includes close button and drag handle since window is frameless. + */ +@Composable +fun MiniPlayerRoot( + sharedViewModel: SharedViewModel, + onClose: () -> Unit, + windowState: WindowState, +) { + val nowPlayingData by sharedViewModel.nowPlayingScreenData.collectAsStateWithLifecycle() + val controllerState by sharedViewModel.controllerState.collectAsStateWithLifecycle() + val timeline by sharedViewModel.timeline.collectAsStateWithLifecycle() + + val lyricsData by remember { + derivedStateOf { + nowPlayingData.lyricsData + } + } + + // Track mouse position for dragging + var dragStartMousePos by remember { mutableStateOf?>(null) } + var dragStartWindowPos by remember { mutableStateOf?>(null) } + + // Check if there's any track playing + val hasTrack = nowPlayingData.nowPlayingTitle.isNotBlank() + + Surface( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + color = Color(0xFF1C1C1E), + shape = RoundedCornerShape(12.dp), + ) { + Box(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { + if (!hasTrack) { + // Show empty state + EmptyMiniPlayerState() + } else { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Calculate aspect ratio to detect square/tall layout + val aspectRatio = maxWidth.value / maxHeight.value + val isSquareOrTall = aspectRatio <= 1.3f && maxHeight >= 200.dp + + when { + isSquareOrTall -> { + SquareMiniLayout( + nowPlayingData = nowPlayingData, + controllerState = controllerState, + timeline = timeline, + lyricsData = lyricsData, + onUIEvent = sharedViewModel::onUIEvent, + ) + } + + maxWidth < 260.dp -> { + CompactMiniLayout( + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent, + ) + } + + maxWidth < 360.dp -> { + MediumMiniLayout( + nowPlayingData = nowPlayingData, + controllerState = controllerState, + timeline = timeline, + lyricsData = lyricsData, + onUIEvent = sharedViewModel::onUIEvent, + ) + } + + else -> { + ExpandedMiniLayout( + nowPlayingData = nowPlayingData, + controllerState = controllerState, + timeline = timeline, + lyricsData = lyricsData, + onUIEvent = sharedViewModel::onUIEvent, + ) + } + } + } + } + + // Close button (top-right corner) + IconButton( + onClick = onClose, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp), + ) + } + + // Drag handle (top center area for moving window - narrower to avoid resize corners) + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(0.5f) + .height(28.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + // Store initial mouse and window positions + val mousePos = MouseInfo.getPointerInfo().location + dragStartMousePos = Pair(mousePos.x, mousePos.y) + val currentPos = windowState.position + if (currentPos is androidx.compose.ui.window.WindowPosition.Absolute) { + dragStartWindowPos = Pair(currentPos.x.value, currentPos.y.value) + } + }, + onDrag = { change, _ -> + change.consume() + val startMouse = dragStartMousePos + val startWindow = dragStartWindowPos + if (startMouse != null && startWindow != null) { + val currentMousePos = MouseInfo.getPointerInfo().location + val deltaX = currentMousePos.x - startMouse.first + val deltaY = currentMousePos.y - startMouse.second + windowState.position = + androidx.compose.ui.window.WindowPosition( + (startWindow.first + deltaX).dp, + (startWindow.second + deltaY).dp, + ) + } + }, + onDragEnd = { + dragStartMousePos = null + dragStartWindowPos = null + }, + ) + }.pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR))), + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerWindow.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerWindow.kt new file mode 100644 index 000000000..12bc98bcf --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerWindow.kt @@ -0,0 +1,131 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import com.maxrave.simpmusic.viewModel.SharedViewModel +import com.maxrave.simpmusic.viewModel.UIEvent +import org.jetbrains.compose.resources.painterResource +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.circle_app_icon +import java.awt.Dimension +import java.util.prefs.Preferences + +/** + * Mini player window - a separate always-on-top window for music controls. + * Spotify-style frameless design with custom close button. + * + * Features: + * - Always on top of other windows + * - Frameless (no title bar) + * - Resizable (default 400x110 dp) + * - Shares player state with main window + * - Close-safe (doesn't close main app) + * - Remembers window position + * - Keyboard shortcuts (Space: play/pause, Arrow keys: prev/next) + */ +@Composable +fun MiniPlayerWindow( + sharedViewModel: SharedViewModel, + onCloseRequest: () -> Unit, +) { + val prefs = remember { Preferences.userRoot().node("SimpMusic/MiniPlayer") } + + // Minimum size constraints + val minWidth = 200f + val minHeight = 56f + + // Load saved position or use default (with minimum constraints) + val savedX = prefs.getFloat("windowX", Float.NaN) + val savedY = prefs.getFloat("windowY", Float.NaN) + val savedWidth = prefs.getFloat("windowWidth", 400f).coerceAtLeast(minWidth) + val savedHeight = prefs.getFloat("windowHeight", 56f).coerceAtLeast(minHeight) + + var windowState by remember { + mutableStateOf( + WindowState( + placement = WindowPlacement.Floating, + position = + if (savedX.isNaN() || savedY.isNaN()) { + WindowPosition(Alignment.BottomEnd) + } else { + WindowPosition(savedX.dp, savedY.dp) + }, + size = DpSize(savedWidth.dp, savedHeight.dp), + ), + ) + } + + // Save position on change + LaunchedEffect(windowState.position, windowState.size) { + val pos = windowState.position + if (pos is WindowPosition.Absolute) { + prefs.putFloat("windowX", pos.x.value) + prefs.putFloat("windowY", pos.y.value) + } + prefs.putFloat("windowWidth", windowState.size.width.value) + prefs.putFloat("windowHeight", windowState.size.height.value) + } + + Window( + onCloseRequest = onCloseRequest, + title = "SimpMusic - Mini Player", + icon = painterResource(Res.drawable.circle_app_icon), + alwaysOnTop = true, + undecorated = true, + transparent = true, + resizable = true, + state = windowState, + onKeyEvent = { keyEvent -> + when { + keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Spacebar -> { + sharedViewModel.onUIEvent(UIEvent.PlayPause) + true + } + + keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.DirectionRight -> { + sharedViewModel.onUIEvent(UIEvent.Next) + true + } + + keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.DirectionLeft -> { + sharedViewModel.onUIEvent(UIEvent.Previous) + true + } + + else -> { + false + } + } + }, + ) { + // Set minimum size at AWT level to prevent flickering + LaunchedEffect(Unit) { + (window as? java.awt.Window)?.minimumSize = + Dimension( + (minWidth * window.graphicsConfiguration.defaultTransform.scaleX).toInt(), + (minHeight * window.graphicsConfiguration.defaultTransform.scaleY).toInt(), + ) + } + + MiniPlayerRoot( + sharedViewModel = sharedViewModel, + onClose = onCloseRequest, + windowState = windowState, + ) + } +} \ No newline at end of file diff --git a/core b/core index 507ff62a7..b011398b0 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 507ff62a7f1bb285a09ae82cc3ca43152f777fa7 +Subproject commit b011398b0e03392ece32573a1f16dcdf76bb83dd diff --git a/fastlane/metadata/android/en-US/changelogs/44.txt b/fastlane/metadata/android/en-US/changelogs/44.txt new file mode 100644 index 000000000..cfe700200 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/44.txt @@ -0,0 +1,7 @@ +- Mini floating player in desktop app +- New AppImage package for Linux desktop app +- Local tracking listening history and analytics +- Auto backup (Android only) +- Vote for SimpMusic Lyrics +- Fix download playlist +- Fix Spotify Canvas diff --git a/fastlane/metadata/android/vi-VN/changelogs/44.txt b/fastlane/metadata/android/vi-VN/changelogs/44.txt new file mode 100644 index 000000000..8114f4b99 --- /dev/null +++ b/fastlane/metadata/android/vi-VN/changelogs/44.txt @@ -0,0 +1,7 @@ +- Trình phát nổi nhỏ trong ứng dụng máy tính để bàn +- Gói AppImage mới cho ứng dụng máy tính để bàn Linux +- Theo dõi lịch sử nghe và phân tích cục bộ +- Sao lưu tự động (Chỉ Android) +- Bình chọn cho SimpMusic Lyrics +- Sửa lỗi tải xuống danh sách phát +- Sửa lỗi Spotify Canvas \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 941f91d1c..c9dfc5f76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,22 @@ [versions] # App version -version-name="1.0.1-hf" -version-code="43" +version-name = "1.0.2" +version-code = "44" -android = "8.13.1" -kotlin = "2.2.21" -serialization = "2.2.21" -ksp = "2.2.20-2.0.3" -compose-bom = "2025.12.00" -material3-expressive = "1.5.0-alpha10" +android = "8.13.2" +kotlin = "2.3.0" +serialization = "2.3.0" +ksp = "2.3.4" +compose-bom = "2026.01.00" +material3-expressive = "1.5.0-alpha12" constraintlayout-compose = "1.1.1" -activity-compose = "1.12.1" +activity-compose = "1.12.2" lifecycle = "2.9.6" core-ktx = "1.17.0" appcompat = "1.7.1" work = "2.11.0" startup-runtime = "1.2.0" -media3 = "1.8.0" +media3 = "1.9.0" okhttp3 = "5.3.2" junit = "4.13.2" androidx-junit = "1.3.0" @@ -31,35 +31,35 @@ coil3 = "3.3.0" kmpalette = "3.1.0" easypermissions = "3.0.0" datastore-preferences = "1.2.0" -paging = "3.4.0-alpha04" +paging = "3.4.0-rc01" customactivityoncrash = "2.4.0" -aboutlibraries = "13.1.0" +aboutlibraries = "13.2.1" ktor = "3.3.3" brotli = "0.1.2" ksoup = "0.6.0" desugaring = "2.1.5" koin-bom = "4.1.1" -md = "0.38.1" +md = "0.39.1" haze = "1.7.1" smart-exception = "0.2.1" onetimepassword = "2.4.1" -common = "1.19.0" +common = "1.20.0" json = "1.9.0" gemini-kotlin = "4.0.2" slf4j = "1.7.36" -sentry-android = "8.28.0" +sentry-android = "8.30.0" sentry-gradle-android = "5.12.2" -sentry-jvm = "8.28.0" -newpipe = "31edd9f7ad2b8e309442e86d5318cc2779480674" -webkit = "1.14.0" +sentry-jvm = "8.30.0" +newpipe = "v0.25.0" +webkit = "1.15.0" kermit = "2.0.8" paging-common = "3.3.6" glance = "1.1.1" -liquid-glass = "1.0.2" +liquid-glass = "1.0.4" ytdlp = "0.18.1" ffmpeg-kit-audio="6.0.1" composeHotReload = "1.0.0" -composeMultiplatform = "1.10.0-rc02" +composeMultiplatform = "1.10.0" datetime = "0.7.1" sqlite = "2.6.2" okio = "3.16.4" @@ -68,17 +68,17 @@ gst1JavaFx = "0.9.0" gst1JavaSwing = "0.9.0" material3-multiplatform = "1.10.0-alpha05" adaptive = "1.2.0" -material-multiplatform = "1.9.3" +material-multiplatform = "1.10.0" material-icons-multiplatform = "1.7.3" -componentsResources="1.10.0-rc02" -foundation="1.10.0-rc02" -runtime="1.10.0-rc02" +componentsResources = "1.10.0" +foundation = "1.10.0" +runtime = "1.10.0" compottie = "2.0.2" cmptoast = "1.0.71" uri = "0.0.21" buildkonfig = "0.17.1" -jna="5.18.1" -jnaPlatform="5.18.1" +jna = "5.18.1" +jnaPlatform = "5.18.1" calf = "0.9.0" material = "1.13.0" osdetector = "1.7.3" diff --git a/settings.gradle.kts b/settings.gradle.kts index 66354cccb..8c69aeeb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,8 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } + + // prepare for git submodules val mediaServiceCore = if (File(rootDir, "../MediaServiceCore").exists()) { @@ -66,7 +68,8 @@ val mediaDir = rootProject.name = "SimpMusic" include( - "composeApp", + ":androidApp", + ":composeApp", ":common", ":data", ":domain",