From 36eb81ef81c8f6178d8a177f808eec63f9aee063 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 4 Jan 2026 22:49:45 +0700 Subject: [PATCH 01/40] feat: migrate to new Android KMP structure --- .gitignore | 3 + MediaServiceCore | 2 +- androidApp/build.gradle.kts | 265 +++++++++++++++++ androidApp/src/main/AndroidManifest.xml | 223 ++++++++++++++ .../com/maxrave/simpmusic/MainActivity.kt | 56 ++-- .../maxrave/simpmusic/SimpMusicApplication.kt | 3 +- .../test/notification/NotificationHandler.kt | 17 +- .../service/test/notification/NotifyWork.kt | 0 .../simpmusic/ui/widget/AppWidgetReceiver.kt | 0 .../simpmusic/ui/widget/MainAppWidget.kt | 7 +- .../drawable-v24/ic_launcher_foreground.xml | 0 .../src/main}/res/drawable/app_icon.png | Bin .../res/drawable/baseline_pause_circle_24.xml | 0 .../res/drawable/baseline_play_circle_24.xml | 0 .../main}/res/drawable/baseline_repeat_24.xml | 0 .../drawable/baseline_repeat_24_enable.xml | 0 .../res/drawable/baseline_repeat_one_24.xml | 0 .../res/drawable/baseline_shuffle_24.xml | 0 .../res/drawable/baseline_skip_next_24.xml | 0 .../drawable/baseline_skip_previous_24.xml | 0 .../src/main}/res/drawable/holder.png | Bin .../src/main}/res/drawable/holder_video.png | Bin .../res/drawable/ic_launcher_background.xml | 0 .../src/main}/res/drawable/mono.xml | 0 .../src/main}/res/drawable/monochrome.xml | 0 .../src/main}/res/drawable/preview_widget.png | Bin .../src/main}/res/drawable/round_home_24.xml | 0 .../res/drawable/round_library_music_24.xml | 0 .../main}/res/drawable/round_search_24.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../main}/res/mipmap-hdpi/ic_launcher.webp | Bin .../mipmap-hdpi/ic_launcher_foreground.webp | Bin .../res/mipmap-hdpi/ic_launcher_round.webp | Bin .../main}/res/mipmap-mdpi/ic_launcher.webp | Bin .../mipmap-mdpi/ic_launcher_foreground.webp | Bin .../res/mipmap-mdpi/ic_launcher_round.webp | Bin .../main}/res/mipmap-xhdpi/ic_launcher.webp | Bin .../mipmap-xhdpi/ic_launcher_foreground.webp | Bin .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin .../main}/res/mipmap-xxhdpi/ic_launcher.webp | Bin .../mipmap-xxhdpi/ic_launcher_foreground.webp | Bin .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin .../main}/res/mipmap-xxxhdpi/ic_launcher.webp | Bin .../ic_launcher_foreground.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin .../src/main}/res/values/strings.xml | 0 .../res/xml/allowed_media_browser_callers.xml | 0 .../src/main}/res/xml/automotive_app_desc.xml | 0 .../src/main}/res/xml/backup_rules.xml | 0 .../main}/res/xml/data_extraction_rules.xml | 0 .../src/main}/res/xml/locale_config.xml | 0 .../main}/res/xml/network_security_config.xml | 0 .../src/main}/res/xml/new_app_widget_info.xml | 0 .../src/main}/res/xml/provider_paths.xml | 0 .../src/main}/res/xml/shortcuts.xml | 0 composeApp/build.gradle.kts | 278 ++---------------- .../src/androidMain/AndroidManifest.xml | 223 -------------- .../simpmusic/expect/OpenUrl.android.kt | 6 +- .../simpmusic/expect/Worker.android.kt | 31 -- .../com/maxrave/simpmusic/expect/Worker.kt | 3 - .../simpmusic/utils/ComposeResUtils.kt | 28 ++ .../simpmusic/viewModel/SharedViewModel.kt | 40 ++- .../simpmusic/viewModel/base/BaseViewModel.kt | 2 +- gradle/libs.versions.toml | 32 +- settings.gradle.kts | 3 +- 66 files changed, 641 insertions(+), 581 deletions(-) create mode 100644 androidApp/build.gradle.kts create mode 100644 androidApp/src/main/AndroidManifest.xml rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/MainActivity.kt (84%) rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/SimpMusicApplication.kt (98%) rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt (89%) rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/service/test/notification/NotifyWork.kt (100%) rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/ui/widget/AppWidgetReceiver.kt (100%) rename {composeApp/src/androidMain/kotlin => androidApp/src/main/java}/com/maxrave/simpmusic/ui/widget/MainAppWidget.kt (98%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable-v24/ic_launcher_foreground.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/app_icon.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_pause_circle_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_play_circle_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_repeat_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_repeat_24_enable.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_repeat_one_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_shuffle_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_skip_next_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/baseline_skip_previous_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/holder.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/holder_video.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/ic_launcher_background.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/mono.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/monochrome.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/preview_widget.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/round_home_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/round_library_music_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/round_search_24.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher_foreground.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher_round.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher_foreground.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher_round.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher_foreground.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher_round.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher_foreground.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher_round.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher_foreground.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher_round.webp (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/values/strings.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/allowed_media_browser_callers.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/automotive_app_desc.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/backup_rules.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/data_extraction_rules.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/locale_config.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/network_security_config.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/new_app_widget_info.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/provider_paths.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/xml/shortcuts.xml (100%) delete mode 100644 composeApp/src/androidMain/AndroidManifest.xml delete mode 100644 composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/Worker.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/Worker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/utils/ComposeResUtils.kt diff --git a/.gitignore b/.gitignore index 72122f4e8..50aa6f9c6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ sentry.properties /composeApp/cache/ /composeApp/kcef-bundle/ /.mcp.json + +/androidApp/build/ +/androidApp/cache/ diff --git a/MediaServiceCore b/MediaServiceCore index 5a4f7e1de..d377f4c95 160000 --- a/MediaServiceCore +++ b/MediaServiceCore @@ -1 +1 @@ -Subproject commit 5a4f7e1de5f36a1702d48e1c89e9acf66b22bdde +Subproject commit d377f4c952e8c3c8177449f769e2604a5e4eed53 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts new file mode 100644 index 000000000..0afc55343 --- /dev/null +++ b/androidApp/build.gradle.kts @@ -0,0 +1,265 @@ +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"), + "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" + } + } + 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/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 98% rename from composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/SimpMusicApplication.kt rename to androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt index a2e76b11b..062c110b6 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/SimpMusicApplication.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt @@ -95,8 +95,7 @@ class SimpMusicApplication : }, ), ) - } - .diskCachePolicy(CachePolicy.ENABLED) + }.diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .diskCache( DiskCache 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/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5f6cbd0ea..d9b6e7e70 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -16,23 +16,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 +32,13 @@ kotlin { freeCompilerArgs.add("-Xmulti-dollar-interpolation") freeCompilerArgs.add("-Xexpect-actual-classes") } - androidTarget { + android { + namespace = "com.maxrave.simpmusic.composeapp" + compileSdk = 36 + withJava() + androidResources { + enable = true + } compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -66,45 +64,22 @@ kotlin { implementation(koinBom) } 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 +100,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 +111,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 +143,7 @@ kotlin { implementation(libs.haze) implementation(libs.haze.material) - implementation(libs.cmptoast) + api(libs.cmptoast) implementation(libs.file.picker) } commonTest.dependencies { @@ -183,130 +158,6 @@ 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" @@ -362,6 +213,7 @@ compose.desktop { buildkonfig { packageName = "com.maxrave.simpmusic" + exposeObjectWithName = "BuildKonfig" defaultConfigs { val versionName = libs.versions.version.name @@ -408,96 +260,6 @@ 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) - } - 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) - } - } - } -} - afterEvaluate { tasks.withType { jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") 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/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/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/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/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 64ff2744e..a942f3246 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 @@ -732,24 +731,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 +769,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) @@ -1525,11 +1542,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 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..c73b3fecb 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 941f91d1c..e03e88a44 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,20 +3,20 @@ version-name="1.0.1-hf" version-code="43" -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 = "2025.12.01" +material3-expressive = "1.5.0-alpha11" 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,15 +31,15 @@ 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-beta01" 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.0" haze = "1.7.1" smart-exception = "0.2.1" onetimepassword = "2.4.1" @@ -47,15 +47,15 @@ common = "1.19.0" json = "1.9.0" gemini-kotlin = "4.0.2" slf4j = "1.7.36" -sentry-android = "8.28.0" +sentry-android = "8.29.0" sentry-gradle-android = "5.12.2" -sentry-jvm = "8.28.0" -newpipe = "31edd9f7ad2b8e309442e86d5318cc2779480674" -webkit = "1.14.0" +sentry-jvm = "8.29.0" +newpipe = "7b5cdd1b42ec66e98dbb1b3813a2695822588d6a" +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" diff --git a/settings.gradle.kts b/settings.gradle.kts index 66354cccb..506c4542d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,7 +66,8 @@ val mediaDir = rootProject.name = "SimpMusic" include( - "composeApp", + ":androidApp", + ":composeApp", ":common", ":data", ":domain", From e31e1e5f8d30e13c32b53a0951347334c4e73492 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 19:06:53 +0530 Subject: [PATCH 02/40] chore: add desktop mini player window skeleton --- .../kotlin/com/maxrave/simpmusic/main.kt | 12 ++++++++ .../ui/mini_player/MiniPlayerLayout.kt | 27 +++++++++++++++++ .../ui/mini_player/MiniPlayerManager.kt | 13 ++++++++ .../ui/mini_player/MiniPlayerRoot.kt | 24 +++++++++++++++ .../ui/mini_player/MiniPlayerWindow.kt | 30 +++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerManager.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerWindow.kt diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt index 6c79857a6..221cd5e02 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 @@ -130,4 +132,14 @@ fun main() = App() ToastHost() } + + // 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..cb8680fbe --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt @@ -0,0 +1,27 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.runtime.Composable +import com.maxrave.simpmusic.viewModel.SharedViewModel + +/** + * Adaptive layout components for the mini player. + * Different layouts will be used based on window width: + * - Compact (< 260dp): Controls only + * - Medium (260-360dp): Artwork + controls + * - Expanded (> 360dp): Full mini player with all info + */ + +@Composable +fun CompactMiniLayout(sharedViewModel: SharedViewModel) { + // TODO: Implement compact layout with controls only +} + +@Composable +fun MediumMiniLayout(sharedViewModel: SharedViewModel) { + // TODO: Implement medium layout with artwork + controls +} + +@Composable +fun ExpandedMiniLayout(sharedViewModel: SharedViewModel) { + // TODO: Implement expanded layout with full information +} 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..5a1950576 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt @@ -0,0 +1,24 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.maxrave.simpmusic.viewModel.SharedViewModel + +/** + * Root composable for the mini player window content. + * This will contain the adaptive layout logic based on window size. + */ +@Composable +fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // Placeholder content - will be replaced with adaptive layouts + Text("Mini Player") + } +} 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..c74d26eb5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerWindow.kt @@ -0,0 +1,30 @@ +package com.maxrave.simpmusic.ui.mini_player + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowState +import com.maxrave.simpmusic.viewModel.SharedViewModel + +/** + * Mini player window - a separate always-on-top window for music controls. + * This window is independent of the main application window. + */ +@Composable +fun MiniPlayerWindow( + sharedViewModel: SharedViewModel, + onCloseRequest: () -> Unit +) { + Window( + onCloseRequest = onCloseRequest, + title = "Mini Player", + alwaysOnTop = true, + resizable = true, + state = WindowState( + size = DpSize(360.dp, 120.dp) + ) + ) { + MiniPlayerRoot(sharedViewModel) + } +} From 877facec644f1b7910608106fb5b0706414e2a24 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 19:07:38 +0530 Subject: [PATCH 03/40] chore: update .gitignore to include dev files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 72122f4e8..df5008470 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/ From 682b96f5bab5c2ab7f05da2fff601858550f02a3 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 19:11:21 +0530 Subject: [PATCH 04/40] feat: add resizable always-on-top mini player window --- .../ui/mini_player/MiniPlayerRoot.kt | 147 +++++++++++++++++- .../ui/mini_player/MiniPlayerWindow.kt | 14 +- 2 files changed, 154 insertions(+), 7 deletions(-) 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 index 5a1950576..5f6787f03 100644 --- 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 @@ -1,24 +1,161 @@ package com.maxrave.simpmusic.ui.mini_player +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.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.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.maxrave.simpmusic.ui.component.PlayPauseButton +import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.theme.typo 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.baseline_skip_next_24 +import simpmusic.composeapp.generated.resources.baseline_skip_previous_24 +import simpmusic.composeapp.generated.resources.holder /** * Root composable for the mini player window content. - * This will contain the adaptive layout logic based on window size. + * Displays current track information and playback controls. */ @Composable fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { - Box( + val nowPlayingData by sharedViewModel.nowPlayingScreenData.collectAsStateWithLifecycle() + val controllerState by sharedViewModel.controllerState.collectAsStateWithLifecycle() + val timeline by sharedViewModel.timeline.collectAsStateWithLifecycle() + + Surface( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + color = Color(0xFF1C1C1E) ) { - // Placeholder content - will be replaced with adaptive layouts - Text("Mini Player") + 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 + 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) + .clip(RoundedCornerShape(8.dp)) + ) + + // 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 + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RippleIconButton( + resId = Res.drawable.baseline_skip_previous_24, + modifier = Modifier.size(32.dp), + tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, + onClick = { + if (controllerState.isPreviousAvailable) { + sharedViewModel.onUIEvent(UIEvent.Previous) + } + } + ) + + PlayPauseButton( + isPlaying = controllerState.isPlaying, + modifier = Modifier.size(40.dp), + onClick = { + sharedViewModel.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) { + sharedViewModel.onUIEvent(UIEvent.Next) + } + } + ) + } + } + + // 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, + ) + } + } + } } } 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 index c74d26eb5..237c7ef0a 100644 --- 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 @@ -6,10 +6,19 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowState import com.maxrave.simpmusic.viewModel.SharedViewModel +import org.jetbrains.compose.resources.painterResource +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.circle_app_icon /** * Mini player window - a separate always-on-top window for music controls. * This window is independent of the main application window. + * + * Features: + * - Always on top of other windows + * - Resizable (default 400x110 dp) + * - Shares player state with main window + * - Close-safe (doesn't close main app) */ @Composable fun MiniPlayerWindow( @@ -18,11 +27,12 @@ fun MiniPlayerWindow( ) { Window( onCloseRequest = onCloseRequest, - title = "Mini Player", + title = "SimpMusic - Mini Player", + icon = painterResource(Res.drawable.circle_app_icon), alwaysOnTop = true, resizable = true, state = WindowState( - size = DpSize(360.dp, 120.dp) + size = DpSize(400.dp, 110.dp) ) ) { MiniPlayerRoot(sharedViewModel) From 05320889c73153f1e71798ecd1d8acbea449b6a6 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 20:44:18 +0530 Subject: [PATCH 05/40] feat: implement adaptive layouts for mini player based on window size --- .../ui/mini_player/MiniPlayerLayout.kt | 205 ++++++++++++++++-- .../ui/mini_player/MiniPlayerRoot.kt | 43 +++- gradle/libs.versions.toml | 2 +- 3 files changed, 234 insertions(+), 16 deletions(-) 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 index cb8680fbe..988d9b3bf 100644 --- 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 @@ -1,27 +1,206 @@ package com.maxrave.simpmusic.ui.mini_player +import androidx.compose.animation.animateContentSize +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.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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.maxrave.simpmusic.viewModel.SharedViewModel +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +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.mediaservice.handler.ControlState +import com.maxrave.simpmusic.ui.component.PlayPauseButton +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.TimeLine +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 /** - * Adaptive layout components for the mini player. - * Different layouts will be used based on window width: - * - Compact (< 260dp): Controls only - * - Medium (260-360dp): Artwork + controls - * - Expanded (> 360dp): Full mini player with all info + * Compact layout (< 260dp): Controls only, no artwork or text + * Perfect for very narrow windows */ - @Composable -fun CompactMiniLayout(sharedViewModel: SharedViewModel) { - // TODO: Implement compact layout with controls only +fun CompactMiniLayout( + controllerState: ControlState, + timeline: TimeLine, + onUIEvent: (UIEvent) -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize().animateContentSize(), + color = Color(0xFF1C1C1E) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Controls only - centered + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + 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(sharedViewModel: SharedViewModel) { - // TODO: Implement medium layout with artwork + controls +fun MediumMiniLayout( + nowPlayingData: NowPlayingScreenData, + controllerState: ControlState, + timeline: TimeLine, + onUIEvent: (UIEvent) -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize().animateContentSize(), + color = Color(0xFF1C1C1E) + ) { + 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 + 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) + .clip(RoundedCornerShape(6.dp)) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Controls + Row( + horizontalArrangement = Arrangement.spacedBy(4.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) + } + } } +/** + * Progress bar component shared across all layouts + */ @Composable -fun ExpandedMiniLayout(sharedViewModel: SharedViewModel) { - // TODO: Implement expanded layout with full information +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, + ) + } + } } 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 index 5f6787f03..8270357c3 100644 --- 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 @@ -3,6 +3,7 @@ package com.maxrave.simpmusic.ui.mini_player import androidx.compose.foundation.background 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 @@ -42,7 +43,10 @@ import simpmusic.composeapp.generated.resources.holder /** * Root composable for the mini player window content. - * Displays current track information and playback controls. + * Automatically switches between layouts based on window width: + * - < 260dp: Compact (controls only) + * - 260-360dp: Medium (artwork + controls) + * - > 360dp: Full (artwork + info + controls) */ @Composable fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { @@ -50,6 +54,41 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { val controllerState by sharedViewModel.controllerState.collectAsStateWithLifecycle() val timeline by sharedViewModel.timeline.collectAsStateWithLifecycle() + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + when { + maxWidth < 260.dp -> CompactMiniLayout( + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + maxWidth < 360.dp -> MediumMiniLayout( + nowPlayingData = nowPlayingData, + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + else -> ExpandedMiniLayout( + nowPlayingData = nowPlayingData, + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + } + } +} + +/** + * Legacy full layout - now used only when BoxWithConstraints shows > 360dp + * Kept for backwards compatibility + */ +@Composable +private fun ExpandedMiniLayout( + nowPlayingData: com.maxrave.simpmusic.viewModel.NowPlayingScreenData, + controllerState: com.maxrave.domain.mediaservice.handler.ControlState, + timeline: com.maxrave.simpmusic.viewModel.TimeLine, + onUIEvent: (UIEvent) -> Unit +) { + Surface( modifier = Modifier.fillMaxSize(), color = Color(0xFF1C1C1E) @@ -132,7 +171,7 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { tint = if (controllerState.isNextAvailable) Color.White else Color.Gray, onClick = { if (controllerState.isNextAvailable) { - sharedViewModel.onUIEvent(UIEvent.Next) + onUIEvent(UIEvent.Next) } } ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 941f91d1c..7f72588e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ version-name="1.0.1-hf" version-code="43" -android = "8.13.1" +android = "8.13.2" kotlin = "2.2.21" serialization = "2.2.21" ksp = "2.2.20-2.0.3" From 1709e61c2d07f5b3ebf546e3b71b0a6dca7f9003 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 20:48:52 +0530 Subject: [PATCH 06/40] feat: add smooth animations and hover effects to mini player --- .../ui/mini_player/MiniPlayerLayout.kt | 194 +++++++++++------- .../ui/mini_player/MiniPlayerRoot.kt | 135 +++++++----- 2 files changed, 212 insertions(+), 117 deletions(-) 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 index 988d9b3bf..08ab31842 100644 --- 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 @@ -1,7 +1,17 @@ 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.background +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.Column @@ -18,9 +28,13 @@ 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 @@ -51,8 +65,18 @@ fun CompactMiniLayout( 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(), + modifier = Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)) + .hoverable(interactionSource), color = Color(0xFF1C1C1E) ) { Column(modifier = Modifier.fillMaxSize()) { @@ -63,37 +87,44 @@ fun CompactMiniLayout( .fillMaxWidth(), contentAlignment = Alignment.Center ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300)) + scaleIn(tween(300)), + exit = fadeOut(tween(200)) + scaleOut(tween(200)) ) { - 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) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.alpha(alpha) + ) { + 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) + ) + + 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) + } } - } - ) + ) + } } } @@ -114,8 +145,17 @@ fun MediumMiniLayout( timeline: TimeLine, 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(), + modifier = Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), color = Color(0xFF1C1C1E) ) { Column(modifier = Modifier.fillMaxSize()) { @@ -127,52 +167,66 @@ fun MediumMiniLayout( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - // Smaller artwork - 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) - .clip(RoundedCornerShape(6.dp)) - ) + // Smaller artwork with hover effect + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300)) + scaleIn(tween(300)), + exit = fadeOut(tween(200)) + scaleOut(tween(200)) + ) { + 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 - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + // Controls with fade-in + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300, delayMillis = 100)), + exit = fadeOut(tween(200)) ) { - 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) + Row( + horizontalArrangement = Arrangement.spacedBy(4.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) + ) + + 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) + } } - } - ) + ) + } } } 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 index 8270357c3..2d2155d52 100644 --- 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 @@ -1,6 +1,17 @@ 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.background +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 @@ -19,9 +30,11 @@ 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.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 @@ -88,9 +101,17 @@ private fun ExpandedMiniLayout( timeline: com.maxrave.simpmusic.viewModel.TimeLine, 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(), + modifier = Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), color = Color(0xFF1C1C1E) ) { Column( @@ -105,63 +126,82 @@ private fun ExpandedMiniLayout( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - // Album artwork - 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) - .clip(RoundedCornerShape(8.dp)) - ) - - // Track info - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center + // Album artwork with hover animation + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300)) + scaleIn(tween(300)), + exit = fadeOut(tween(200)) + scaleOut(tween(200)) ) { - 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 + 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) ) } - // Playback controls - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + // Track info with fade-in animation + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300, delayMillis = 100)), + exit = fadeOut(tween(200)) ) { - RippleIconButton( - resId = Res.drawable.baseline_skip_previous_24, - modifier = Modifier.size(32.dp), - tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray, - onClick = { - if (controllerState.isPreviousAvailable) { - sharedViewModel.onUIEvent(UIEvent.Previous) + 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 with fade-in animation + AnimatedVisibility( + visible = true, + enter = fadeIn(tween(300, delayMillis = 150)), + exit = fadeOut(tween(200)) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.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(40.dp), onClick = { - sharedViewModel.onUIEvent(UIEvent.PlayPause) + onUIEvent(UIEvent.PlayPause) } ) @@ -175,6 +215,7 @@ private fun ExpandedMiniLayout( } } ) + } } } From 8f2beb9ac74b955c862ed91836ba7184a63de0ed Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 21:32:52 +0530 Subject: [PATCH 07/40] feat: implement toggle functionality for desktop mini player --- .../expect/ToggleMiniPlayer.android.kt | 6 + .../simpmusic/expect/ToggleMiniPlayer.kt | 7 + .../ui/screen/player/NowPlayingScreen.kt | 12 ++ .../simpmusic/expect/ToggleMiniPlayer.ios.kt | 6 + .../simpmusic/expect/ToggleMiniPlayer.jvm.kt | 7 + .../ui/mini_player/MiniPlayerLayout.kt | 162 ++++++++---------- .../ui/mini_player/MiniPlayerRoot.kt | 115 ++++++------- 7 files changed, 162 insertions(+), 153 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.kt create mode 100644 composeApp/src/iosMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt 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/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/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index 4fc7c94e6..e79056967 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,6 +56,7 @@ 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 @@ -125,6 +126,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 @@ -731,6 +733,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 }) { 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..00c72339f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt @@ -0,0 +1,7 @@ +package com.maxrave.simpmusic.expect + +import com.maxrave.simpmusic.ui.mini_player.MiniPlayerManager + +actual fun toggleMiniPlayer() { + MiniPlayerManager.isOpen = !MiniPlayerManager.isOpen +} 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 index 08ab31842..69b8db974 100644 --- 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 @@ -42,12 +42,12 @@ 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.ui.component.PlayPauseButton 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.TimeLine import com.maxrave.simpmusic.viewModel.UIEvent import org.jetbrains.compose.resources.painterResource import simpmusic.composeapp.generated.resources.Res @@ -84,47 +84,41 @@ fun CompactMiniLayout( Box( modifier = Modifier .weight(1f) - .fillMaxWidth(), + .fillMaxWidth() + .alpha(alpha), contentAlignment = Alignment.Center ) { - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300)) + scaleIn(tween(300)), - exit = fadeOut(tween(200)) + scaleOut(tween(200)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.alpha(alpha) - ) { - 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) - } + 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) - } + } + ) + + 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) } - ) - } + } + ) } } @@ -168,65 +162,53 @@ fun MediumMiniLayout( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Smaller artwork with hover effect - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300)) + scaleIn(tween(300)), - exit = fadeOut(tween(200)) + scaleOut(tween(200)) - ) { - 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) - ) - } + 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 with fade-in - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300, delayMillis = 100)), - exit = fadeOut(tween(200)) + // Controls + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.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) - } + 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) - } + } + ) + + 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) } - ) - } + } + ) } } 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 index 2d2155d52..f6e25e0bd 100644 --- 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.maxrave.domain.data.model.streams.TimeLine import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.RippleIconButton import com.maxrave.simpmusic.ui.theme.typo @@ -98,7 +99,7 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { private fun ExpandedMiniLayout( nowPlayingData: com.maxrave.simpmusic.viewModel.NowPlayingScreenData, controllerState: com.maxrave.domain.mediaservice.handler.ControlState, - timeline: com.maxrave.simpmusic.viewModel.TimeLine, + timeline: TimeLine, onUIEvent: (UIEvent) -> Unit ) { val artworkInteractionSource = remember { MutableInteractionSource() } @@ -127,56 +128,44 @@ private fun ExpandedMiniLayout( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // Album artwork with hover animation - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300)) + scaleIn(tween(300)), - exit = fadeOut(tween(200)) + scaleOut(tween(200)) - ) { - 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) - ) - } + 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 with fade-in animation - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300, delayMillis = 100)), - exit = fadeOut(tween(200)) + // Track info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center ) { - 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 - ) - } + 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 with fade-in animation + // Playback controls AnimatedVisibility( visible = true, enter = fadeIn(tween(300, delayMillis = 150)), @@ -197,24 +186,24 @@ private fun ExpandedMiniLayout( } ) - PlayPauseButton( - isPlaying = controllerState.isPlaying, - modifier = Modifier.size(40.dp), - onClick = { - onUIEvent(UIEvent.PlayPause) - } - ) + 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) + 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) + } } - } - ) + ) } } } From 3187525304617665923c9e71a0a09ee8f31673ef Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 21:40:25 +0530 Subject: [PATCH 08/40] feat: add logging to toggleMiniPlayer function for better state tracking --- .../simpmusic/expect/ToggleMiniPlayer.jvm.kt | 3 + .../kotlin/com/maxrave/simpmusic/main.kt | 105 +++++++++--------- 2 files changed, 58 insertions(+), 50 deletions(-) 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 index 00c72339f..89d8f5eef 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/ToggleMiniPlayer.jvm.kt @@ -1,7 +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/main.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt index 221cd5e02..66904e966 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt @@ -43,62 +43,66 @@ 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), @@ -142,4 +146,5 @@ fun main() = } ) } - } \ No newline at end of file + } +} From a70baf673a87ed4f60dd47de78992e3625ff27ae Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 21:45:47 +0530 Subject: [PATCH 09/40] feat: add empty state and keyboard shortcuts to mini player window --- .../ui/mini_player/MiniPlayerRoot.kt | 81 ++++++++++++++----- .../ui/mini_player/MiniPlayerWindow.kt | 70 +++++++++++++++- 2 files changed, 129 insertions(+), 22 deletions(-) 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 index f6e25e0bd..69a2b318f 100644 --- 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 @@ -61,6 +61,8 @@ import simpmusic.composeapp.generated.resources.holder * - < 260dp: Compact (controls only) * - 260-360dp: Medium (artwork + controls) * - > 360dp: Full (artwork + info + controls) + * + * Shows placeholder when no track is playing. */ @Composable fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { @@ -68,25 +70,66 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { val controllerState by sharedViewModel.controllerState.collectAsStateWithLifecycle() val timeline by sharedViewModel.timeline.collectAsStateWithLifecycle() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - when { - maxWidth < 260.dp -> CompactMiniLayout( - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - maxWidth < 360.dp -> MediumMiniLayout( - nowPlayingData = nowPlayingData, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - else -> ExpandedMiniLayout( - nowPlayingData = nowPlayingData, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) + // Check if there's any track playing + val hasTrack = nowPlayingData?.nowPlayingTitle?.isNotBlank() == true + + if (!hasTrack) { + // Show empty state + EmptyMiniPlayerState() + } else { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + when { + maxWidth < 260.dp -> CompactMiniLayout( + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + maxWidth < 360.dp -> MediumMiniLayout( + nowPlayingData = nowPlayingData!!, + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + else -> ExpandedMiniLayout( + nowPlayingData = nowPlayingData!!, + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + } + } + } +} + +/** + * Empty state when no track is playing + */ +@Composable +private fun EmptyMiniPlayerState() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF1C1C1E) + ) { + 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) + ) + } } } } 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 index 237c7ef0a..867173f0d 100644 --- 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 @@ -1,14 +1,28 @@ 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.util.prefs.Preferences /** * Mini player window - a separate always-on-top window for music controls. @@ -19,21 +33,71 @@ import simpmusic.composeapp.generated.resources.circle_app_icon * - 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") } + + // Load saved position or use default + val savedX = prefs.getFloat("windowX", Float.NaN) + val savedY = prefs.getFloat("windowY", Float.NaN) + val savedWidth = prefs.getFloat("windowWidth", 400f) + val savedHeight = prefs.getFloat("windowHeight", 110f) + + 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, resizable = true, - state = WindowState( - size = DpSize(400.dp, 110.dp) - ) + 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 + } + } ) { MiniPlayerRoot(sharedViewModel) } From 56e27f098bea247cb68d01f343367e18709698ce Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Mon, 5 Jan 2026 21:53:52 +0530 Subject: [PATCH 10/40] feat: enhance mini player with close button and drag functionality --- .../ui/mini_player/MiniPlayerRoot.kt | 143 ++++++++++++------ .../ui/mini_player/MiniPlayerWindow.kt | 10 +- 2 files changed, 107 insertions(+), 46 deletions(-) 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 index 69a2b318f..dc28fed86 100644 --- 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 @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -24,7 +25,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape 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.LinearProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,10 +43,14 @@ 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.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowState import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.maxrave.domain.data.model.streams.TimeLine @@ -54,6 +64,8 @@ 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 +import java.awt.Cursor +import java.awt.MouseInfo /** * Root composable for the mini player window content. @@ -63,9 +75,14 @@ import simpmusic.composeapp.generated.resources.holder * - > 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) { +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() @@ -73,30 +90,73 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { // Check if there's any track playing val hasTrack = nowPlayingData?.nowPlayingTitle?.isNotBlank() == true - if (!hasTrack) { - // Show empty state - EmptyMiniPlayerState() - } else { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - when { - maxWidth < 260.dp -> CompactMiniLayout( - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - maxWidth < 360.dp -> MediumMiniLayout( - nowPlayingData = nowPlayingData!!, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - else -> ExpandedMiniLayout( - nowPlayingData = nowPlayingData!!, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF1C1C1E), + shape = RoundedCornerShape(8.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (!hasTrack) { + // Show empty state + EmptyMiniPlayerState() + } else { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + when { + maxWidth < 260.dp -> CompactMiniLayout( + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + maxWidth < 360.dp -> MediumMiniLayout( + nowPlayingData = nowPlayingData!!, + controllerState = controllerState, + timeline = timeline, + onUIEvent = sharedViewModel::onUIEvent + ) + else -> ExpandedMiniLayout( + nowPlayingData = nowPlayingData!!, + controllerState = controllerState, + timeline = timeline, + 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 area for moving window) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(0.7f) + .height(32.dp) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + val location = MouseInfo.getPointerInfo().location + windowState.position = androidx.compose.ui.window.WindowPosition( + (location.x - dragAmount.x).dp, + (location.y - dragAmount.y).dp + ) + } + } + .pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR))) + ) } } } @@ -106,30 +166,25 @@ fun MiniPlayerRoot(sharedViewModel: SharedViewModel) { */ @Composable private fun EmptyMiniPlayerState() { - Surface( + Box( modifier = Modifier.fillMaxSize(), - color = Color(0xFF1C1C1E) + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.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) - ) - } + 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) + ) } } } 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 index 867173f0d..73603cafc 100644 --- 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 @@ -26,10 +26,11 @@ import java.util.prefs.Preferences /** * Mini player window - a separate always-on-top window for music controls. - * This window is independent of the main application window. + * 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) @@ -79,6 +80,7 @@ fun MiniPlayerWindow( title = "SimpMusic - Mini Player", icon = painterResource(Res.drawable.circle_app_icon), alwaysOnTop = true, + undecorated = true, resizable = true, state = windowState, onKeyEvent = { keyEvent -> @@ -99,6 +101,10 @@ fun MiniPlayerWindow( } } ) { - MiniPlayerRoot(sharedViewModel) + MiniPlayerRoot( + sharedViewModel = sharedViewModel, + onClose = onCloseRequest, + windowState = windowState + ) } } From 285c7134e02ded91b80a9d5242408de06c7001ee Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Tue, 6 Jan 2026 06:10:00 +0530 Subject: [PATCH 11/40] feat: implement drag functionality for mini player and make window transparent --- .../ui/mini_player/MiniPlayerRoot.kt | 43 +++++++++++++++---- .../ui/mini_player/MiniPlayerWindow.kt | 1 + 2 files changed, 36 insertions(+), 8 deletions(-) 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 index dc28fed86..07c577040 100644 --- 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 @@ -36,7 +36,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.draw.clip @@ -87,6 +89,10 @@ fun MiniPlayerRoot( val controllerState by sharedViewModel.controllerState.collectAsStateWithLifecycle() val timeline by sharedViewModel.timeline.collectAsStateWithLifecycle() + // 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() == true @@ -146,14 +152,35 @@ fun MiniPlayerRoot( .fillMaxWidth(0.7f) .height(32.dp) .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - val location = MouseInfo.getPointerInfo().location - windowState.position = androidx.compose.ui.window.WindowPosition( - (location.x - dragAmount.x).dp, - (location.y - dragAmount.y).dp - ) - } + 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))) ) 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 index 73603cafc..c2f13bd6a 100644 --- 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 @@ -81,6 +81,7 @@ fun MiniPlayerWindow( icon = painterResource(Res.drawable.circle_app_icon), alwaysOnTop = true, undecorated = true, + transparent = true, resizable = true, state = windowState, onKeyEvent = { keyEvent -> From 590587c055ab0cc632d64fcf945f2b55b7a86337 Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Tue, 6 Jan 2026 06:19:21 +0530 Subject: [PATCH 12/40] feat: refine mini player drag handle size and add minimum window size constraints --- .../ui/mini_player/MiniPlayerRoot.kt | 6 +++--- .../ui/mini_player/MiniPlayerWindow.kt | 20 ++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) 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 index 07c577040..184fbb071 100644 --- 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 @@ -145,12 +145,12 @@ fun MiniPlayerRoot( ) } - // Drag handle (top area for moving window) + // Drag handle (top center area for moving window - narrower to avoid resize corners) Box( modifier = Modifier .align(Alignment.TopCenter) - .fillMaxWidth(0.7f) - .height(32.dp) + .fillMaxWidth(0.5f) + .height(28.dp) .pointerInput(Unit) { detectDragGestures( onDragStart = { 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 index c2f13bd6a..6c9924fa2 100644 --- 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 @@ -17,11 +17,13 @@ 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 androidx.compose.ui.window.rememberWindowState 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 /** @@ -44,11 +46,15 @@ fun MiniPlayerWindow( ) { val prefs = remember { Preferences.userRoot().node("SimpMusic/MiniPlayer") } - // Load saved position or use default + // Minimum size constraints + val minWidth = 200f + val minHeight = 90f + + // 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) - val savedHeight = prefs.getFloat("windowHeight", 110f) + val savedWidth = prefs.getFloat("windowWidth", 400f).coerceAtLeast(minWidth) + val savedHeight = prefs.getFloat("windowHeight", 110f).coerceAtLeast(minHeight) var windowState by remember { mutableStateOf( @@ -102,6 +108,14 @@ fun MiniPlayerWindow( } } ) { + // 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, From 0915db56fca2d2cc1badd07286a739f6773847fc Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Tue, 6 Jan 2026 06:49:23 +0530 Subject: [PATCH 13/40] feat: add like and volume buttons to mini player with responsive layout adjustments --- .../ui/mini_player/MiniPlayerLayout.kt | 323 +++++++++++++++--- .../ui/mini_player/MiniPlayerRoot.kt | 38 +++ 2 files changed, 308 insertions(+), 53 deletions(-) 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 index 69b8db974..bb11da177 100644 --- 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 @@ -24,6 +24,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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 @@ -152,68 +159,108 @@ fun MediumMiniLayout( .animateContentSize(animationSpec = tween(300)), color = Color(0xFF1C1C1E) ) { - 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 + BoxWithConstraints { + Column(modifier = Modifier.fillMaxSize()) { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.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) - } - } + // 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) ) - PlayPauseButton( - isPlaying = controllerState.isPlaying, - modifier = Modifier.size(36.dp), - onClick = { onUIEvent(UIEvent.PlayPause) } - ) + Spacer(modifier = Modifier.weight(1f)) - 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) + // 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 = maxWidth >= 300.dp, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + IconButton( + onClick = { /* TODO: Implement favorite toggle */ }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Outlined.FavoriteBorder, + contentDescription = "Like", + tint = 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 = maxWidth >= 300.dp, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + IconButton( + onClick = { /* TODO: Implement volume control */ }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Filled.VolumeUp, + contentDescription = "Volume", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + } } + + // Progress bar + ProgressBar(timeline) } - - // Progress bar - ProgressBar(timeline) } } } @@ -240,3 +287,173 @@ private fun ProgressBar(timeline: TimeLine) { } } } + +/** + * 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, + 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(12.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 = { + // TODO: Implement favorite toggle + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Outlined.FavoriteBorder, + contentDescription = "Like", + tint = 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 = { + // TODO: Implement volume control/mute toggle + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.VolumeUp, + contentDescription = "Volume", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} 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 index 184fbb071..806e1049e 100644 --- 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 @@ -29,6 +29,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +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 @@ -107,7 +109,17 @@ fun MiniPlayerRoot( 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, + onUIEvent = sharedViewModel::onUIEvent + ) maxWidth < 260.dp -> CompactMiniLayout( controllerState = controllerState, timeline = timeline, @@ -300,6 +312,19 @@ private fun ExpandedMiniLayout( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { + // Like button + IconButton( + onClick = { /* TODO: Implement favorite toggle */ }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Outlined.FavoriteBorder, + contentDescription = "Like", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + } + RippleIconButton( resId = Res.drawable.baseline_skip_previous_24, modifier = Modifier.size(28.dp), @@ -329,6 +354,19 @@ private fun ExpandedMiniLayout( } } ) + + // Volume button + IconButton( + onClick = { /* TODO: Implement volume control */ }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Filled.VolumeUp, + contentDescription = "Volume", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + } } } } From 2b81136f85d4f77acc39bf125f9cb94982604b1b Mon Sep 17 00:00:00 2001 From: Yugabharathi21 Date: Tue, 6 Jan 2026 07:06:45 +0530 Subject: [PATCH 14/40] feat: add volume control and like button functionality to mini player --- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 29 +++++++++- .../ui/mini_player/MiniPlayerLayout.kt | 57 +++++++++++++------ .../ui/mini_player/MiniPlayerRoot.kt | 27 +++++++-- 3 files changed, 90 insertions(+), 23 deletions(-) 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..4a481f651 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 @@ -99,6 +102,7 @@ import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette import com.maxrave.simpmusic.extension.toResizedBitmap +import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox @@ -807,7 +811,30 @@ 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/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt index bb11da177..d0f2a1418 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -160,6 +161,8 @@ fun MediumMiniLayout( color = Color(0xFF1C1C1E) ) { BoxWithConstraints { + val showExtraButtons = maxWidth >= 300.dp + Column(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier @@ -192,18 +195,24 @@ fun MediumMiniLayout( ) { // Like button - only show if width >= 300dp AnimatedVisibility( - visible = maxWidth >= 300.dp, + visible = showExtraButtons, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { IconButton( - onClick = { /* TODO: Implement favorite toggle */ }, + onClick = { onUIEvent(UIEvent.ToggleLike) }, modifier = Modifier.size(28.dp) ) { Icon( - imageVector = Icons.Outlined.FavoriteBorder, + imageVector = if (controllerState.isLiked) + Icons.Filled.Favorite + else + Icons.Outlined.FavoriteBorder, contentDescription = "Like", - tint = Color.White.copy(alpha = 0.7f), + tint = if (controllerState.isLiked) + Color(0xFFFF4081) + else + Color.White.copy(alpha = 0.7f), modifier = Modifier.size(18.dp) ) } @@ -239,17 +248,24 @@ fun MediumMiniLayout( // Volume button - only show if width >= 300dp AnimatedVisibility( - visible = maxWidth >= 300.dp, + visible = showExtraButtons, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { IconButton( - onClick = { /* TODO: Implement volume control */ }, + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, modifier = Modifier.size(28.dp) ) { Icon( - imageVector = Icons.Filled.VolumeUp, - contentDescription = "Volume", + 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) ) @@ -393,15 +409,19 @@ fun SquareMiniLayout( ) { // Like/Favorite button IconButton( - onClick = { - // TODO: Implement favorite toggle - }, + onClick = { onUIEvent(UIEvent.ToggleLike) }, modifier = Modifier.size(32.dp) ) { Icon( - imageVector = Icons.Outlined.FavoriteBorder, + imageVector = if (controllerState.isLiked) + Icons.Filled.Favorite + else + Icons.Outlined.FavoriteBorder, contentDescription = "Like", - tint = Color.White.copy(alpha = 0.7f), + tint = if (controllerState.isLiked) + Color(0xFFFF4081) + else + Color.White.copy(alpha = 0.7f), modifier = Modifier.size(24.dp) ) } @@ -440,13 +460,18 @@ fun SquareMiniLayout( // Volume/Mute button IconButton( onClick = { - // TODO: Implement volume control/mute toggle + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) }, modifier = Modifier.size(32.dp) ) { Icon( - imageVector = Icons.Filled.VolumeUp, - contentDescription = "Volume", + 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) ) 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 index 806e1049e..17ef16b43 100644 --- 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 @@ -29,6 +29,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +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 @@ -314,13 +316,19 @@ private fun ExpandedMiniLayout( ) { // Like button IconButton( - onClick = { /* TODO: Implement favorite toggle */ }, + onClick = { onUIEvent(UIEvent.ToggleLike) }, modifier = Modifier.size(28.dp) ) { Icon( - imageVector = Icons.Outlined.FavoriteBorder, + imageVector = if (controllerState.isLiked) + Icons.Filled.Favorite + else + Icons.Outlined.FavoriteBorder, contentDescription = "Like", - tint = Color.White.copy(alpha = 0.7f), + tint = if (controllerState.isLiked) + Color(0xFFFF4081) + else + Color.White.copy(alpha = 0.7f), modifier = Modifier.size(20.dp) ) } @@ -357,12 +365,19 @@ private fun ExpandedMiniLayout( // Volume button IconButton( - onClick = { /* TODO: Implement volume control */ }, + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + onUIEvent(UIEvent.UpdateVolume(newVolume)) + }, modifier = Modifier.size(28.dp) ) { Icon( - imageVector = Icons.Filled.VolumeUp, - contentDescription = "Volume", + 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) ) From 9f6fb7d51de1de2a8127368b37a852f40fa6b3b4 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 7 Jan 2026 13:49:36 +0700 Subject: [PATCH 15/40] feat: fix playlist is empty when download local playlist --- .github/workflows/android.yml | 6 +++--- MediaServiceCore | 2 +- androidApp/build.gradle.kts | 1 - {composeApp => androidApp}/proguard-rules.pro | 0 build_and_sign_apk.sh | 8 ++++---- core | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) rename {composeApp => androidApp}/proguard-rules.pro (100%) 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/MediaServiceCore b/MediaServiceCore index d377f4c95..082e649ee 160000 --- a/MediaServiceCore +++ b/MediaServiceCore @@ -1 +1 @@ -Subproject commit d377f4c952e8c3c8177449f769e2604a5e4eed53 +Subproject commit 082e649ee1f4f7b4bf90ea15d46328317ef4156b diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 0afc55343..f4c643370 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -87,7 +87,6 @@ android { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "consumer-rules.pro", "proguard-rules.pro", ) splits { diff --git a/composeApp/proguard-rules.pro b/androidApp/proguard-rules.pro similarity index 100% rename from composeApp/proguard-rules.pro rename to androidApp/proguard-rules.pro 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/core b/core index 507ff62a7..c82fa64a5 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 507ff62a7f1bb285a09ae82cc3ca43152f777fa7 +Subproject commit c82fa64a5729a0605e97d17382d4d66994afa514 From 99b657cfbd38dd75869d1099de6de0eb394d1680 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 7 Jan 2026 14:15:38 +0700 Subject: [PATCH 16/40] feat: fix build error JVM --- .../jvmMain/kotlin/com/maxrave/simpmusic/expect/Worker.jvm.kt | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/Worker.jvm.kt 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 From 30db4bc6a77b7a902512691df5f4ce319e198ad2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 8 Jan 2026 17:24:38 +0700 Subject: [PATCH 17/40] feat: add analytics repository --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index c82fa64a5..013f0ebc8 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c82fa64a5729a0605e97d17382d4d66994afa514 +Subproject commit 013f0ebc8f2476e2380e28f5187c0514a24fcd5d From 3bb6b89b40c7f576c2f73b17de993db53b34660c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 8 Jan 2026 22:59:35 +0700 Subject: [PATCH 18/40] feat: add local tracking to setting --- composeApp/build.gradle.kts | 1 + .../com/maxrave/simpmusic/extension/UIExt.kt | 34 +++++++++++++------ .../simpmusic/ui/screen/home/SettingScreen.kt | 6 ++++ .../simpmusic/viewModel/SettingsViewModel.kt | 19 +++++++++++ core | 2 +- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d9b6e7e70..a4709e5ba 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -35,6 +35,7 @@ kotlin { android { namespace = "com.maxrave.simpmusic.composeapp" compileSdk = 36 + minSdk = 26 withJava() androidResources { enable = true 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..54e15555e 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(800)) } else { this@animateScrollAndCentralizeItem.animateScrollToItem(index) } 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..51c38c1c0 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 @@ -375,6 +375,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() @@ -704,6 +705,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), 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..b245481a9 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,9 @@ class SettingsViewModel( private val _videoDownloadQuality = MutableStateFlow(null) val videoDownloadQuality: StateFlow = _videoDownloadQuality + private val _localTrackingEnabled = MutableStateFlow(false) + val localTrackingEnabled: StateFlow = _localTrackingEnabled + private var _alertData: MutableStateFlow = MutableStateFlow(null) val alertData: StateFlow = _alertData @@ -249,6 +252,7 @@ class SettingsViewModel( getCombineLocalAndYouTubeLiked() getDownloadQuality() getVideoDownloadQuality() + getLocalTrackingEnabled() viewModelScope.launch { calculateDataFraction( cacheRepository, @@ -258,6 +262,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 -> diff --git a/core b/core index 013f0ebc8..b2f152634 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 013f0ebc8f2476e2380e28f5187c0514a24fcd5d +Subproject commit b2f152634310e41c204775586d367a5b522a9c02 From e7cc88a8e9a7291d48a0df8e3b58ab0f9608df37 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 9 Jan 2026 16:53:54 +0700 Subject: [PATCH 19/40] feat: enhance analytics tracking with new event_artist entity and related queries --- .../maxrave/simpmusic/di/ViewModelModule.kt | 9 + .../com/maxrave/simpmusic/extension/UIExt.kt | 2 +- .../destination/home/AnalyticsDestination.kt | 6 + .../ui/navigation/graph/HomeScreenGraph.kt | 8 + .../ui/screen/home/RecentlySongsScreen.kt | 20 ++ .../screen/home/analytics/AnalyticsScreen.kt | 124 ++++++++ .../simpmusic/viewModel/AnalyticsViewModel.kt | 270 ++++++++++++++++++ .../simpmusic/viewModel/ArtistViewModel.kt | 10 +- .../simpmusic/viewModel/base/BaseViewModel.kt | 1 - core | 2 +- 10 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/home/AnalyticsDestination.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/analytics/AnalyticsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt 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/extension/UIExt.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt index 54e15555e..49247b493 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/UIExt.kt @@ -359,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).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/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/home/RecentlySongsScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt index dd8b1e153..f12c638a0 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 @@ -7,7 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoGraph import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -19,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems @@ -36,6 +41,7 @@ import com.maxrave.simpmusic.ui.component.EndOfPage 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.home.AnalyticsDestination import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.list.PlaylistDestination @@ -234,6 +240,20 @@ fun RecentlySongsScreen( } } }, + 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 + )) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, 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..b11f55afe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/analytics/AnalyticsScreen.kt @@ -0,0 +1,124 @@ +package com.maxrave.simpmusic.ui.screen.home.analytics + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoGraph +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.maxrave.logger.Logger +import com.maxrave.simpmusic.ui.component.EndOfPage +import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.AnalyticsViewModel +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import org.koin.compose.viewmodel.koinViewModel +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.baseline_arrow_back_ios_new_24 + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun AnalyticsScreen( + innerPadding: PaddingValues, + navController: NavController, + analyticsViewModel: AnalyticsViewModel = koinViewModel() +) { + val hazeState = rememberHazeState() + val uiState by analyticsViewModel.analyticsUiState.collectAsStateWithLifecycle() + + 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 }}") + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .hazeSource(state = hazeState), + ) { + item { + Spacer( + Modifier.size( + innerPadding.calculateTopPadding() + 64.dp, + ), + ) + } + + item { + EndOfPage() + } + } + + // Top App Bar with haze effect + TopAppBar( + modifier = + Modifier + .align(Alignment.TopCenter) + .hazeEffect(state = hazeState, style = HazeMaterials.ultraThin()) { + blurEnabled = true + }, + title = { + Text( + text = "Analytics", + style = typo().titleMedium, + ) + }, + navigationIcon = { + Box(Modifier.padding(horizontal = 5.dp)) { + RippleIconButton( + Res.drawable.baseline_arrow_back_ios_new_24, + Modifier.size(32.dp), + true, + ) { + navController.navigateUp() + } + } + }, + actions = { + IconButton( + onClick = { + + } + ) { + Box { + Icon(Icons.Rounded.AutoGraph, "Analytics", tint = Color.White) + Text("NEW", Modifier.align(Alignment.BottomEnd), style = typo().bodySmall.copy( + fontSize = 5.sp + )) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + } +} \ 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..64f399ca0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt @@ -0,0 +1,270 @@ +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.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 + +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() + getTopTracks(AnalyticsUiState.DayRange.LAST_7_DAYS) + getTopArtists(AnalyticsUiState.DayRange.LAST_7_DAYS) + getTopAlbums(AnalyticsUiState.DayRange.LAST_7_DAYS) + } + + 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) + ) + } + } + } + } + } + } +} + +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 topTracks: LocalResource>> = LocalResource.Loading(), + val topArtists: LocalResource>> = LocalResource.Loading(), + val topAlbums: LocalResource>> = LocalResource.Loading() +) { + enum class DayRange { + LAST_7_DAYS, + LAST_30_DAYS, + LAST_90_DAYS, + THIS_YEAR, + } +} \ 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/base/BaseViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/base/BaseViewModel.kt index c73b3fecb..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 @@ -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/core b/core index b2f152634..f60882ea4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit b2f152634310e41c204775586d367a5b522a9c02 +Subproject commit f60882ea4aec829272b0bf8acab01bea82814342 From f2b802670f083bd783afe143e612ad2bfb65a2b5 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 11 Jan 2026 23:08:25 +0700 Subject: [PATCH 20/40] feat: done local tracking --- .../composeResources/values/strings.xml | 15 + .../ui/component/FiveImagesComponent.kt | 348 +++++++ .../simpmusic/ui/component/FullWidthItems.kt | 12 + .../ui/screen/home/RecentlySongsScreen.kt | 21 +- .../screen/home/analytics/AnalyticsScreen.kt | 915 +++++++++++++++++- .../library/LibraryDynamicPlaylistScreen.kt | 181 +++- .../ui/screen/library/LibraryScreen.kt | 30 +- .../simpmusic/viewModel/AnalyticsViewModel.kt | 424 +++++--- .../LibraryDynamicPlaylistViewModel.kt | 1 + core | 2 +- 10 files changed, 1756 insertions(+), 193 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/FiveImagesComponent.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3fadddd9a..a32d28948 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -484,4 +484,19 @@ 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 \ No newline at end of file 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/screen/home/RecentlySongsScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/RecentlySongsScreen.kt index f12c638a0..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 @@ -7,11 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AutoGraph import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -23,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems @@ -41,7 +36,6 @@ import com.maxrave.simpmusic.ui.component.EndOfPage 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.home.AnalyticsDestination import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.list.PlaylistDestination @@ -240,20 +234,7 @@ fun RecentlySongsScreen( } } }, - 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 - )) - } - } - }, + actions = {}, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, 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 index b11f55afe..cd4c761dd 100644 --- 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 @@ -1,74 +1,826 @@ 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.Spacer +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.size +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.rounded.AutoGraph +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.RippleIconButton +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 dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource +import com.maxrave.simpmusic.viewModel.SharedViewModel import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import dev.chrisbanes.haze.rememberHazeState +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.baseline_arrow_back_ios_new_24 +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() + analyticsViewModel: AnalyticsViewModel = koinViewModel(), + sharedViewModel: SharedViewModel = koinInject(), ) { - val hazeState = rememberHazeState() - val uiState by analyticsViewModel.analyticsUiState.collectAsStateWithLifecycle() + 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", + "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() - .hazeSource(state = hazeState), + .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 { - Spacer( - Modifier.size( - innerPadding.calculateTopPadding() + 64.dp, - ), - ) + // 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 { @@ -76,42 +828,115 @@ fun AnalyticsScreen( } } + var dayRangeMenuExpanded by rememberSaveable { mutableStateOf(false) } // Top App Bar with haze effect TopAppBar( modifier = Modifier - .align(Alignment.TopCenter) - .hazeEffect(state = hazeState, style = HazeMaterials.ultraThin()) { - blurEnabled = true - }, - title = { - Text( - text = "Analytics", - style = typo().titleMedium, - ) - }, + .align(Alignment.TopCenter), + title = {}, navigationIcon = { - Box(Modifier.padding(horizontal = 5.dp)) { - RippleIconButton( - Res.drawable.baseline_arrow_back_ios_new_24, - Modifier.size(32.dp), - true, + 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, + ), + ), ) { - navController.navigateUp() + Icon(Icons.Default.ArrowBackIosNew, "Back") } } }, actions = { - IconButton( - onClick = { - - } + Box( + modifier = + Modifier + .clip(CircleShape) + .wrapContentSize() + .padding( + 12.dp, + ), ) { - Box { - Icon(Icons.Rounded.AutoGraph, "Analytics", tint = Color.White) - Text("NEW", Modifier.align(Alignment.BottomEnd), style = typo().bodySmall.copy( - fontSize = 5.sp - )) + 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 + }, + ) } } }, 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..5b1d3a1d4 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 @@ -36,17 +36,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 +74,12 @@ 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.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 +89,7 @@ fun LibraryDynamicPlaylistScreen( navController: NavController, type: String, viewModel: LibraryDynamicPlaylistViewModel = koinViewModel(), + analyticsViewModel: AnalyticsViewModel = koinViewModel(), sharedViewModel: SharedViewModel = koinInject(), ) { val nowPlayingVideoId by viewModel.nowPlayingVideoId.collectAsStateWithLifecycle() @@ -93,6 +107,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 +126,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 +176,154 @@ 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()), + ) + }, + ) + } + } + + 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 +451,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 +475,9 @@ sealed class LibraryDynamicPlaylistType { Followed -> "followed" MostPlayed -> "most_played" Downloaded -> "downloaded" + TopAlbums -> "top_albums" + TopArtists -> "top_artists" + TopTracks -> "top_tracks" } companion object { @@ -319,6 +487,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/viewModel/AnalyticsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt index 64f399ca0..c67adb831 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AnalyticsViewModel.kt @@ -4,6 +4,7 @@ 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 @@ -22,37 +23,51 @@ 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 albumRepository: AlbumRepository, +) : BaseViewModel() { private val _analyticsUIState: MutableStateFlow = MutableStateFlow(AnalyticsUiState()) - val analyticsUiState: StateFlow get() = _analyticsUIState.asStateFlow() + val analyticsUIState: StateFlow get() = _analyticsUIState.asStateFlow() init { getScrobblesCount() getArtistCount() getTotalListenTime() - getTopTracks(AnalyticsUiState.DayRange.LAST_7_DAYS) - getTopArtists(AnalyticsUiState.DayRange.LAST_7_DAYS) - getTopAlbums(AnalyticsUiState.DayRange.LAST_7_DAYS) + 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() + scrobblesCount = LocalResource.Loading(), ) } analyticsRepository.getTotalPlaybackEventCount().collect { count -> _analyticsUIState.update { it.copy( - scrobblesCount = LocalResource.Success(count) + scrobblesCount = LocalResource.Success(count), ) } } @@ -63,13 +78,13 @@ class AnalyticsViewModel( viewModelScope.launch { _analyticsUIState.update { it.copy( - artistCount = LocalResource.Loading() + artistCount = LocalResource.Loading(), ) } analyticsRepository.getTotalEventArtistCount().collect { count -> _analyticsUIState.update { it.copy( - artistCount = LocalResource.Success(count) + artistCount = LocalResource.Success(count), ) } } @@ -80,13 +95,13 @@ class AnalyticsViewModel( viewModelScope.launch { _analyticsUIState.update { it.copy( - totalListenTimeInSeconds = LocalResource.Loading() + totalListenTimeInSeconds = LocalResource.Loading(), ) } analyticsRepository.getTotalListeningTimeInSeconds().collect { total -> _analyticsUIState.update { it.copy( - totalListenTimeInSeconds = LocalResource.Success(total) + totalListenTimeInSeconds = LocalResource.Success(total), ) } } @@ -97,43 +112,47 @@ class AnalyticsViewModel( viewModelScope.launch { _analyticsUIState.update { it.copy( - topTracks = LocalResource.Loading() + 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) - ) - } + 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 - } + 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) - ) + 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), + ) + } } - } } } } @@ -143,113 +162,265 @@ class AnalyticsViewModel( viewModelScope.launch { _analyticsUIState.update { it.copy( - topArtists = LocalResource.Loading() + 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) - ) - } + 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 - } + 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) - ) + 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 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() + 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) - ) - } + 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 - } + 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) + 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( @@ -257,9 +428,11 @@ data class AnalyticsUiState( 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 topAlbums: LocalResource>> = LocalResource.Loading(), + val scrobblesLineChart: LocalResource>> = LocalResource.Loading(), ) { enum class DayRange { LAST_7_DAYS, @@ -267,4 +440,15 @@ data class AnalyticsUiState( 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/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/core b/core index f60882ea4..7073015d4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f60882ea4aec829272b0bf8acab01bea82814342 +Subproject commit 7073015d4f8fb1e0967d8a43b8db7c2575489294 From 069175fc092ca218ae5280c3319f72ca08b1b6b5 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 12 Jan 2026 16:04:11 +0700 Subject: [PATCH 21/40] feat: init auto backup --- .../maxrave/simpmusic/SimpMusicApplication.kt | 18 ++ .../service/backup/AutoBackupScheduler.kt | 84 ++++++ .../service/backup/AutoBackupWorker.kt | 248 ++++++++++++++++++ .../composeResources/values/strings.xml | 12 + .../simpmusic/ui/screen/home/SettingScreen.kt | 94 +++++++ .../library/LibraryDynamicPlaylistScreen.kt | 18 ++ .../simpmusic/viewModel/SettingsViewModel.kt | 71 +++++ core | 2 +- 8 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupScheduler.kt create mode 100644 androidApp/src/main/java/com/maxrave/simpmusic/service/backup/AutoBackupWorker.kt diff --git a/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt index 062c110b6..6e73ae3e6 100644 --- a/androidApp/src/main/java/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 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/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a32d28948..56761ec9c 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 %d 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 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 51c38c1c0..a3a4fd036 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 @@ -168,6 +168,16 @@ import simpmusic.composeapp.generated.resources.auto_check_for_update_descriptio 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.auto_backup +import simpmusic.composeapp.generated.resources.auto_backup_description +import simpmusic.composeapp.generated.resources.backup_frequency +import simpmusic.composeapp.generated.resources.daily +import simpmusic.composeapp.generated.resources.weekly +import simpmusic.composeapp.generated.resources.monthly +import simpmusic.composeapp.generated.resources.keep_backups +import simpmusic.composeapp.generated.resources.keep_backups_format +import simpmusic.composeapp.generated.resources.last_backup +import simpmusic.composeapp.generated.resources.never 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 @@ -415,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() @@ -1647,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/library/LibraryDynamicPlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LibraryDynamicPlaylistScreen.kt index 5b1d3a1d4..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 @@ -77,6 +79,7 @@ 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 @@ -292,6 +295,21 @@ fun LibraryDynamicPlaylistScreen( 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, + ) + } + }, ) } } 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 b245481a9..12be3fc86 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -171,6 +171,19 @@ class SettingsViewModel( 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 @@ -253,6 +266,10 @@ class SettingsViewModel( getDownloadQuality() getVideoDownloadQuality() getLocalTrackingEnabled() + getAutoBackupEnabled() + getAutoBackupFrequency() + getAutoBackupMaxFiles() + getAutoBackupLastTime() viewModelScope.launch { calculateDataFraction( cacheRepository, @@ -465,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/core b/core index 7073015d4..3b5fdac2d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 7073015d4f8fb1e0967d8a43b8db7c2575489294 +Subproject commit 3b5fdac2d8810989f98b902c3cc032abc1c54721 From 1acbe288e3e27d5d619c60cdb74d3d851c5457dc Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 13 Jan 2026 15:44:13 +0700 Subject: [PATCH 22/40] feat: update backup settings and improve string formatting --- .../composeResources/values/strings.xml | 2 +- .../simpmusic/ui/screen/home/SettingScreen.kt | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 56761ec9c..e42a6fb09 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -465,7 +465,7 @@ Weekly Monthly Keep backups - Keep last %d backups + Keep last %1$s backups Last backup Never Auto backup completed successfully 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 a3a4fd036..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,21 +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.auto_backup -import simpmusic.composeapp.generated.resources.auto_backup_description import simpmusic.composeapp.generated.resources.backup_frequency -import simpmusic.composeapp.generated.resources.daily -import simpmusic.composeapp.generated.resources.weekly -import simpmusic.composeapp.generated.resources.monthly -import simpmusic.composeapp.generated.resources.keep_backups -import simpmusic.composeapp.generated.resources.keep_backups_format -import simpmusic.composeapp.generated.resources.last_backup -import simpmusic.composeapp.generated.resources.never 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 @@ -206,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 @@ -232,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 @@ -239,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 @@ -251,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 @@ -311,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 @@ -1705,7 +1705,7 @@ fun SettingScreen( ) SettingItem( title = stringResource(Res.string.keep_backups), - subtitle = stringResource(Res.string.keep_backups_format, autoBackupMaxFiles), + subtitle = stringResource(Res.string.keep_backups_format, "$autoBackupMaxFiles"), onClick = { viewModel.setAlertData( SettingAlertState( From e46c7c26d5b895a5ce57d30b33f0f4b5c9066f62 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 13 Jan 2026 23:15:18 +0700 Subject: [PATCH 23/40] feat: add analytics tracking features and UI components for top artists, albums, and tracks --- .../ui/mini_player/MiniPlayerLayout.kt | 615 ++++++++++++++---- .../ui/mini_player/MiniPlayerRoot.kt | 424 +++--------- .../ui/mini_player/MiniPlayerWindow.kt | 58 +- 3 files changed, 618 insertions(+), 479 deletions(-) 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 index d0f2a1418..ee20f86f6 100644 --- 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 @@ -8,7 +8,10 @@ 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 @@ -23,7 +26,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.width +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 @@ -46,6 +49,7 @@ 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 @@ -71,34 +75,36 @@ import simpmusic.composeapp.generated.resources.holder fun CompactMiniLayout( controllerState: ControlState, timeline: TimeLine, - onUIEvent: (UIEvent) -> Unit + 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) + animationSpec = tween(200), ) - + Surface( - modifier = Modifier - .fillMaxSize() - .animateContentSize(animationSpec = tween(300)) - .hoverable(interactionSource), - color = Color(0xFF1C1C1E) + 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 + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .alpha(alpha), + contentAlignment = Alignment.Center, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { RippleIconButton( resId = Res.drawable.baseline_skip_previous_24, @@ -108,15 +114,15 @@ fun CompactMiniLayout( if (controllerState.isPreviousAvailable) { onUIEvent(UIEvent.Previous) } - } + }, ) - + PlayPauseButton( isPlaying = controllerState.isPlaying, modifier = Modifier.size(36.dp), - onClick = { onUIEvent(UIEvent.PlayPause) } + onClick = { onUIEvent(UIEvent.PlayPause) }, ) - + RippleIconButton( resId = Res.drawable.baseline_skip_next_24, modifier = Modifier.size(28.dp), @@ -125,11 +131,11 @@ fun CompactMiniLayout( if (controllerState.isNextAvailable) { onUIEvent(UIEvent.Next) } - } + }, ) } } - + // Progress bar ProgressBar(timeline) } @@ -145,32 +151,35 @@ fun MediumMiniLayout( nowPlayingData: NowPlayingScreenData, controllerState: ControlState, timeline: TimeLine, - onUIEvent: (UIEvent) -> Unit + 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) + animationSpec = tween(200), ) - + Surface( - modifier = Modifier - .fillMaxSize() - .animateContentSize(animationSpec = tween(300)), - color = Color(0xFF1C1C1E) + 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), + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { // Smaller artwork with hover effect AsyncImage( @@ -179,45 +188,50 @@ fun MediumMiniLayout( 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) + 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 + verticalAlignment = Alignment.CenterVertically, ) { // Like button - only show if width >= 300dp AnimatedVisibility( visible = showExtraButtons, enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut() + exit = scaleOut() + fadeOut(), ) { IconButton( onClick = { onUIEvent(UIEvent.ToggleLike) }, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) { Icon( - imageVector = if (controllerState.isLiked) - Icons.Filled.Favorite - else - Icons.Outlined.FavoriteBorder, + 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) + 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), @@ -226,15 +240,15 @@ fun MediumMiniLayout( if (controllerState.isPreviousAvailable) { onUIEvent(UIEvent.Previous) } - } + }, ) - + PlayPauseButton( isPlaying = controllerState.isPlaying, modifier = Modifier.size(36.dp), - onClick = { onUIEvent(UIEvent.PlayPause) } + onClick = { onUIEvent(UIEvent.PlayPause) }, ) - + RippleIconButton( resId = Res.drawable.baseline_skip_next_24, modifier = Modifier.size(28.dp), @@ -243,37 +257,78 @@ fun MediumMiniLayout( if (controllerState.isNextAvailable) { onUIEvent(UIEvent.Next) } - } + }, ) - + // Volume button - only show if width >= 300dp AnimatedVisibility( visible = showExtraButtons, enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut() + exit = scaleOut() + fadeOut(), ) { IconButton( - onClick = { + onClick = { // Toggle mute/unmute val newVolume = if (controllerState.volume > 0f) 0f else 1f onUIEvent(UIEvent.UpdateVolume(newVolume)) }, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) { Icon( - imageVector = if (controllerState.volume > 0f) - Icons.Filled.VolumeUp - else - Icons.Filled.VolumeOff, + 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) + 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, + ) { + 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) } @@ -287,10 +342,11 @@ fun MediumMiniLayout( @Composable private fun ProgressBar(timeline: TimeLine) { Box( - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .background(Color(0xFF2C2C2E)) + modifier = + Modifier + .fillMaxWidth() + .height(3.dp) + .background(Color(0xFF2C2C2E)), ) { if (timeline.total > 0L && timeline.current >= 0L) { LinearProgressIndicator( @@ -314,30 +370,33 @@ fun SquareMiniLayout( nowPlayingData: NowPlayingScreenData, controllerState: ControlState, timeline: TimeLine, - onUIEvent: (UIEvent) -> Unit + 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) + animationSpec = tween(300), ) - + Surface( - modifier = Modifier - .fillMaxSize() - .animateContentSize(animationSpec = tween(300)), - color = Color(0xFF1C1C1E) + modifier = + Modifier + .fillMaxSize() + .animateContentSize(animationSpec = tween(300)), + color = Color(0xFF1C1C1E), ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { Spacer(modifier = Modifier.height(24.dp)) - + // Large centered album artwork AsyncImage( model = nowPlayingData.thumbnailURL, @@ -345,20 +404,21 @@ fun SquareMiniLayout( 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) + 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 + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = nowPlayingData.nowPlayingTitle, @@ -366,7 +426,7 @@ fun SquareMiniLayout( color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 16.sp + fontSize = 16.sp, ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -375,18 +435,52 @@ fun SquareMiniLayout( color = Color.Gray, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 13.sp + fontSize = 13.sp, ) } - - Spacer(modifier = Modifier.height(12.dp)) - + + 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) { + 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)) + modifier = + Modifier + .fillMaxWidth() + .height(4.dp) + .background(Color(0xFF2C2C2E), RoundedCornerShape(2.dp)), ) { if (timeline.total > 0L && timeline.current >= 0L) { LinearProgressIndicator( @@ -398,34 +492,38 @@ fun SquareMiniLayout( ) } } - + Spacer(modifier = Modifier.height(12.dp)) - + // Main playback controls Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Like/Favorite button IconButton( onClick = { onUIEvent(UIEvent.ToggleLike) }, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Icon( - imageVector = if (controllerState.isLiked) - Icons.Filled.Favorite - else - Icons.Outlined.FavoriteBorder, + 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) + 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, @@ -435,16 +533,16 @@ fun SquareMiniLayout( if (controllerState.isPreviousAvailable) { onUIEvent(UIEvent.Previous) } - } + }, ) - + // Play/Pause PlayPauseButton( isPlaying = controllerState.isPlaying, modifier = Modifier.size(52.dp), - onClick = { onUIEvent(UIEvent.PlayPause) } + onClick = { onUIEvent(UIEvent.PlayPause) }, ) - + // Next RippleIconButton( resId = Res.drawable.baseline_skip_next_24, @@ -454,9 +552,9 @@ fun SquareMiniLayout( if (controllerState.isNextAvailable) { onUIEvent(UIEvent.Next) } - } + }, ) - + // Volume/Mute button IconButton( onClick = { @@ -464,21 +562,278 @@ fun SquareMiniLayout( val newVolume = if (controllerState.volume > 0f) 0f else 1f onUIEvent(UIEvent.UpdateVolume(newVolume)) }, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Icon( - imageVector = if (controllerState.volume > 0f) - Icons.Filled.VolumeUp - else - Icons.Filled.VolumeOff, + 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) + 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), + ) { + 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/MiniPlayerRoot.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerRoot.kt index 17ef16b43..03c67abcd 100644 --- 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 @@ -1,75 +1,36 @@ 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.background import androidx.compose.foundation.gestures.detectDragGestures -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.width -import androidx.compose.foundation.shape.CircleShape +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.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.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.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap 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.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.WindowState import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import com.maxrave.domain.data.model.streams.TimeLine -import com.maxrave.simpmusic.ui.component.PlayPauseButton -import com.maxrave.simpmusic.ui.component.RippleIconButton -import com.maxrave.simpmusic.ui.theme.typo 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.baseline_skip_next_24 -import simpmusic.composeapp.generated.resources.baseline_skip_previous_24 -import simpmusic.composeapp.generated.resources.holder import java.awt.Cursor import java.awt.MouseInfo @@ -79,7 +40,7 @@ import java.awt.MouseInfo * - < 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. */ @@ -87,25 +48,31 @@ import java.awt.MouseInfo fun MiniPlayerRoot( sharedViewModel: SharedViewModel, onClose: () -> Unit, - windowState: WindowState + 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() == true - + val hasTrack = nowPlayingData.nowPlayingTitle.isNotBlank() + Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth().wrapContentHeight(), color = Color(0xFF1C1C1E), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(12.dp), ) { - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { if (!hasTrack) { // Show empty state EmptyMiniPlayerState() @@ -114,295 +81,106 @@ fun MiniPlayerRoot( // 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, - onUIEvent = sharedViewModel::onUIEvent - ) - maxWidth < 260.dp -> CompactMiniLayout( - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - maxWidth < 360.dp -> MediumMiniLayout( - nowPlayingData = nowPlayingData!!, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) - else -> ExpandedMiniLayout( - nowPlayingData = nowPlayingData!!, - controllerState = controllerState, - timeline = timeline, - onUIEvent = sharedViewModel::onUIEvent - ) + 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) + 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) + 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))) - ) - } - } -} - -/** - * Empty state when no track is playing - */ -@Composable -private 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 -private fun ExpandedMiniLayout( - nowPlayingData: com.maxrave.simpmusic.viewModel.NowPlayingScreenData, - controllerState: com.maxrave.domain.mediaservice.handler.ControlState, - timeline: TimeLine, - 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) + 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 + }, ) - } - } - } - } - - // 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, - ) - } - } + }.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 index 6c9924fa2..12bc98bcf 100644 --- 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 @@ -17,7 +17,6 @@ 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 androidx.compose.ui.window.rememberWindowState import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent import org.jetbrains.compose.resources.painterResource @@ -29,7 +28,7 @@ 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) @@ -42,34 +41,35 @@ import java.util.prefs.Preferences @Composable fun MiniPlayerWindow( sharedViewModel: SharedViewModel, - onCloseRequest: () -> Unit + onCloseRequest: () -> Unit, ) { val prefs = remember { Preferences.userRoot().node("SimpMusic/MiniPlayer") } - + // Minimum size constraints val minWidth = 200f - val minHeight = 90f - + 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", 110f).coerceAtLeast(minHeight) - + 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) - ) + 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 @@ -80,7 +80,7 @@ fun MiniPlayerWindow( prefs.putFloat("windowWidth", windowState.size.width.value) prefs.putFloat("windowHeight", windowState.size.height.value) } - + Window( onCloseRequest = onCloseRequest, title = "SimpMusic - Mini Player", @@ -96,30 +96,36 @@ fun MiniPlayerWindow( 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 + + 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() - ) + (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 + windowState = windowState, ) } -} +} \ No newline at end of file From d2bdc7b35a455351abe9a63fa6b03fc875e0f39b Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 14 Jan 2026 08:59:00 +0700 Subject: [PATCH 24/40] docs: update README to acknowledge contributors with a badge --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 63d506fc9..07bb0bdc3 100644 --- a/README.md +++ b/README.md @@ -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). From efc771c477048566a9b47827bf7f178dd14716db Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 14 Jan 2026 17:08:25 +0700 Subject: [PATCH 25/40] feat: add AppImage packaging support and create desktop entry for SimpMusic --- .../workflows/desktop-appimage-package.yml | 61 +++++++++++ composeApp/appimage/AppRun | 10 ++ composeApp/appimage/simpmusic.desktop | 8 ++ composeApp/appimage/simpmusic.png | Bin 0 -> 9266 bytes composeApp/build.gradle.kts | 95 +++++++++++++++++- settings.gradle.kts | 2 + 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/desktop-appimage-package.yml create mode 100644 composeApp/appimage/AppRun create mode 100644 composeApp/appimage/simpmusic.desktop create mode 100644 composeApp/appimage/simpmusic.png diff --git a/.github/workflows/desktop-appimage-package.yml b/.github/workflows/desktop-appimage-package.yml new file mode 100644 index 000000000..d5ddf37ee --- /dev/null +++ b/.github/workflows/desktop-appimage-package.yml @@ -0,0 +1,61 @@ +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-deb: + 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: 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/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 0000000000000000000000000000000000000000..71a4ee2dd7f29136255748b56d41c691256ff9e2 GIT binary patch literal 9266 zcmZ{Kbxa&y(Cy-j7k77u;_gKX#ogWA7GDaB7AX{`NQ=7`hsCu(TO10D7hj4k{`$*H zUh?wg%gx-AoXMH<$IM^%PJ;G360GW3|myXoG1iI~86(zvSf2X*&BI6%}>8)zw z2LNCb{TE1pyaMunB!<75rZUD65*{W#=_-fiA^^bnq^2aV4_ZC_?3U(W6p+&&Fw@!I zT~V5`RG6^f*-BKlBSMD9oGR2Btd`f*v3MOyuu(+tNx22uD=q~ zp-;@3nb+x@=Gm@j z4!m&2Uj{6KFQMWPmME2|IB*e{R4v>YxdJSle8ojz(?8Jnr-`jYxeFk@r_E@N)`QwX z3aWx;LRp~5DRUWMTx$|BTxuhP09+BK0`-Y%>GPs3sZL}5mAX-NW}~&h(0t^8Y>wW8 z3_^iRz$l}bMtXn8P=idjkfOSY&rtiYrFh?oWfF3Jww>S)kUp+XC*Aa;Ht++bK#x&3 zkk$d_$}D{qXcEw9u{Q``ydV~M3{SB}QC?2o#%{GL**Nu=c>=@|>IT|6J6xeW^^fM9 z4k8zN3MC_nU_UdhnkwhR~Ra&A3D-4vU(V!2Xz8#p|GiIoTi62?(m?> z7ihrVz;>WDfnoj+*k?$dItSwZK~o~jKUPKkl%ulb@UmIpW6%emt>wt~5n>pRRfCa8 z?{4M^*w^IM8tsH@B(}*49*?0s$PJrKAEjDgc#u@=^+O+aNoGt|OSeL<@&+DQIF=~3 zR8U;n^#{}dhQ0hKSqu>yr$sVWQ?q(?Erx)ICkB`uG!C)X0%t?3f!0CUq6GPayRB`7 zt2o^{{v>nGl5Axl_X|#lM5xI}^#h(!;Kmqa?75%Rb5wacF;8H&H|@w?8y*_cLt8b_ zxTuF-BWcWl##2hC-}5`I6L8Cd8@uc(9Gr7s>dX6Ht9uH*ij{wIH@G zyM&hbZLy+>Laqk#c?cSby3vf%u_`gBZI3>#=}w1`F3{@Yi`D z;i&NwBGvt<;N`lFjDI`3A=>y$3ICS+WB@@+1WFxjCwzlXkva-MQ>l78`#cz7y0DhK zQr`ig#1>dT7qVktkNRr~^LewL%CC<`?vu^}SLvS~o^89Yq_m)RQEj_l{@Zz0u*Crn zA$TTd@Oz@MJk~}}JMv?KV-SrVrVJPotSV7^nPv7@5p5{%-pjJ!eYeNS9TJSMi=?sx z^>>65n|BNJz~v~meszMUqWbNNh7n`R<_X>&eQX2550al+qtlKk)R;FcfmsQo;B#Ql z+byN~Xu(8E7BCr|^cV`=F2$QLuEesELVj!7DSs zr-`;Zy9dWI$ho46_R-uoUUZo+ljiCz#PP4eUivT(sALdCeJzI79u%`;;)L5>5<|Du zvhXZIk#C3vG9bv1W8xpsOn~3WZ&=fzkiRn-K7798N%1kVenrqTbN#bH$(!FVpI+!O zkgGgR<=)97*7R<`P8_@z96-LUTbzod@g$1mwGI(D2Yy-<35b+-To*RE4>TNAil}w} zb=^o~(}l{Y!2TL|(6)$Ew)ShehDw8V7c;&5pVD(ME}1dW zkab_j&yu2D?tob}q79k+i?vHZss7Bl^bTN3geZYzgXz4`GV?bN3kE@Ev2Dn=c$`W$ z3CCX;ToBi%y8DZA>WI3E^9?pzOix|2Kxn2&ZDIn2--5p&qoQ8oSs)TZkT8a$Y zJy#-?70yd=c4(4-s_fZ+JYKf_t`4+vmhLqzKbBU_tMs7Fc3@Rty47U4aj?BR^756j z$Axw0GlnBbM{-9_m+ZS?gXw$bk8U=;PPWXk%PT7YHF((aB0KECEXhx4KBdbKcN4w9 zKb$8E_q%lihNTdJ`YEGv`5#>Z3Dne+3jMpHuTwRSrjIS1B1>(0c%)jGSRqZf)PgH3XL647XB-eGW6c*huv%JsayX6gRA1AKovIf!1+3IxWv)aO{ zfG2d5nMGYXbXv8k>M)1ip>`3GU`@%L(G}9AHZ$&WU7ls_DvX7$PjbhCv$GLYAY`KL z%#a7l!<^ZD%pwXjQJdjkw=2t79xxi@NCUVd1CsgHl=q?K177V06`LuWSi3XN?A?7x zoL7U_-@k#QntmSj`7S?=3^YbM|O|GoV-A>P3K>;IX682BoUx~p4F7iN6aGG@JW4pX-eepb-DpCW{ zmbRA3OyYe#a$$d{{cjUhUzQ((xI&3S1-SuN1dVbIOze{vmT|NG5@FYmoJ({$d$D&W z_3lp30B)xq5|2+YtlkI2zE$&a=WjXg+lnw18lcZ-8R$*UrA;cW>FG-_=ZJ=EKtJc% z_;onuUPtf6S5tK`p$%3X5gA%obM}&dLvR>82ofieFSWk1dv0WScim~S(aJw|xysXw z`je4VryAp&#M-eQT~LHPPLw_a)^#5Dd7-4nm`jx0L1n(zZp(6Wri~!%MErv-Vg)Vz zCtR0BM_FGD+mYVLa(xnUtC9+zCEM?0)SO6Uk+RxQOBpEA$P_ZllCXF_I>Bm}x)8FG z2#vaw4x$FBlw=rT%S#6&X}1aa-WyR4UF?6n;4&coar8|7j$1E=!ws#j!GK;bhiD8} z4};0u*W!R=w)Ch6z%g7GN8I7dy0bj3+~Z(VNLO2b`Tdd%RQao<2E7EOC+=02h@VV* z>$GX1-_AC2OM)aBK|z#*G;%+>bzQ{qxmk1r$J}nTimqz@o1U1>-rY+5t+!E~IN9ex z3%o!4zr3=V<1m+7_!$%g)MhsFg#;qcdY1JB_!Jt=&i;KdT)I1(VBPQ*|sz`{reunT+(_=ozWNz5@S`!G3h>N;*lD^A*kbF z9ZS~K#KEQIZ3?62<&@cCwfUc$dU_??CBBeVdzgfWszz?+;S?NtMMgQ=>yn^4)eK&~uc?m}4s3ERM9}_K|Z_ zynS$DhV`@g1Iv3{*6MT=i#o<&+XSbpz{;^Ue$t_nJ#~oi7+|n$uhDp1Lzzkt%g70O zd6So8hUA7B2UJ_2cu%+vWCC06W}STzQxq`DQG+$-l4j3v|IHhTUT^Q3Y0sX$gsw{D ze^mVvBwJTj_*Pn$KU-a*crHUC8iX9IIDh|Ocra%F@w;(ivVcYu*O7P@hq=HwX!))4 z>pqXs28)|%pEpZ5>Sk?OJs-caMA=&B%1xj1dbei&tsM!jmlhj4(Bi%Ly>}Td({rAB z9;Zop{j=P>FjInP8PpR*(3`AX4b)5Z;BuGbEEY<63*i!6iCdA4u->v&3HB`H+!Z(B zKbhBv<}bGW8dx4gUNr-16t(44o>*cnZiN~(F;6@|aLX1=1=!U{+yRvJ{xo?j+&l^}6k zHVqx>L22qaD+A-=n?0SM;cwmuKdq!9or4tJCddp~d>fxFmb*h1HnT3AAO`{jIUsOdDBe&Q>h*_Pd0Bq@~;i z+y2g)x4@h;JX!Tu((BxxEHE4Impk0P$Kltq65W$4-+9I72)DeuM>^jvL4w`n3yw@` z-g@w(9evp(TozP%8`J8&{)ANpQMxoowX--J~Lq>n3BjlGG4cm4Co2IdkD~gQg`PaqmkgbyzPUxH6$J?xU zGZ*GTu~8h`;)QlJL(i32Jy75T>?-}uZxWv zIV?H=cMQALySHHUyfuN|4ayfzz8 zUbxIZf#)=f*Pg!FZhLcsuSirxu};&Y5V+_;vOEsPEx&pdzkDL~jepEMeQu$E_UYiy za-6gcF$3StP=f0EAMDlZ&Txi|GPy6;b!>b0;o2(G8|)91iD}ja!qh?<;m7qNYAp<) zQ|69rk~8e_C^EhozlA~W1Wuk}$i#BVvzoz_157^?V)%xX`6TY%;7>);=UhzQzO-Fa z-kVb88y&uexV#}6^&fQy`|Vbu0lWj$;@u|RnnaipxEkFjRxskMrypYjWD-SOJZ zcwhSC=kDekl(+^zi35(_D;d#sRZgGde-skpH2ySES}nMcIH4Q5%H)$UcMyls?_pkS zm^wTq(iJ+tL}07EE}PBHeJUf~j)!F)v`hbTHe&Sm%MkW7tL_Xm5uT}^CdGzk8931* z7WhLxyH!nEdWu#5Q>;E%J9Rup9TIPN7c-~f9m%)OV}G7Gi$=`lM^o%|*X_^Y^6KSh zEjpI*Br5HS2csuiEq*?*=W1Lkn@(yp+=&;S`qoLjsd+Rv8Ly@@^ke3}cpmWbRD`c{ z*QDnpooTvEVG6@9#?v=#6uP(ec)a&>uVKRL(afba0{fk?}`T5(W@TrUGvOksvKtxa#=j3e7MfHdu8 z*ObL>*$CC{|={-UwAkR@rntyp+$k!(O#g^@*l(dBhnRv!st$0t-v&Ui5+A3LoDBQsCiJ%H`iVn~k{_LASFf2xlw~yV4Yzj3=+Dkx{ylq`MC& z)sG;e#Ya~depTtd{$R0oMe_dL(>>!tuylT>Z%9Bi#yh@CXvUOOuuXEZrR7-ldr~BT z*AkcR)c(iB8J%&ZqZad{aO~eLI&9aCjn~B?l=TN#vc&5fa$q_XEy2A&Ih;XIIPb!3 z=Jx`XY$L=(m7vt$tKgZ2ph?A2LV^(5=>6c(p1cPmi^i-D<@ztvvorA8Xbe|O76zi2 zP%L+`B~P|f$=M4LXo6;`zD^WJ|9%D6>ZP2 zV7vQ=UZt5Peq*sOt={g)<{Ye$@5|<5tK>Wt+RA&I+AJoi=ZGEp9xn-f^|eExEo^m! zS?)WKmd*}8-kZ95X@Z+;9k(ZtY(hk2ej6aKA?M?=ax2p`EsZZd@D~!$75omLBQ>@u zU+o^J8PFMCEU(|N>;$q7j3qM<6d2=`(Jkyd{H-ThGi1=6F0as@0_K>$Ual`;CErB@ zt_MV9v?SFjpb_Inpdeo9Lqo{tAgp zW1jxrYAnS+Hj;#G9^xD|va;gKipk5nwaI0l+^StG>Tz@x-t>fZi4ylcO!f@;)gLT* z&|>SuMR2iratq$+7(%Te@or~VeoHuMJ7Ga*9x7^NA9I|IP$m_5A_&)?*89%yBOrDl7h9SybQ&yGZ2cc9?$qj@Fb$3pau zbA5)GA6f|$w8sB#-*atLzk7Zj`Qe|>kK;WSq#l9=^%%qMk?jE#Y72AN_@LCKU(0l< z%O<0y8gtb%hLiOC^AeEv3AKHiDDg^uw78#+mTsunsZ=(&wDRBvweSy8f`Dn1s}&Xe zXjovNa16C|%hWxzgY3{rQe4(dVvO%y(boD=NE04C_w+2um+-=sToIn=AKJuYeQP%g zT7&BE`6q^DGjyv^71MrF#aE%?TPDxn69kXm{K(?6R)f(%gTQ%9x(rL55*{!=JN)6rT7@|M?S86WOvMD5K|-GWvA zwINH)5i_HJyOq29!22<^Lvj73{xqx?!xB3Kdv_;o6&jQtqMYA6wqx{;81se;!;ec6 zzD{}!nz|yFN40&00ssZ8Mtb}*)-?Xnk>Nx3|EzWGDAyE76hP@JzmOJFN z5Ap`dy42a$B`od0v}KiDC9NO}x+vRU`WieamfQO3vBUIW$4$P%=;KP}midi1Vdz2NtHRD0O-^Xi z(<`0DgWK6`8SxajbPaE5gm#RDiCZGA2%|V7LmtiuT#u?H&lfj#Gw1C$8rV150;@A9 zOUpIs135>-&pf=H4a(=yi`9H?oE8+R=0Q!8MPA@b=;2o6Lttx(MZ!AOezVC1-u1Xu z#@V-xA^PY>mmAT_PcNT=FIZy7NAoBXy@92}3%{E2Y zGvbpr+6ZqqBL{nt%SLGKEJLG|yf?>?b*pcILf}T2a7(I0`zS%QQPheG?@%w5WX&lqR%={${UgnY6K&^8gnjyd(P&z)pYNY%`qnS zvnZjWn=C6&@pIP=e4%OQeZbv`a80$lIQ9hpw%#=l{greA+|ztAQvWvF1Lr6#3pjxe zU;}G4PMJ-_F{~d;yJ3IWmV8+GEHb^(Iybe*pR|Iq;ap1rF8|Pu+g$28%bGO&j;&>O zY!Ntl;X;Ce=^p2Y+`c=Ra9%{C5k@{xHtjS?;V0Q$i}gkasbslLr!mKYmZ*h+2@#t0 zXKohw4X^u)5ibUI@?x*jvuIVScde?QVFt9QeDE{@%0#nnzZsQjdXB5M%^|M5o;?nuw3r%0lff&{fcz=R z6d+GPN@pCa2T=^Rqk1}$p(yQuP)gj$*IcN6y)fvyHX(n_7>a-dj?gpWHOt@QRq=;6 zg)$x*!6`;rcN^2z#CC$XfW%=ETqFMl$ThfjVG>@;`FY?dzdu7UQ`wx`rV#cZ~G zy|ds0lSK~kVe$E*&QWt+c}>^RtP4L-!&}Ub>I-psN!n$LfOP0H9j|vj`n#ihkL&Zf z7Uct9Xh4^SM-(;R1tuXBiVQ>u-z>~phLukVLY&(QUo!{_aTj6(u){Z*2*w&%2Omgi z{n8!7d_5D8{;q%Sw)!(pwyi%{)2_Qz$)8e>S@)=ow)`Ma8b?@?khUYI(ZAN^HflS! ziJrerhq_VW-LG3(lv;Vq(Sge+k`LCf&Auq|-mhVt zLmG&MRuSih$;XtxAXg;!(SET2BE+c)EXE)GmT438?kvT$Bth; zSQ9=qUZXWccPhf{7{jJEi*{@2gt8J8+QnpLiN?3YF&_!w7#awNOtW-Ul)=$&GvrJ{ z0?@hT==tQI<1GhDh+57UifZY{I?UO7dAnROP3RoD zc89!Ub$_CP5mkAIY8~F+2fFTO+im<`MjX8-ccYdf84bDC6(GdHF_MIgGLww?zE%mR7&3hzWc(>5lZ zwpk2S0^x_w0B98wiswlG?LTA>CL{CNYFzWHMb{-z&>w9CygDPwR)0~3VaTa~RSaR1 z(V<$1=JU|sQLo;6H+Q3Fv!2zyZ{K;ZZY0PksF8?i>tYqI6olciks#@L?&%ZfnMoxW z+960@j#*wKigcA2hT?}Vmk!KEY8Vz6yoO#zG2y6r=C(eRrx+ED_WB-p!l)R-_@c(T zO{q+EOCO zoA1X4^fc1kFsOF*UyYhude8Er@ys$MsMw z_BQ?w5_Y~0{{Zls|FsAYzW|SbkUqb-gn+PwfB+XizXU%&|1EFj|0nS9vUhO|{@(@i t)mg;<1nmFM!QaKh!O!2u!~1_~UcdgoCRNMTZT~a?HRX3o^$OPS{|}0=ms$V- literal 0 HcmV?d00001 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a4709e5ba..ef41239cc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -2,9 +2,11 @@ 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.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI import java.util.Properties val isFullBuild: Boolean = @@ -63,6 +65,8 @@ kotlin { val koinBom = project.dependencies.platform(libs.koin.bom) implementation(composeBom) implementation(koinBom) + + implementation("commons-io:commons-io:2.5") } androidMain.dependencies { api(libs.koin.android) @@ -164,7 +168,7 @@ compose.desktop { mainClass = "com.maxrave.simpmusic.MainKt" nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) modules("jdk.unsupported") packageName = "SimpMusic" macOS { @@ -195,6 +199,7 @@ compose.desktop { iconFile.set(project.file("icon/circle_app_icon.ico")) } linux { + targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) includeAllModules = true packageVersion = libs.versions.version.name @@ -272,4 +277,92 @@ afterEvaluate { jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") } } + 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 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 + ) + } + + if (!appimagetool.canExecute()) { + appimagetool.setExecutable(true) + } + + val appDir = if (isRelease) + layout.buildDirectory.dir("appimage/main-release/${appName}.AppDir") + .get().asFile + else + layout.buildDirectory.dir("appimage/main/${appName}.AppDir") + .get().asFile + 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()}") + } + + exec { + workingDir = appDir.parentFile + executable = appimagetool.canonicalPath + environment("ARCH", "x86_64") // TODO: 支持arm64 + args( + "${appName}.AppDir", + "${appName}-x86_64.AppImage" + ) + } + } + + tasks.findByName("packageAppImage")?.doLast { + packAppImage(false) + } + tasks.findByName("packageReleaseAppImage")?.doLast { + packAppImage(true) + } +} + +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/settings.gradle.kts b/settings.gradle.kts index 506c4542d..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()) { From c07a3864b996a8da732971ee567fa1d3b6ec36b8 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 14 Jan 2026 17:27:00 +0700 Subject: [PATCH 26/40] feat: add target formats for macOS and Windows packaging in build configuration --- composeApp/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ef41239cc..28806f14f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -172,6 +172,7 @@ compose.desktop { modules("jdk.unsupported") packageName = "SimpMusic" macOS { + targetFormats(TargetFormat.Dmg) includeAllModules = true packageVersion = "2025.12.24" iconFile.set(project.file("icon/circle_app_icon.icns")) @@ -191,6 +192,7 @@ compose.desktop { } } windows { + targetFormats(TargetFormat.Msi) includeAllModules = true packageVersion = libs.versions.version.name From 6f0a5fe2fb5122653961016e5ebfd0400685ccd3 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 14 Jan 2026 20:39:56 +0700 Subject: [PATCH 27/40] feat: add installation step for libfuse2 in AppImage packaging workflow --- .github/workflows/desktop-appimage-package.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/desktop-appimage-package.yml b/.github/workflows/desktop-appimage-package.yml index d5ddf37ee..e6c20096c 100644 --- a/.github/workflows/desktop-appimage-package.yml +++ b/.github/workflows/desktop-appimage-package.yml @@ -35,6 +35,9 @@ jobs: distribution: "zulu" cache: 'gradle' + - name: Install libfuse2 + run: sudo apt install libfuse2 + - name: Update build product flavor run: | echo "" >> ./gradle.properties From 263f8e89049b56f809b07223bac4653e27e70d21 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 14 Jan 2026 21:13:58 +0700 Subject: [PATCH 28/40] feat: fix rich sync in external windows --- composeApp/build.gradle.kts | 71 +++++--- .../simpmusic/ui/component/LyricsView.kt | 29 ++- .../ui/mini_player/MiniPlayerLayout.kt | 171 ++++++++++++------ 3 files changed, 183 insertions(+), 88 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 28806f14f..ec28ec92a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -172,7 +172,6 @@ compose.desktop { modules("jdk.unsupported") packageName = "SimpMusic" macOS { - targetFormats(TargetFormat.Dmg) includeAllModules = true packageVersion = "2025.12.24" iconFile.set(project.file("icon/circle_app_icon.icns")) @@ -192,7 +191,6 @@ compose.desktop { } } windows { - targetFormats(TargetFormat.Msi) includeAllModules = true packageVersion = libs.versions.version.name @@ -201,7 +199,6 @@ compose.desktop { iconFile.set(project.file("icon/circle_app_icon.ico")) } linux { - targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) includeAllModules = true packageVersion = libs.versions.version.name @@ -279,26 +276,37 @@ afterEvaluate { jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") } } + 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 + 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 appimagetool = layout.buildDirectory.dir("tmp").get().asFile - .resolve("appimagetool-x86_64.AppImage") + 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 + appimagetool, ) } @@ -306,12 +314,18 @@ afterEvaluate { appimagetool.setExecutable(true) } - val appDir = if (isRelease) - layout.buildDirectory.dir("appimage/main-release/${appName}.AppDir") - .get().asFile - else - layout.buildDirectory.dir("appimage/main/${appName}.AppDir") - .get().asFile + val appDir = + if (isRelease) { + layout.buildDirectory + .dir("appimage/main-release/$appName.AppDir") + .get() + .asFile + } else { + layout.buildDirectory + .dir("appimage/main/$appName.AppDir") + .get() + .asFile + } if (appDir.exists()) { appDir.deleteRecursively() } @@ -319,27 +333,29 @@ afterEvaluate { FileUtils.copyDirectory(appDirSrc, appDir) FileUtils.copyDirectory(packageOutput, appDir) - val appExecutable = appDir.resolve("bin/${appName}") + 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.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()}") + println( + "Set AppRun executable permissions, readable: ${appRun.canRead()}, writable: ${appRun.canWrite()}, executable: ${appRun.canExecute()}", + ) } exec { workingDir = appDir.parentFile executable = appimagetool.canonicalPath - environment("ARCH", "x86_64") // TODO: 支持arm64 + environment("ARCH", "x86_64") // TODO: 支持arm64 args( - "${appName}.AppDir", - "${appName}-x86_64.AppImage" + "$appName.AppDir", + "$appName-x86_64.AppImage", ) } } @@ -352,7 +368,10 @@ afterEvaluate { } } -fun downloadFile(url: String, destFile: File) { +fun downloadFile( + url: String, + destFile: File, +) { val destParent = destFile.parentFile destParent.mkdirs() 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/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/mini_player/MiniPlayerLayout.kt index ee20f86f6..789b796a6 100644 --- 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 @@ -56,7 +56,9 @@ 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 @@ -307,24 +309,43 @@ fun MediumMiniLayout( .padding(horizontal = 8.dp), contentAlignment = Alignment.Center, ) { - 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, - ) + 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, + ) + } } } } @@ -451,26 +472,45 @@ fun SquareMiniLayout( } if (currentLine != null) { - 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)) + 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)) + } } } @@ -794,24 +834,43 @@ fun ExpandedMiniLayout( .padding(horizontal = 12.dp) .padding(bottom = 8.dp), ) { - 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, - ) + 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, + ) + } } } } From 5969a3ff4e5259a58dd124f9b2ec8fb3cad2d6b7 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 15 Jan 2026 09:50:31 +0700 Subject: [PATCH 29/40] feat: refactor AppImage packaging process to use ProcessBuilder --- composeApp/build.gradle.kts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ec28ec92a..e55f6493f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -4,6 +4,7 @@ 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 @@ -349,14 +350,20 @@ afterEvaluate { ) } - exec { - workingDir = appDir.parentFile - executable = appimagetool.canonicalPath - environment("ARCH", "x86_64") // TODO: 支持arm64 - args( - "$appName.AppDir", - "$appName-x86_64.AppImage", - ) + // 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") } } @@ -368,7 +375,13 @@ afterEvaluate { } } -fun downloadFile( +// 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") +} + +private fun downloadFile( url: String, destFile: File, ) { From d91343544f0ff22df5985906c7db70fa2b2b3399 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 15 Jan 2026 14:54:38 +0700 Subject: [PATCH 30/40] feat: add voting functionality for SimpMusic lyrics and translated lyrics --- .../composeResources/values/strings.xml | 7 + .../ui/component/VoteLyricsDialog.kt | 156 ++++++++++++++++++ .../ui/screen/player/NowPlayingScreen.kt | 62 +++++++ .../simpmusic/viewModel/SharedViewModel.kt | 100 +++++++++++ core | 2 +- 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e42a6fb09..a8fea29c6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -511,4 +511,11 @@ 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/ui/component/VoteLyricsDialog.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt new file mode 100644 index 000000000..ae1462d5a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt @@ -0,0 +1,156 @@ +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.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: VoteState, + translatedLyricsVoteState: VoteState, + 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) { + VoteRow( + label = stringResource(Res.string.rate_lyrics), + voteState = lyricsVoteState, + onUpvote = { onVoteLyrics(true) }, + onDownvote = { onVoteLyrics(false) }, + ) + } + + // Vote for translated lyrics + if (canVoteTranslatedLyrics) { + VoteRow( + label = stringResource(Res.string.rate_translated_lyrics), + voteState = translatedLyricsVoteState, + onUpvote = { onVoteTranslatedLyrics(true) }, + onDownvote = { onVoteTranslatedLyrics(false) }, + ) + } + } + }, + ) +} + +@Composable +private fun VoteRow( + label: String, + voteState: VoteState, + onUpvote: () -> Unit, + onDownvote: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = label, + style = typo().bodySmall, + ) + when (voteState) { + is VoteState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + is VoteState.Success -> { + Icon( + imageVector = if (voteState.upvote) Icons.Rounded.ThumbUpAlt else Icons.Rounded.ThumbDownAlt, + contentDescription = null, + tint = Color.Cyan, + modifier = Modifier.size(24.dp), + ) + } + is VoteState.Error -> { + Text( + text = voteState.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), + ) + } + } + } + } + } +} 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 e79056967..46ee87d79 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 @@ -62,6 +62,8 @@ 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 @@ -135,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 @@ -150,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 @@ -188,11 +192,17 @@ 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.downvote 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" @@ -278,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() @@ -303,6 +315,10 @@ fun NowPlayingScreenContent( mutableStateOf(false) } + var showVoteDialog by rememberSaveable { + mutableStateOf(false) + } + var shouldShowToolbar by remember { mutableStateOf(false) } @@ -517,6 +533,30 @@ fun NowPlayingScreenContent( ) } + // Vote Dialog + if (showVoteDialog) { + val canVoteLyrics = screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC && + !screenDataState.lyricsData?.lyrics?.simpMusicLyricsId.isNullOrEmpty() + val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && + !screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty() + + 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, @@ -1495,6 +1535,28 @@ 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?.simpMusicLyricsId.isNullOrEmpty() + val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && + !screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty() + 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/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index a942f3246..6e66757e2 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -96,6 +96,7 @@ 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 kotlin.math.abs import kotlin.reflect.KClass @@ -300,6 +301,7 @@ class SharedViewModel( it.songEntity?.videoId }.collectLatest { state -> Logger.w(tag, "NowPlayingState is $state") + resetLyricsVoteState() canvasJob?.cancel() _nowPlayingState.value = state state.track?.let { track -> @@ -1605,6 +1607,97 @@ class SharedViewModel( } } + // Vote state for translated lyrics + private val _translatedVoteState = MutableStateFlow(VoteState.Idle) + val translatedVoteState: StateFlow = _translatedVoteState.asStateFlow() + + // Vote state for original lyrics + private val _lyricsVoteState = MutableStateFlow(VoteState.Idle) + 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?.simpMusicLyricsId + + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) { + Logger.w(tag, "Cannot vote: not a SimpMusic lyrics or missing ID") + return + } + + viewModelScope.launch { + _lyricsVoteState.value = 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.value = VoteState.Error(result.message ?: "Unknown error") + } + + is Resource.Success -> { + Logger.d(tag, "Vote SimpMusic Lyrics Success") + _lyricsVoteState.value = VoteState.Success(upvote) + makeToast(getString(Res.string.vote_submitted)) + } + } + } + } + } + + fun resetLyricsVoteState() { + _lyricsVoteState.value = VoteState.Idle + _translatedVoteState.value = VoteState.Idle + } + + /** + * 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?.simpMusicLyricsId + + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) { + Logger.w(tag, "Cannot vote: not a SimpMusic translated lyrics or missing ID") + return + } + + viewModelScope.launch { + _translatedVoteState.value = 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.value = VoteState.Error(result.message ?: "Unknown error") + } + + is Resource.Success -> { + Logger.d(tag, "Vote SimpMusic Translated Lyrics Success") + _translatedVoteState.value = VoteState.Success(upvote) + makeToast(getString(Res.string.vote_submitted)) + } + } + } + } + } + + fun resetVoteState() { + _translatedVoteState.value = VoteState.Idle + } + fun shouldStopMusicService(): Boolean = runBlocking { dataStoreManager.killServiceOnExit.first() == TRUE } fun isUserLoggedIn(): Boolean = runBlocking { dataStoreManager.cookie.first().isNotEmpty() } @@ -1685,4 +1778,11 @@ data class NowPlayingScreenData( playlistName = "", ) } +} + +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/core b/core index 3b5fdac2d..036ec2be6 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 3b5fdac2d8810989f98b902c3cc032abc1c54721 +Subproject commit 036ec2be6b901372b94e5ca4b49efb600fc1df17 From 1efac54a4365419e6cb3fee5340c8b0fbeb5e250 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 16 Jan 2026 16:48:06 +0700 Subject: [PATCH 31/40] feat: enhance timestamp regex to support three-digit milliseconds in RichSyncParser --- .../com/maxrave/simpmusic/extension/RichSyncParser.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 From 9c7276e3d5111102bb221c90fb2aec0837bc700d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 17 Jan 2026 15:48:09 +0700 Subject: [PATCH 32/40] feat: update packaging formats and dependencies, enhance README for Linux installation --- MediaServiceCore | 2 +- README.md | 2 +- composeApp/build.gradle.kts | 130 ++++++++++-------- .../simpmusic/viewModel/SharedViewModel.kt | 7 +- gradle/libs.versions.toml | 34 ++--- 5 files changed, 92 insertions(+), 83 deletions(-) diff --git a/MediaServiceCore b/MediaServiceCore index 082e649ee..5276197e7 160000 --- a/MediaServiceCore +++ b/MediaServiceCore @@ -1 +1 @@ -Subproject commit 082e649ee1f4f7b4bf90ea15d46328317ef4156b +Subproject commit 5276197e7dd5b1ec63bb484d4e6ffb2be8d0b4d1 diff --git a/README.md b/README.md index 07bb0bdc3..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 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e55f6493f..4bcc5477f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -169,7 +169,17 @@ compose.desktop { mainClass = "com.maxrave.simpmusic.MainKt" nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) + 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 { @@ -297,75 +307,75 @@ afterEvaluate { return } - 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, - ) - } - - if (!appimagetool.canExecute()) { - appimagetool.setExecutable(true) - } - - val appDir = - if (isRelease) { + val appimagetool = layout.buildDirectory - .dir("appimage/main-release/$appName.AppDir") - .get() - .asFile - } else { - layout.buildDirectory - .dir("appimage/main/$appName.AppDir") + .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, + ) } - if (appDir.exists()) { - appDir.deleteRecursively() - } - FileUtils.copyDirectory(appDirSrc, appDir) - FileUtils.copyDirectory(packageOutput, appDir) + if (!appimagetool.canExecute()) { + appimagetool.setExecutable(true) + } - val appExecutable = appDir.resolve("bin/$appName") - if (!appExecutable.canExecute()) { - appimagetool.setExecutable(true) - } + val appDir = + if (isRelease) { + layout.buildDirectory + .dir("appimage/main-release/$appName.AppDir") + .get() + .asFile + } else { + layout.buildDirectory + .dir("appimage/main/$appName.AppDir") + .get() + .asFile + } + if (appDir.exists()) { + appDir.deleteRecursively() + } - 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) + FileUtils.copyDirectory(appDirSrc, appDir) + FileUtils.copyDirectory(packageOutput, appDir) - println( - "Set AppRun executable permissions, readable: ${appRun.canRead()}, writable: ${appRun.canWrite()}, executable: ${appRun.canExecute()}", - ) - } + 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") + // 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) 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 6e66757e2..3326ae070 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -304,15 +304,14 @@ class SharedViewModel( resetLyricsVoteState() 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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e03e88a44..c9dfc5f76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] # App version -version-name="1.0.1-hf" -version-code="43" +version-name = "1.0.2" +version-code = "44" android = "8.13.2" kotlin = "2.3.0" serialization = "2.3.0" ksp = "2.3.4" -compose-bom = "2025.12.01" -material3-expressive = "1.5.0-alpha11" +compose-bom = "2026.01.00" +material3-expressive = "1.5.0-alpha12" constraintlayout-compose = "1.1.1" activity-compose = "1.12.2" lifecycle = "2.9.6" @@ -31,7 +31,7 @@ coil3 = "3.3.0" kmpalette = "3.1.0" easypermissions = "3.0.0" datastore-preferences = "1.2.0" -paging = "3.4.0-beta01" +paging = "3.4.0-rc01" customactivityoncrash = "2.4.0" aboutlibraries = "13.2.1" ktor = "3.3.3" @@ -39,18 +39,18 @@ brotli = "0.1.2" ksoup = "0.6.0" desugaring = "2.1.5" koin-bom = "4.1.1" -md = "0.39.0" +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.29.0" +sentry-android = "8.30.0" sentry-gradle-android = "5.12.2" -sentry-jvm = "8.29.0" -newpipe = "7b5cdd1b42ec66e98dbb1b3813a2695822588d6a" +sentry-jvm = "8.30.0" +newpipe = "v0.25.0" webkit = "1.15.0" kermit = "2.0.8" paging-common = "3.3.6" @@ -59,7 +59,7 @@ 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" From ab1cf3eb952b194f35c3f89613cf42fb40ac4246 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 17 Jan 2026 21:52:28 +0700 Subject: [PATCH 33/40] feat: disable proguard msc --- MediaServiceCore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaServiceCore b/MediaServiceCore index 5276197e7..a22df4f68 160000 --- a/MediaServiceCore +++ b/MediaServiceCore @@ -1 +1 @@ -Subproject commit 5276197e7dd5b1ec63bb484d4e6ffb2be8d0b4d1 +Subproject commit a22df4f682bc73a863c6f5b0847a4973aa259c5f From 928d9e80dd4c063f86bdd65d2b1c0e7c5b5d8d49 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 18 Jan 2026 14:34:27 +0700 Subject: [PATCH 34/40] feat: fix voting functionality for SimpMusic lyrics --- .editorconfig | 2 + .../ui/component/VoteLyricsDialog.kt | 134 ++++++++++-------- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 20 +-- .../ui/screen/player/NowPlayingScreen.kt | 32 +++-- .../simpmusic/viewModel/SharedViewModel.kt | 100 +++++++++---- .../kotlin/com/maxrave/simpmusic/main.kt | 15 +- core | 2 +- 7 files changed, 195 insertions(+), 110 deletions(-) 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/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt index ae1462d5a..dcbc11074 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/VoteLyricsDialog.kt @@ -22,6 +22,7 @@ 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 @@ -36,8 +37,8 @@ import simpmusic.composeapp.generated.resources.vote_for_lyrics fun VoteLyricsDialog( canVoteLyrics: Boolean, canVoteTranslatedLyrics: Boolean, - lyricsVoteState: VoteState, - translatedLyricsVoteState: VoteState, + lyricsVoteState: VoteData?, + translatedLyricsVoteState: VoteData?, onVoteLyrics: (upvote: Boolean) -> Unit, onVoteTranslatedLyrics: (upvote: Boolean) -> Unit, onDismiss: () -> Unit, @@ -64,7 +65,7 @@ fun VoteLyricsDialog( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Vote for original lyrics - if (canVoteLyrics) { + if (canVoteLyrics && lyricsVoteState != null) { VoteRow( label = stringResource(Res.string.rate_lyrics), voteState = lyricsVoteState, @@ -74,7 +75,7 @@ fun VoteLyricsDialog( } // Vote for translated lyrics - if (canVoteTranslatedLyrics) { + if (canVoteTranslatedLyrics && translatedLyricsVoteState != null) { VoteRow( label = stringResource(Res.string.rate_translated_lyrics), voteState = translatedLyricsVoteState, @@ -90,67 +91,84 @@ fun VoteLyricsDialog( @Composable private fun VoteRow( label: String, - voteState: VoteState, + voteState: VoteData, onUpvote: () -> Unit, onDownvote: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - style = typo().bodySmall, - ) - when (voteState) { - is VoteState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } - is VoteState.Success -> { - Icon( - imageVector = if (voteState.upvote) Icons.Rounded.ThumbUpAlt else Icons.Rounded.ThumbDownAlt, - contentDescription = null, - tint = Color.Cyan, - modifier = Modifier.size(24.dp), - ) - } - is VoteState.Error -> { - Text( - text = voteState.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), + 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), ) { - Icon( - imageVector = Icons.Rounded.ThumbDown, - contentDescription = stringResource(Res.string.downvote), - modifier = Modifier.size(20.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/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 4a481f651..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 @@ -96,13 +96,13 @@ 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 import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette import com.maxrave.simpmusic.extension.toResizedBitmap -import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox @@ -161,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 -> @@ -816,7 +816,7 @@ fun MiniPlayer( IconButton(onClick = { toggleMiniPlayer() }) { Icon( imageVector = Icons.Outlined.OpenInNew, - contentDescription = "Mini Player" + contentDescription = "Mini Player", ) } } @@ -825,14 +825,16 @@ fun MiniPlayer( // 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" + 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)) 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 46ee87d79..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 @@ -181,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 @@ -199,7 +200,6 @@ 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.downvote import simpmusic.composeapp.generated.resources.view_count import simpmusic.composeapp.generated.resources.vote_error import simpmusic.composeapp.generated.resources.vote_submitted @@ -535,10 +535,17 @@ fun NowPlayingScreenContent( // Vote Dialog if (showVoteDialog) { - val canVoteLyrics = screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC && - !screenDataState.lyricsData?.lyrics?.simpMusicLyricsId.isNullOrEmpty() - val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && - !screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty() + 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, @@ -1536,10 +1543,17 @@ fun NowPlayingScreenContent( } 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?.simpMusicLyricsId.isNullOrEmpty() - val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC && - !screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty() + 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( 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 3326ae070..bd69b3964 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -98,6 +98,7 @@ 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 @@ -301,7 +302,6 @@ class SharedViewModel( it.songEntity?.videoId }.collectLatest { state -> Logger.w(tag, "NowPlayingState is $state") - resetLyricsVoteState() canvasJob?.cancel() _nowPlayingState.value = state state.songEntity?.let { track -> @@ -1007,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 @@ -1052,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 = @@ -1084,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 = @@ -1153,6 +1169,7 @@ class SharedViewModel( ?.artist ?: "" } + resetLyricsVoteState() val lyricsProvider = dataStoreManager.lyricsProvider.first() if (isVideo) { getYouTubeCaption( @@ -1461,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 @@ -1561,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 @@ -1607,12 +1624,12 @@ class SharedViewModel( } // Vote state for translated lyrics - private val _translatedVoteState = MutableStateFlow(VoteState.Idle) - val translatedVoteState: StateFlow = _translatedVoteState.asStateFlow() + private val _translatedVoteState = MutableStateFlow(null) + val translatedVoteState: StateFlow = _translatedVoteState.asStateFlow() // Vote state for original lyrics - private val _lyricsVoteState = MutableStateFlow(VoteState.Idle) - val lyricsVoteState: StateFlow = _lyricsVoteState.asStateFlow() + private val _lyricsVoteState = MutableStateFlow(null) + val lyricsVoteState: StateFlow = _lyricsVoteState.asStateFlow() /** * Vote for SimpMusic original lyrics (upvote or downvote) @@ -1621,15 +1638,19 @@ class SharedViewModel( fun voteLyrics(upvote: Boolean) { val lyricsData = _nowPlayingScreenData.value.lyricsData val lyricsProvider = lyricsData?.lyricsProvider - val simpMusicLyricsId = lyricsData?.lyrics?.simpMusicLyricsId + val simpMusicLyricsId = lyricsData?.lyrics?.simpMusicLyrics?.id ?: return - if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) { + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) { Logger.w(tag, "Cannot vote: not a SimpMusic lyrics or missing ID") return } viewModelScope.launch { - _lyricsVoteState.value = VoteState.Loading + _lyricsVoteState.update { + it?.copy( + state = VoteState.Loading, + ) + } lyricsCanvasRepository .voteSimpMusicLyrics( lyricsId = simpMusicLyricsId, @@ -1638,12 +1659,20 @@ class SharedViewModel( when (result) { is Resource.Error -> { Logger.w(tag, "Vote SimpMusic Lyrics Error ${result.message}") - _lyricsVoteState.value = VoteState.Error(result.message ?: "Unknown error") + _lyricsVoteState.update { + it?.copy( + state = VoteState.Error(result.message ?: "Unknown error"), + ) + } } is Resource.Success -> { Logger.d(tag, "Vote SimpMusic Lyrics Success") - _lyricsVoteState.value = VoteState.Success(upvote) + _lyricsVoteState.update { + it?.copy( + state = VoteState.Success(upvote), + ) + } makeToast(getString(Res.string.vote_submitted)) } } @@ -1651,9 +1680,9 @@ class SharedViewModel( } } - fun resetLyricsVoteState() { - _lyricsVoteState.value = VoteState.Idle - _translatedVoteState.value = VoteState.Idle + private fun resetLyricsVoteState() { + _lyricsVoteState.value = null + _translatedVoteState.value = null } /** @@ -1663,15 +1692,19 @@ class SharedViewModel( fun voteTranslatedLyrics(upvote: Boolean) { val translatedLyrics = _nowPlayingScreenData.value.lyricsData?.translatedLyrics val lyricsProvider = translatedLyrics?.second - val simpMusicLyricsId = translatedLyrics?.first?.simpMusicLyricsId + val simpMusicLyricsId = translatedLyrics?.first?.simpMusicLyrics?.id ?: return - if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) { + if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) { Logger.w(tag, "Cannot vote: not a SimpMusic translated lyrics or missing ID") return } viewModelScope.launch { - _translatedVoteState.value = VoteState.Loading + _translatedVoteState.update { + it?.copy( + state = VoteState.Loading, + ) + } lyricsCanvasRepository .voteSimpMusicTranslatedLyrics( translatedLyricsId = simpMusicLyricsId, @@ -1680,12 +1713,16 @@ class SharedViewModel( when (result) { is Resource.Error -> { Logger.w(tag, "Vote SimpMusic Translated Lyrics Error ${result.message}") - _translatedVoteState.value = VoteState.Error(result.message ?: "Unknown error") + _translatedVoteState.update { + it?.copy( + state = VoteState.Error(result.message ?: "Unknown error"), + ) + } } is Resource.Success -> { Logger.d(tag, "Vote SimpMusic Translated Lyrics Success") - _translatedVoteState.value = VoteState.Success(upvote) + _translatedVoteState.update { it?.copy(state = VoteState.Success(upvote)) } makeToast(getString(Res.string.vote_submitted)) } } @@ -1693,10 +1730,6 @@ class SharedViewModel( } } - fun resetVoteState() { - _translatedVoteState.value = VoteState.Idle - } - fun shouldStopMusicService(): Boolean = runBlocking { dataStoreManager.killServiceOnExit.first() == TRUE } fun isUserLoggedIn(): Boolean = runBlocking { dataStoreManager.cookie.first().isNotEmpty() } @@ -1779,9 +1812,22 @@ data class NowPlayingScreenData( } } +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() + + 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/jvmMain/kotlin/com/maxrave/simpmusic/main.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt index 66904e966..ba1f60a58 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/main.kt @@ -77,9 +77,13 @@ fun main() { mediaPlayerHandler.showToast = { type -> showToast( when (type) { - ToastType.ExplicitContent -> runBlocking { getString(Res.string.explicit_content_blocked) } - is ToastType.PlayerError -> + 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) } + } }, ) } @@ -121,8 +125,7 @@ fun main() { }, ), ) - } - .diskCachePolicy(CachePolicy.ENABLED) + }.diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .diskCache( DiskCache @@ -143,8 +146,8 @@ fun main() { sharedViewModel = sharedViewModel, onCloseRequest = { MiniPlayerManager.isOpen = false - } + }, ) } } -} +} \ No newline at end of file diff --git a/core b/core index 036ec2be6..c242a70c7 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 036ec2be6b901372b94e5ca4b49efb600fc1df17 +Subproject commit c242a70c736f308930a4aee68a9a8188c5bbb0e9 From 807bb63a7b9779151c0a0d46d74eb5fc4b24c0bb Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 18 Jan 2026 14:54:38 +0700 Subject: [PATCH 35/40] feat: update ProGuard rules to include re2j library and suppress warnings --- androidApp/proguard-rules.pro | 4 ++++ composeApp/proguard-desktop-rules.pro | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro index f8ad0332a..2a1724491 100644 --- a/androidApp/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/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 From 45ee42bfc6aac5160167d1ee1492cb7ff97bd738 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 18 Jan 2026 22:19:28 +0700 Subject: [PATCH 36/40] feat: fix spotify canvas --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index c242a70c7..b011398b0 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c242a70c736f308930a4aee68a9a8188c5bbb0e9 +Subproject commit b011398b0e03392ece32573a1f16dcdf76bb83dd From d1cfc1c96efe8166f286bc4eb0cfe9ee0d71ba3c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 19 Jan 2026 22:23:49 +0700 Subject: [PATCH 37/40] feat: update string --- .github/workflows/android-release.yml | 113 +++++++++++++--- .../workflows/desktop-appimage-package.yml | 2 +- .../composeResources/values-ar/strings.xml | 2 +- .../composeResources/values-az/strings.xml | 1 - .../composeResources/values-bg/strings.xml | 1 - .../composeResources/values-ca/strings.xml | 123 +++++++++--------- .../composeResources/values-de/strings.xml | 27 +++- .../composeResources/values-es/strings.xml | 56 +++++++- .../composeResources/values-fa/strings.xml | 56 +++++++- .../composeResources/values-fi/strings.xml | 2 + .../composeResources/values-fr/strings.xml | 56 +++++++- .../composeResources/values-hi/strings.xml | 2 - .../composeResources/values-it/strings.xml | 73 +++++++---- .../composeResources/values-ja/strings.xml | 44 ++++++- .../composeResources/values-ko/strings.xml | 2 - .../composeResources/values-nl/strings.xml | 4 +- .../composeResources/values-pl/strings.xml | 3 +- .../composeResources/values-pt/strings.xml | 8 +- .../composeResources/values-ru/strings.xml | 22 +++- .../composeResources/values-th/strings.xml | 2 - .../composeResources/values-tr/strings.xml | 46 ++++--- .../composeResources/values-uk/strings.xml | 31 ++++- .../composeResources/values-vi/strings.xml | 39 +++++- .../values-zh-rTW/strings.xml | 4 +- .../composeResources/values-zh/strings.xml | 72 +++++++++- .../metadata/android/en-US/changelogs/44.txt | 7 + .../metadata/android/vi-VN/changelogs/44.txt | 7 + 27 files changed, 636 insertions(+), 169 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/44.txt create mode 100644 fastlane/metadata/android/vi-VN/changelogs/44.txt diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index aedb14282..fedb44c0b 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' @@ -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/desktop-appimage-package.yml b/.github/workflows/desktop-appimage-package.yml index e6c20096c..28320b6f7 100644 --- a/.github/workflows/desktop-appimage-package.yml +++ b/.github/workflows/desktop-appimage-package.yml @@ -17,7 +17,7 @@ permissions: contents: write jobs: - build-desktop-deb: + build-desktop-appimage: name: Build desktop AppImage package runs-on: ubuntu-22.04 defaults: 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/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 From 0496a435e4a8ea13bc6cafcdd47e0e16f46adcc6 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 19 Jan 2026 22:32:12 +0700 Subject: [PATCH 38/40] feat: update Spotify login URL validation regex --- .../com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..95a82f4d2 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 @@ -172,7 +172,7 @@ fun SpotifyLoginScreen( } viewModel.setFullSpotifyCookies(cookies) } - if (url == Config.SPOTIFY_ACCOUNT_URL) { + if (Regex("^https://accounts\\.spotify\\.com/[a-z]{2}(-[a-zA-Z]{2})?/status$").matches(url)) { createWebViewCookieManager() .getCookie(url) .takeIf { From 4da724761f490f9a95975219fe640c36fdeec082 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 20 Jan 2026 15:20:52 +0700 Subject: [PATCH 39/40] feat: fix spotify login crash --- .../simpmusic/expect/ui/Cookies.android.kt | 9 +++++- .../ui/screen/login/SpotifyLoginScreen.kt | 29 +++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) 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/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt index 95a82f4d2..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) - } + 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)) { - createWebViewCookieManager() - .getCookie(url) + cookie .takeIf { it.isNotEmpty() }?.let { viewModel.saveSpotifySpdc(it) } - createWebViewCookieManager().removeAllCookies() + cookieManager.removeAllCookies() } } } From 872e50fd640e56c1436b56cf86743e7c466e187c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 20 Jan 2026 16:42:25 +0700 Subject: [PATCH 40/40] feat: update artifact paths in android-release.yml --- .github/workflows/android-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index fedb44c0b..56b8362de 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -65,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 @@ -107,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