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