diff --git a/.gitignore b/.gitignore index 02311a6..1db065a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ **/build/ xcuserdata local.properties +key.properties .idea .DS_Store captures diff --git a/README.md b/README.md index d404506..155fad5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 与 Flutter 仓库 `LoveLiveMusicPlayerMobile` **并列**。UI 使用 Compose Multiplatform(Android / iOS 一致);PC 仍为 Electron,本仓库提供协议客户端与共享模型。 -包名:`top.zhushenwudi.llmp` +包名:`com.zhushenwudi.lovelivemusicplayer` ## Maven / Nexus @@ -51,7 +51,7 @@ Debug APK:`composeApp/build/outputs/apk/debug/composeApp-debug.apk` ### 仍需 macOS / Apple 开发者完成的项 1. **Xcode 工程**:创建 App + Widget Extension,链接 `ComposeApp.framework`(`./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64`) -2. **签名 / App Group**:`group.top.zhushenwudi.llmp`(已从 Flutter `group.com.zhushenwudi.lovelivemusicplayer` 迁移;见 `iosApp/README.md`) +2. **签名 / App Group**:与 Flutter 一致 — `group.com.zhushenwudi.lovelivemusicplayer`(Bundle ID `com.zhushenwudi.lovelivemusicplayer`;见 `iosApp/README.md`) 3. **CarPlay entitlement**:`com.apple.developer.carplay-audio` + `playable-content` 需 Apple 审批后真机可用 4. **PiP 资源**:`black.mp4`(Flutter 仓库若缺失需自行放入 `iosApp/iosApp/pip/`) 5. **Swift ↔ Kotlin 播放回调**:Widget/CarPlay 的 NotificationCenter 事件需在 Mac 上接到 `AppContainer.player` diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index dd53eea..6455e08 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Properties plugins { alias(libs.plugins.androidApplication) @@ -9,31 +10,108 @@ plugins { kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xexpect-actual-classes") } } +val appVersionCode = 20001 +val appVersionName = "2.0.0.01" + +val taskNames = gradle.startParameter.taskNames +val isApkRelease = taskNames.any { it.contains("assembleRelease", ignoreCase = true) } +val isAabRelease = taskNames.any { it.contains("bundleRelease", ignoreCase = true) } + +val keystoreProperties = Properties() +if (isApkRelease || isAabRelease) { + val keystorePropertiesFile = file("key.properties") + if (!keystorePropertiesFile.exists()) { + error("release 模式需要在 androidApp 目录下创建 key.properties(可参考 key.properties.example)") + } + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } +} + android { - namespace = "top.zhushenwudi.llmp" + namespace = "com.zhushenwudi.lovelivemusicplayer" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "top.zhushenwudi.llmp" + applicationId = "com.zhushenwudi.lovelivemusicplayer" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "0.2.0-phaseb" + versionCode = appVersionCode + versionName = appVersionName + } + + splits { + abi { + isEnable = isApkRelease + reset() + include("armeabi-v7a", "arm64-v8a") + isUniversalApk = false + } + } + + signingConfigs { + if (isApkRelease || isAabRelease) { + create("release") { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("storePassword") + storePassword = keystoreProperties.getProperty("keyPassword") + val storePath = if (isAabRelease) { + keystoreProperties.getProperty("aabStoreFile") + } else { + keystoreProperties.getProperty("apkStoreFile") + } + storeFile = file(storePath) + } + } + } + + buildTypes { + getByName("debug") { + isMinifyEnabled = false + isDebuggable = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + if (isApkRelease || isAabRelease) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + // ABI 分包开启时不能再写 ndk.abiFilters,否则 AGP 会报冲突。 + if (!isApkRelease) { + ndk { + //noinspection ChromeOsAbiSupport + abiFilters += listOf("armeabi-v7a", "arm64-v8a") + } + } + } } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + dex { + useLegacyPackaging = true + } + jniLibs { + useLegacyPackaging = true + } } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } + lint { + abortOnError = false + checkReleaseBuilds = false + disable += "InvalidPackage" } compileOptions { @@ -42,6 +120,27 @@ android { } } +base.archivesName.set("LLMP_v$appVersionName") + +afterEvaluate { + tasks.findByName("assembleRelease")?.doLast { + val apkRoot = layout.buildDirectory.dir("outputs/apk/release").get().asFile + if (!apkRoot.exists()) return@doLast + apkRoot.walkTopDown().maxDepth(2).filter { it.isFile && it.extension == "apk" }.forEach { apk -> + val shortAbi = when { + "arm64-v8a" in apk.name -> "v8a" + "armeabi-v7a" in apk.name -> "v7a" + else -> null + } ?: return@forEach + val target = apk.resolveSibling("LLMP_v${appVersionName}_${shortAbi}.apk") + if (apk.canonicalFile != target.canonicalFile) { + apk.copyTo(target, overwrite = true) + apk.delete() + } + } + } +} + dependencies { implementation(projects.composeApp) implementation(projects.shared) @@ -57,10 +156,12 @@ dependencies { implementation(libs.zxing.android.embedded) implementation(libs.androidx.lifecycle.service) implementation(libs.ktor.client.okhttp) + implementation(files("libs/open_sdk_3.5.19_r9483ffc7_lite.jar")) implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) diff --git a/androidApp/key.properties.example b/androidApp/key.properties.example new file mode 100644 index 0000000..6c51de9 --- /dev/null +++ b/androidApp/key.properties.example @@ -0,0 +1,7 @@ +# 复制为 androidApp/key.properties 后填写。assembleRelease / bundleRelease 会读这个文件。 +# 字段名和赋值方式和原项目 android/app/build.gradle 保持一致(含 key/store 密码对调)。 +keyAlias= +storePassword= +keyPassword= +apkStoreFile=signature/apk.jks +aabStoreFile=signature/aab.jks diff --git a/androidApp/libs/open_sdk_3.5.19_r9483ffc7_lite.jar b/androidApp/libs/open_sdk_3.5.19_r9483ffc7_lite.jar new file mode 100644 index 0000000..219000e Binary files /dev/null and b/androidApp/libs/open_sdk_3.5.19_r9483ffc7_lite.jar differ diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro new file mode 100644 index 0000000..c10e115 --- /dev/null +++ b/androidApp/proguard-rules.pro @@ -0,0 +1,39 @@ +-keepattributes *Annotation*,InnerClasses,Signature,Exceptions,SourceFile,LineNumberTable + +-keep class com.zhushenwudi.lovelivemusicplayer.** { *; } +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Service + +-keep class com.tencent.** { *; } +-dontwarn com.tencent.** + +-keep class com.umeng.** { *; } +-keep class org.repackage.** { *; } +-keepclassmembers class * { + public (org.json.JSONObject); +} +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keep class androidx.media3.** { *; } +-keep class androidx.startup.** { *; } +-keep class androidx.work.** { *; } +-keep class androidx.room.** { *; } +-keep class * extends androidx.room.RoomDatabase +-keep class * extends androidx.room.RoomDatabase_Impl +-keep class * implements androidx.room.RoomDatabase +-keep class androidx.work.impl.WorkDatabase { *; } +-keep class androidx.work.impl.WorkDatabase_Impl { *; } +-keep class * extends androidx.work.impl.WorkDatabase +-keepnames class * extends androidx.room.RoomDatabase +-keepclassmembers class * extends androidx.room.RoomDatabase { + public static ** getInstance(...); +} +-keep class kotlinx.** { *; } +-keep class io.ktor.** { *; } +-dontwarn org.slf4j.** +-dontwarn okhttp3.internal.Version +-dontwarn java.lang.management.** +-dontwarn io.ktor.util.debug.** diff --git a/androidApp/signature/aab.jks b/androidApp/signature/aab.jks new file mode 100644 index 0000000..a297c0e Binary files /dev/null and b/androidApp/signature/aab.jks differ diff --git a/androidApp/signature/apk.jks b/androidApp/signature/apk.jks new file mode 100644 index 0000000..947e006 Binary files /dev/null and b/androidApp/signature/apk.jks differ diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 12fcff2..4192446 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -16,9 +16,16 @@ + + + + + + + android:windowSoftInputMode="adjustResize" + android:enableOnBackInvokedCallback="false"> @@ -59,9 +67,39 @@ android:configChanges="orientation|screenSize|keyboardHidden" android:theme="@android:style/Theme.Material.Light.NoActionBar" /> + + + + + + + + + + + + + + + diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt new file mode 100644 index 0000000..2673c69 --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/MainActivity.kt @@ -0,0 +1,166 @@ +package com.zhushenwudi.lovelivemusicplayer + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import com.zhushenwudi.lovelivemusicplayer.db.DriverFactory +import com.zhushenwudi.lovelivemusicplayer.db.createDatabase +import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidShareBridge +import com.zhushenwudi.lovelivemusicplayer.native.platform.AndroidDesktopLyricController +import com.zhushenwudi.lovelivemusicplayer.native.platform.installAndroidPlatformServices +import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils +import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices +import com.zhushenwudi.lovelivemusicplayer.platform.initAndroidFileSystem +import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService +import com.zhushenwudi.lovelivemusicplayer.player.PlayerController +import com.zhushenwudi.lovelivemusicplayer.settings.createAppSettings + +class MainActivity : ComponentActivity() { + private var player: PlayerController? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + AndroidUiHost.activity = this + initAndroidFileSystem(applicationContext) + + if (!AppContainer.isInitialized) { + val settings = createAppSettings(applicationContext) + val database = createDatabase(DriverFactory(applicationContext)) + player = PlayerController(applicationContext) + AppContainer.init( + settings = settings, + database = database, + player = player!!, + ) + AppContainer.musicTransfer.setPlatform("android") + } else { + player = AppContainer.player + } + + installAndroidPlatformServices(applicationContext) + val lyricEnabled = AppContainer.settings.enableDesktopLyric + if (!PlatformServices.desktopLyric.pipAutoOpen(lyricEnabled) && lyricEnabled) { + AppContainer.settings.enableDesktopLyric = false + } + PlatformServices.analytics.initIfAgreed( + getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false), + ) + + getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) { + putBoolean("isShutdown", false) + } + + handleIncomingIntent(intent) + + requestNotifyPermission() + startPlaybackService() + + setContent { App() } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + AndroidShareBridge.handleIntentResult(intent) + handleIncomingIntent(intent) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + AndroidShareBridge.handleActivityResult(requestCode, resultCode, data) + if (requestCode == AndroidDesktopLyricController.REQUEST_CODE_OVERLAY_PERMISSION) { + AndroidDesktopLyricController.onOverlayPermissionResult(Settings.canDrawOverlays(this)) + } + } + + override fun onResume() { + super.onResume() + AndroidUiHost.activity = this + AppUtils.getSchemeData(intent) { url -> + PlatformServices.deepLink.handle(url) + } + AppContainer.setAppInBackground(false) + } + + override fun onPause() { + AppContainer.setAppInBackground(true) + super.onPause() + } + + override fun onDestroy() { + if (AndroidUiHost.activity === this) { + AndroidUiHost.activity = null + } + if (isFinishing) { + val stillPlaying = player?.state?.value?.isPlaying == true + if (!stillPlaying) { + getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) { + putString("curJpLrc", "") + putString("nextJpLrc", "") + putBoolean("isPlaying", false) + putBoolean("isShutdown", true) + } + PlatformServices.homeWidget.setShutdown(true) + } + PlatformServices.desktopLyric.hide() + } + super.onDestroy() + } + + private fun handleIncomingIntent(intent: Intent?) { + val data = intent?.data?.toString() ?: return + when { + data.startsWith(AppUtils.HOME_WIDGET_URI_PREFIX) -> { + handleWidgetUri(data) + intent.data = null + } + + data.startsWith("llmp://") || data.startsWith("llmp:") -> { + PlatformServices.deepLink.handle(data) + intent.data = null + } + } + } + + private fun handleWidgetUri(uri: String) { + when { + uri.endsWith("toggle_play") -> player?.toggle() + uri.endsWith("next") -> player?.next() + uri.endsWith("previous") -> player?.previous() + uri.endsWith("toggle_love") -> { + val id = player?.state?.value?.current?.musicId ?: return + AppContainer.toggleLove(id) + } + } + } + + private fun requestNotifyPermission() { + if (Build.VERSION.SDK_INT >= 33) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 100, + ) + } + } + } + + private fun startPlaybackService() { + PlaybackService.start(this) + } +} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/DesktopLyricOverlay.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/DesktopLyricOverlay.kt similarity index 86% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/DesktopLyricOverlay.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/DesktopLyricOverlay.kt index f7db689..6070292 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/DesktopLyricOverlay.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/DesktopLyricOverlay.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native +package com.zhushenwudi.lovelivemusicplayer.native import android.annotation.SuppressLint import android.content.Context @@ -18,21 +18,23 @@ import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast -import java.lang.ref.WeakReference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import top.zhushenwudi.llmp.MainActivity -import top.zhushenwudi.llmp.R +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.MainActivity +import com.zhushenwudi.lovelivemusicplayer.R +import java.lang.ref.WeakReference import kotlin.math.abs import kotlin.time.Duration.Companion.seconds /** - * Floating desktop-lyric overlay. - * Hosted by [top.zhushenwudi.llmp.player.PlaybackService] so it shares the Media3 FGS notification. + * 桌面悬浮歌词叠加层。 + * 由 [com.zhushenwudi.lovelivemusicplayer.player.PlaybackService] 承载,共享 Media3 FGS 通知。 */ class DesktopLyricOverlay( private val context: Context, @@ -40,18 +42,20 @@ class DesktopLyricOverlay( private val onRequestHide: () -> Unit, ) { companion object { - private const val MSG_HINT = "正在打开应用..." private val mainHandler = Handler(Looper.getMainLooper()) private var activeRef: WeakReference? = null - @Volatile private var cachedLine1: String? = null - @Volatile private var cachedLine2: String? = null - @Volatile private var cachedCurrentLine: Int = -1 + @Volatile + private var cachedLine1: String? = null + @Volatile + private var cachedLine2: String? = null + @Volatile + private var cachedCurrentLine: Int = -1 fun updateLyric(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { - cachedLine1 = lyricLine1 - cachedLine2 = lyricLine2 + if (lyricLine1 != null) cachedLine1 = lyricLine1 + if (lyricLine2 != null) cachedLine2 = lyricLine2 cachedCurrentLine = currentLine val overlay = activeRef?.get() ?: return if (Looper.myLooper() == Looper.getMainLooper()) { @@ -87,6 +91,7 @@ class DesktopLyricOverlay( fun attach() { if (root != null) return createView() + if (root == null) return bindView() activeRef = WeakReference(this) startChromeTimer() @@ -134,7 +139,12 @@ class DesktopLyricOverlay( } windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager root = LayoutInflater.from(context).inflate(R.layout.view_lyric, null) as RelativeLayout - windowManager?.addView(root, wmParams) + val added = runCatching { windowManager?.addView(root, wmParams) } + if (added.isFailure) { + android.util.Log.e("LLMP-Lyric", "add overlay failed", added.exceptionOrNull()) + root = null + windowManager = null + } } @SuppressLint("ClickableViewAccessibility") @@ -178,6 +188,7 @@ class DesktopLyricOverlay( sx = mRawX sy = mRawY } + MotionEvent.ACTION_MOVE -> { isMove = true val params = wmParams ?: return@setOnTouchListener true @@ -187,6 +198,7 @@ class DesktopLyricOverlay( sx = mRawX sy = mRawY } + MotionEvent.ACTION_UP -> { val elapsed = System.currentTimeMillis() - mLastTime val mStopX = event.rawX.toInt() @@ -206,11 +218,11 @@ class DesktopLyricOverlay( flags = Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(intent) - Toast.makeText(context, MSG_HINT, Toast.LENGTH_LONG).show() + Toast.makeText(context, I18n.t("opening_app"), Toast.LENGTH_LONG).show() onRequestHide() } ivTranslate?.setOnClickListener { - NativeEvents.lyricTypeClick() + AppContainer.cycleDesktopLyricType() } } @@ -218,13 +230,13 @@ class DesktopLyricOverlay( val lyricLine1 = cachedLine1 val lyricLine2 = cachedLine2 val currentLine = cachedCurrentLine - tvLyricLine1?.text = lyricLine1.orEmpty() - tvLyricLine2?.text = lyricLine2.orEmpty() + lyricLine1?.let { tvLyricLine1?.text = it } + lyricLine2?.let { tvLyricLine2?.text = it } tvLyricLine1?.setTextColor(if (currentLine == 2) Color.LTGRAY else Color.WHITE) tvLyricLine2?.setTextColor(if (currentLine == 1) Color.LTGRAY else Color.WHITE) if (currentLine == -1) { - tvNoLyric?.text = lyricLine1 ?: "暂无歌词" + tvNoLyric?.text = lyricLine1 ?: I18n.t("no_lyrics") tvLyricLine1?.visibility = View.GONE tvLyricLine2?.visibility = View.GONE tvNoLyric?.visibility = View.VISIBLE diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/NativeEvents.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/NativeEvents.kt similarity index 88% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/NativeEvents.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/NativeEvents.kt index 4ea3583..4737e29 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/NativeEvents.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/NativeEvents.kt @@ -1,9 +1,9 @@ -package top.zhushenwudi.llmp.native +package com.zhushenwudi.lovelivemusicplayer.native import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import top.zhushenwudi.llmp.platform.DeepLinkPayload +import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkPayload object NativeEvents { private val _widgetEvents = MutableSharedFlow(extraBufferCapacity = 8) diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/QrScanActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/QrScanActivity.kt similarity index 83% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/QrScanActivity.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/QrScanActivity.kt index 2bb9e58..0020e5c 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/QrScanActivity.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/QrScanActivity.kt @@ -1,10 +1,11 @@ -package top.zhushenwudi.llmp.native +package com.zhushenwudi.lovelivemusicplayer.native import android.os.Bundle import androidx.activity.ComponentActivity import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions -import top.zhushenwudi.llmp.platform.AndroidUiHost +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost class QrScanActivity : ComponentActivity() { private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> @@ -23,7 +24,7 @@ class QrScanActivity : ComponentActivity() { super.onCreate(savedInstanceState) val options = ScanOptions().apply { setDesiredBarcodeFormats(ScanOptions.QR_CODE) - setPrompt("扫描二维码") + setPrompt(I18n.t("scan_qr_prompt")) setBeepEnabled(false) setOrientationLocked(false) setCaptureActivity(com.journeyapps.barcodescanner.CaptureActivity::class.java) diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/UmengBridge.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/UmengBridge.kt similarity index 94% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/UmengBridge.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/UmengBridge.kt index 5af8640..50f42fc 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/UmengBridge.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/UmengBridge.kt @@ -1,7 +1,7 @@ -package top.zhushenwudi.llmp.native +package com.zhushenwudi.lovelivemusicplayer.native import android.content.Context -import top.zhushenwudi.llmp.platform.AnalyticsHook +import com.zhushenwudi.lovelivemusicplayer.platform.AnalyticsHook object UmengBridge : AnalyticsHook { private const val UMENG_KEY = "634bd9c688ccdf4b7e4ac67b" diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt new file mode 100644 index 0000000..36dfa65 --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/WebViewActivity.kt @@ -0,0 +1,203 @@ +package com.zhushenwudi.lovelivemusicplayer.native + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Bundle +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.compose.setContent +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.platform.WebViewAdBlock +import java.io.ByteArrayInputStream + +class WebViewActivity : ComponentActivity() { + private var webView: WebView? = null + private val canGoBack = mutableStateOf(false) + private val canGoForward = mutableStateOf(false) + private val pageProgress = mutableIntStateOf(0) + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(EXTRA_URL).orEmpty() + val title = intent.getStringExtra(EXTRA_TITLE) + title?.let { setTitle(it) } + + onBackPressedDispatcher.addCallback(this) { + val view = webView + if (view?.canGoBack() == true) { + view.goBack() + } else { + finish() + } + } + + setContent { + val backEnabled by canGoBack + val forwardEnabled by canGoForward + val progress by pageProgress + + MaterialTheme { + Column(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = AdBlockWebViewClient(::refreshNavigationState) + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + pageProgress.intValue = newProgress + } + } + webView = this + if (url.isNotBlank()) loadUrl(url) + refreshNavigationState() + } + }, + update = { view -> + webView = view + refreshNavigationState() + }, + ) + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + ) + } + } + Surface( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { webView?.goBack() }, + enabled = backEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = tr("backward"), + ) + } + IconButton( + onClick = { webView?.goForward() }, + enabled = forwardEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = tr("forward"), + ) + } + IconButton(onClick = { webView?.reload() }) { + Icon(Icons.Default.Refresh, contentDescription = tr("refresh")) + } + } + } + } + } + } + } + + private fun refreshNavigationState() { + canGoBack.value = webView?.canGoBack() == true + canGoForward.value = webView?.canGoForward() == true + } + + private class AdBlockWebViewClient( + private val onNavigationChanged: () -> Unit, + ) : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean = false + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + val host = request?.url?.host + if (!host.isNullOrBlank() && WebViewAdBlock.isBlockedHost(host)) { + return WebResourceResponse( + "text/plain", + "utf-8", + ByteArrayInputStream(ByteArray(0)), + ) + } + return super.shouldInterceptRequest(view, request) + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + onNavigationChanged() + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (view != null && !url.isNullOrBlank()) { + view.evaluateJavascript(WebViewAdBlock.injectionScript(url), null) + } + onNavigationChanged() + } + + override fun doUpdateVisitedHistory( + view: WebView?, + url: String?, + isReload: Boolean, + ) { + super.doUpdateVisitedHistory(view, url, isReload) + onNavigationChanged() + } + } + + companion object { + const val EXTRA_URL = "url" + const val EXTRA_TITLE = "title" + } +} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/HomeWidgetContent.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt similarity index 65% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/HomeWidgetContent.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt index d31d91a..bd466b6 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/HomeWidgetContent.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/HomeWidgetContent.kt @@ -1,426 +1,455 @@ -package top.zhushenwudi.llmp.native.home_widget - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -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.compose.ui.unit.sp -import androidx.glance.ColorFilter -import androidx.glance.GlanceId -import androidx.glance.GlanceModifier -import androidx.glance.Image -import androidx.glance.ImageProvider -import androidx.glance.LocalContext -import androidx.glance.action.Action -import androidx.glance.action.clickable -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.provideContent -import androidx.glance.background -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.Column -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.height -import androidx.glance.layout.padding -import androidx.glance.layout.size -import androidx.glance.layout.width -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import top.zhushenwudi.llmp.MainActivity -import top.zhushenwudi.llmp.R -import top.zhushenwudi.llmp.native.util.AppUtils -import top.zhushenwudi.llmp.native.util.ImageUtil -import java.io.File - -abstract class HomeWidgetContent : GlanceAppWidget() { - open var size: DpSize = AppUtils.SMALL_SQUARE - open var radius: Dp = 6.dp - open val cdSize = 80.dp - abstract var isWhite: Boolean - var isPlaying = true - var isShutdown = false - private var isFavorite = false - private lateinit var sp: SharedPreferences - private lateinit var songName: String - private lateinit var songArtist: String - private lateinit var playText: String - private var lyricLine1: String = "" - private var lyricLine2: String = "" - private var currentLine: Int = -1 - - private var musicColor = Color.White - private var coverPath: String? = null - private var line1Color = Color.White - private var line2Color = Color.White - - private fun widgetAction(context: Context, uri: String): Action { - val intent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = Uri.parse(uri) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - } - return actionStartActivity(intent) - } - - @Composable - fun GlanceContent(preferences: SharedPreferences) { - sp = preferences - songName = sp.getString("songName", "") ?: "" - songArtist = sp.getString("songArtist", "") ?: "" - isFavorite = sp.getBoolean("songFavorite", false) - isPlaying = sp.getBoolean("isPlaying", false) - playText = sp.getString("playText", "") ?: "" - val tempLine1 = sp.getString("lyricLine1", null) - val tempLine2 = sp.getString("lyricLine2", null) - currentLine = sp.getInt("currentLine", -1) - isShutdown = sp.getBoolean("isShutdown", false) - coverPath = sp.getString("shareImage", null) - val strBgColor = sp.getString("bgColor", "") ?: "" - - coverPath?.let { - if (!File(it).exists()) { - coverPath = null - } - } - - if (strBgColor.contains(",")) { - val bgColorArr = strBgColor.split(",") - musicColor = Color( - red = bgColorArr.getOrNull(0)?.toFloatOrNull() ?: 1f, - green = bgColorArr.getOrNull(1)?.toFloatOrNull() ?: 1f, - blue = bgColorArr.getOrNull(2)?.toFloatOrNull() ?: 1f, - ) - } - - if (isShutdown) { - isPlaying = false - } - - tempLine1?.let { lyricLine1 = it } - tempLine2?.let { lyricLine2 = it } - if (currentLine == -1 || currentLine == 1) { - line1Color = if (isWhite) Color.Black else Color.White - line2Color = Color.LightGray - } else { - line2Color = if (isWhite) Color.Black else Color.White - line1Color = Color.LightGray - } - - RenderAppWidget() - } - - @Composable - fun RenderAppWidget() { - Box( - modifier = GlanceModifier - .width(size.width) - .height(size.height), - ) { - if (isWhite) { - GradientRectangle(color = musicColor) - } else { - Box( - modifier = GlanceModifier - .background(Color(red = 49, green = 49, blue = 49)) - .width(size.width) - .height(size.height) - .cornerRadius(radius), - ) {} - } - - Box( - contentAlignment = Alignment.CenterEnd, - modifier = GlanceModifier - .width(size.width) - .height(size.height) - .cornerRadius(radius), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = GlanceModifier - .size(size.height) - .cornerRadius(radius), - ) { - RenderCD(isLargePauseWidget = false) - RenderCover(isLargePauseWidget = false) - RenderPlayController(false) - } - } - - Column( - modifier = GlanceModifier - .width(size.width) - .height(size.height) - .padding(radius), - ) { - Box( - modifier = GlanceModifier.defaultWeight(), - ) { - Column { - RenderLogo(size = 22.dp, paddingInDp = (-6).dp) - if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) { - RenderLyric(isLargePauseWidget = false) - } - } - } - - RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp) - } - - RenderFavorite(imageSize = 20.dp, paddingInDp = radius) - } - } - - open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp - - open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp - - @SuppressLint("RestrictedApi") - @Composable - open fun RenderTextArr( - stateFontSize: TextUnit, - infoFontSize: TextUnit, - ) { - val playTextArr = playText.split(",") - var playStateText = playTextArr.getOrElse(1) { "" } - if (!isShutdown && isPlaying) { - playStateText = playTextArr.getOrElse(0) { playStateText } - } - Text( - text = playStateText, - style = TextStyle( - fontSize = stateFontSize, - fontWeight = FontWeight.Bold, - color = ColorProvider(Color.Gray), - ), - ) - - Text( - text = songName, - style = TextStyle( - fontSize = infoFontSize, - fontWeight = FontWeight.Bold, - color = ColorProvider(if (isWhite) Color.Black else Color.White), - ), - ) - - Text( - text = songArtist, - style = TextStyle( - fontSize = infoFontSize, - fontWeight = FontWeight.Bold, - color = ColorProvider(Color.Gray), - ), - ) - } - - @Composable - open fun RenderLogo(size: Dp, paddingInDp: Dp) { - Image( - provider = ImageProvider(R.drawable.logo), - contentDescription = "logo", - modifier = GlanceModifier - .size(size) - .padding(start = paddingInDp), - ) - } - - @SuppressLint("RestrictedApi") - @Composable - open fun RenderLyric(isLargePauseWidget: Boolean) { - val basicWidth = size.width - val width = if (isLargePauseWidget) basicWidth - 150.dp else basicWidth - 60.dp - Text( - text = lyricLine1, - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = ColorProvider(line1Color), - ), - maxLines = 1, - modifier = GlanceModifier.width(width), - ) - - Text( - text = lyricLine2, - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = ColorProvider(line2Color), - ), - maxLines = 1, - modifier = GlanceModifier.width(width), - ) - } - - @Composable - open fun RenderCD(isLargePauseWidget: Boolean) { - val context = LocalContext.current - val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black - val offsetX = calcCdOffsetX() - val offsetY = calcCdOffsetY() - val startPadding = if (isLargePauseWidget) 0.dp else offsetX - val radio = if (isLargePauseWidget) 0.9f else 1f - if (isShutdown) { - Image( - provider = ImageProvider(cdDrawable), - contentDescription = "cd", - modifier = GlanceModifier - .size(size.height * radio) - .padding( - start = startPadding, - top = -offsetY, - end = -offsetX, - bottom = offsetY, - ), - ) - } else { - Image( - provider = ImageProvider(cdDrawable), - contentDescription = "cd", - modifier = GlanceModifier - .size(size.height * radio) - .padding( - start = startPadding, - top = -offsetY, - end = -offsetX, - bottom = offsetY, - ) - .clickable( - rippleOverride = R.drawable.click_ripple, - onClick = widgetAction(context, "homeWidgetExample://toggle_play"), - ), - ) - } - } - - @Composable - open fun RenderCover(isLargePauseWidget: Boolean) { - val coverSize = cdSize * 0.4f - val coverAndCdDiffSize = (cdSize - coverSize) / 2 - val offsetX = calcCdOffsetX() - val offsetY = calcCdOffsetY() - val startPadding = - if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize - val radio = if (isLargePauseWidget) 0.95f else 1f - coverPath?.let { - if (File(it).exists()) { - Image( - provider = ImageProvider(BitmapFactory.decodeFile(it)), - contentDescription = "cover", - modifier = GlanceModifier - .size(size.height * radio) - .padding( - start = startPadding, - top = -offsetY + coverAndCdDiffSize, - bottom = offsetY + coverAndCdDiffSize, - end = -offsetX + coverAndCdDiffSize, - ), - ) - } - } - } - - @SuppressLint("RestrictedApi") - @Composable - open fun RenderPlayController(isLargePauseWidget: Boolean) { - val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play - - if (isLargePauseWidget) { - Image( - provider = ImageProvider(resId), - contentDescription = "playButton", - colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)), - modifier = GlanceModifier.size(20.dp), - ) - } else { - val padding = - if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp - Box( - contentAlignment = Alignment.TopEnd, - modifier = GlanceModifier - .fillMaxSize() - .padding(top = padding, end = padding), - ) { - Image( - provider = ImageProvider(resId), - contentDescription = "playButton", - colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)), - modifier = GlanceModifier.size(15.dp), - ) - } - } - } - - @Composable - open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { - val context = LocalContext.current - Box( - contentAlignment = Alignment.BottomEnd, - modifier = GlanceModifier - .width(size.width) - .height(size.height) - .padding(paddingInDp), - ) { - val loveIcon = - if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick - if (isShutdown) { - Image( - provider = ImageProvider(loveIcon), - contentDescription = "love", - modifier = GlanceModifier.size(imageSize), - ) - } else { - Image( - provider = ImageProvider(loveIcon), - contentDescription = "love", - modifier = GlanceModifier - .size(imageSize) - .clickable( - rippleOverride = R.drawable.click_ripple, - onClick = widgetAction(context, "homeWidgetExample://toggle_love"), - ), - ) - } - } - } - - @Composable - fun GradientRectangle(color: Color) { - val bitmap = ImageUtil.createGradientImage(size.width.value, size.height.value, color) - - Box( - modifier = GlanceModifier - .width(size.width) - .height(size.height) - .cornerRadius(radius), - ) { - Image( - provider = ImageProvider(bitmap), - contentDescription = "bg", - modifier = GlanceModifier - .width(size.width) - .height(size.height), - ) - } - } - - override suspend fun provideGlance(context: Context, id: GlanceId) { - val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) - provideContent { - GlanceContent(preferences = preferences) - } - } -} +package com.zhushenwudi.lovelivemusicplayer.native.home_widget + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.appwidget.action.actionStartActivity +import com.zhushenwudi.lovelivemusicplayer.MainActivity +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +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.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.zhushenwudi.lovelivemusicplayer.R +import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils +import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil +import java.io.File + +abstract class HomeWidgetContent : GlanceAppWidget() { + open var size: DpSize = AppUtils.SMALL_SQUARE + open var radius: Dp = 6.dp + open val cdSize = 80.dp + abstract var isWhite: Boolean + var isPlaying = true + var isShutdown = false + private var isFavorite = false + private lateinit var sp: SharedPreferences + private lateinit var songName: String + private lateinit var songArtist: String + private lateinit var playText: String + + private var musicColor = Color.White + private var coverPath: String? = null + + @Composable + private fun widgetAction(context: Context, uri: String): Action { + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = uri.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return actionStartActivity(intent) + } + + @Composable + fun GlanceContent(preferences: SharedPreferences) { + sp = preferences + songName = sp.getString("songName", "")!! + songArtist = sp.getString("songArtist", "")!! + isFavorite = sp.getBoolean("songFavorite", false) + isPlaying = sp.getBoolean("isPlaying", false) + playText = sp.getString("playText", "")!! + isShutdown = sp.getBoolean("isShutdown", false) + coverPath = sp.getString("shareImage", null) + val strBgColor = sp.getString("bgColor", "")!! + + coverPath?.let { + if (!File(it).exists()) { + coverPath = null + } + } + + if (strBgColor.contains(",")) { + val bgColorArr = strBgColor.split(",") + musicColor = Color( + red = bgColorArr[0].toDoubleOrNull()?.toInt() ?: 1, + green = bgColorArr[1].toDoubleOrNull()?.toInt() ?: 1, + blue = bgColorArr[2].toDoubleOrNull()?.toInt() ?: 1 + ) + } + + if (isShutdown) { + isPlaying = false + } + + RenderAppWidget() + } + + @Composable + fun RenderAppWidget() { + Box( + modifier = GlanceModifier + .width(size.width) + .height(size.height) + ) { + if (isWhite) { + GradientRectangle(color = musicColor) + } else { + Box( + modifier = GlanceModifier + .background(Color(red = 49, green = 49, blue = 49)) + .width(size.width) + .height(size.height) + .cornerRadius(radius) + ) {} + } + + Box( + contentAlignment = Alignment.CenterEnd, + modifier = GlanceModifier + .width(size.width) + .height(size.height) + .cornerRadius(radius) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .size(size.height) + .cornerRadius(radius) + ) { + RenderCD(isLargePauseWidget = false) + RenderCover(isLargePauseWidget = false) + RenderPlayController(false) + } + } + + Column( + modifier = GlanceModifier + .width(size.width) + .height(size.height) + .padding(radius) + ) { + Box( + modifier = GlanceModifier.defaultWeight() + ) { + RenderLogo(size = 22.dp, paddingInDp = (-6).dp) + } + + RenderTextArr(stateFontSize = 9.sp, infoFontSize = 11.sp) + } + + RenderFavorite(imageSize = 20.dp, paddingInDp = radius) + } + } + + open fun calcCdOffsetX(): Dp = cdSize / 2 - 10.dp + + open fun calcCdOffsetY(): Dp = cdSize / 2 - 10.dp + + @SuppressLint("RestrictedApi") + @Composable + open fun RenderTextArr( + stateFontSize: TextUnit, + infoFontSize: TextUnit + ) { + val textWidth = if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) { + size.width - 135.dp + } else { + size.width - 75.dp + } + val playTextArr = playText.split(",") + var playStateText = playTextArr[1] + if (!isShutdown && isPlaying) { + playStateText = playTextArr[0] + } + Text( + text = playStateText, + style = TextStyle( + fontSize = stateFontSize, + fontWeight = FontWeight.Bold, + color = ColorProvider(Color.Gray) + ), + maxLines = 1, + modifier = GlanceModifier.width(textWidth), + ) + + Text( + text = songName, + style = TextStyle( + fontSize = infoFontSize, + fontWeight = FontWeight.Bold, + color = ColorProvider(if (isWhite) Color.Black else Color.White) + ), + maxLines = 1, + modifier = GlanceModifier.width(textWidth), + ) + + Text( + text = songArtist, + style = TextStyle( + fontSize = infoFontSize, + fontWeight = FontWeight.Bold, + color = ColorProvider(Color.Gray) + ), + maxLines = 1, + modifier = GlanceModifier.width(textWidth), + ) + } + + @Composable + open fun RenderLogo(size: Dp, paddingInDp: Dp) { + Image( + provider = ImageProvider(R.drawable.logo), + contentDescription = "logo", + modifier = GlanceModifier + .size(size) + .padding(start = paddingInDp) + ) + } + + @SuppressLint("RestrictedApi") + @Composable + private fun RenderTransportButton( + drawable: Int, + description: String, + uri: String, + imageSize: Dp, + ) { + val base = GlanceModifier.size(imageSize) + val modifier = if (isShutdown) { + base + } else { + base.clickable( + rippleOverride = R.drawable.click_ripple, + onClick = widgetAction(LocalContext.current, uri), + ) + } + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Image( + provider = ImageProvider(drawable), + contentDescription = description, + colorFilter = ColorFilter.tint(ColorProvider(Color.White)), + modifier = GlanceModifier.size(imageSize * TRANSPORT_ICON_SCALE), + ) + } + } + + @Composable + open fun RenderCD(isLargePauseWidget: Boolean) { + val cdDrawable = if (isWhite) R.drawable.cd_white else R.drawable.cd_black + val offsetX = calcCdOffsetX() + val offsetY = calcCdOffsetY() + + val startPadding = if (isLargePauseWidget) 0.dp else offsetX + val radio = if (isLargePauseWidget) 0.9f else 1f + if (isShutdown) { + Image( + provider = ImageProvider(cdDrawable), + contentDescription = "cd", + modifier = GlanceModifier + .size(size.height * radio) + .padding( + start = startPadding, + top = -offsetY, + end = -offsetX, + bottom = offsetY + ) + ) + } else { + Image( + provider = ImageProvider(cdDrawable), + contentDescription = "cd", + modifier = GlanceModifier + .size(size.height * radio) + .padding( + start = startPadding, + top = -offsetY, + end = -offsetX, + bottom = offsetY + ) + .clickable(rippleOverride = R.drawable.click_ripple, onClick = widgetAction(LocalContext.current, "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play")) + ) + } + } + + @Composable + open fun RenderCover(isLargePauseWidget: Boolean) { + val coverSize = cdSize * 0.4f + val coverAndCdDiffSize = (cdSize - coverSize) / 2 + val offsetX = calcCdOffsetX() + val offsetY = calcCdOffsetY() + val startPadding = + if (isLargePauseWidget) coverAndCdDiffSize else offsetX + coverAndCdDiffSize + val radio = if (isLargePauseWidget) 0.95f else 1f + coverPath?.let { + if (File(it).exists()) { + Image( + provider = ImageProvider(BitmapFactory.decodeFile(it)), + contentDescription = "cover", + modifier = GlanceModifier + .size(size.height * radio) + .padding( + start = startPadding, + top = -offsetY + coverAndCdDiffSize, + bottom = offsetY + coverAndCdDiffSize, + end = -offsetX + coverAndCdDiffSize + ) + ) + } + } + } + + @SuppressLint("RestrictedApi") + @Composable + open fun RenderPlayController(isLargePauseWidget: Boolean) { + val resId = if (isPlaying) R.drawable.vector_pause else R.drawable.vector_play + val toggleAction = widgetAction( + LocalContext.current, + "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_play", + ) + + if (isLargePauseWidget) { + val modifier = if (isShutdown) { + GlanceModifier.size(20.dp) + } else { + GlanceModifier + .size(20.dp) + .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction) + } + Image( + provider = ImageProvider(resId), + contentDescription = "playButton", + colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)), + modifier = modifier, + ) + } else { + val padding = + if (size.width == AppUtils.HORIZONTAL_RECTANGLE.width) 26.dp else 23.dp + val playModifier = if (isShutdown) { + GlanceModifier.size(15.dp) + } else { + GlanceModifier + .size(15.dp) + .clickable(rippleOverride = R.drawable.click_ripple, onClick = toggleAction) + } + Box( + contentAlignment = Alignment.TopEnd, + modifier = GlanceModifier + .fillMaxSize() + .padding(top = padding, end = padding) + ) { + Image( + provider = ImageProvider(resId), + contentDescription = "playButton", + colorFilter = ColorFilter.tint(colorProvider = ColorProvider(Color.White)), + modifier = playModifier, + ) + } + } + + } + + @Composable + open fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { + val isLarge = size.width == AppUtils.HORIZONTAL_RECTANGLE.width + Box( + contentAlignment = Alignment.BottomEnd, + modifier = GlanceModifier + .width(size.width) + .height(size.height) + .padding(paddingInDp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLarge) { + RenderTransportButton( + drawable = R.drawable.player_play_prev, + description = "previous", + uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}previous", + imageSize = imageSize, + ) + Spacer(GlanceModifier.width(8.dp)) + } + RenderTransportButton( + drawable = R.drawable.player_play_next, + description = "next", + uri = "${AppUtils.HOME_WIDGET_URI_PREFIX}next", + imageSize = imageSize, + ) + Spacer(GlanceModifier.width(8.dp)) + val loveIcon = + if (isFavorite) R.drawable.widget_fav_click else R.drawable.widget_fav_unclick + val favoriteModifier = if (isShutdown) { + GlanceModifier.size(imageSize) + } else { + GlanceModifier + .size(imageSize) + .clickable( + rippleOverride = R.drawable.click_ripple, + onClick = widgetAction( + LocalContext.current, + "${AppUtils.HOME_WIDGET_URI_PREFIX}toggle_love", + ), + ) + } + Image( + provider = ImageProvider(loveIcon), + contentDescription = "love", + modifier = favoriteModifier, + ) + } + } + } + + @Composable + fun GradientRectangle(color: Color) { + val bitmap = ImageUtil.createGradientImage(size.width.value, size.height.value, color) + + Box( + modifier = GlanceModifier + .width(size.width) + .height(size.height) + .cornerRadius(radius) + ) { + Image( + provider = ImageProvider(bitmap), + contentDescription = "bg", + modifier = GlanceModifier + .width(size.width) + .height(size.height) + ) + } + } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val preferences = context.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) + provideContent { GlanceContent(preferences = preferences) } + } + + private companion object { + const val TRANSPORT_ICON_SCALE = 0.58f + } +} \ No newline at end of file diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt index ff1cf99..9328120 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidget.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.large_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget import androidx.glance.appwidget.SizeMode diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt similarity index 75% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt index c5da22d..9b01539 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/BlackLargeHomeWidgetReceiver.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.large_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt index bbd45e9..d035eb5 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/LargeHomeWidgetContent.kt @@ -1,60 +1,59 @@ -package top.zhushenwudi.llmp.native.home_widget.large_home_widget - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -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.compose.ui.unit.sp -import top.zhushenwudi.llmp.native.home_widget.HomeWidgetContent -import top.zhushenwudi.llmp.native.util.AppUtils - -@SuppressLint("CommitPrefEdits", "DefaultLocale") -abstract class LargeHomeWidgetContent( - override var size: DpSize = AppUtils.HORIZONTAL_RECTANGLE, - override var radius: Dp = 12.dp, - override var cdSize: Dp = 120.dp, -) : HomeWidgetContent() { - - private fun isLargePauseWidget(): Boolean = !isPlaying || isShutdown - - @Composable - override fun RenderLogo(size: Dp, paddingInDp: Dp) { - super.RenderLogo(size = 30.dp, paddingInDp) - } - - @Composable - override fun RenderCD(isLargePauseWidget: Boolean) { - super.RenderCD(isLargePauseWidget = isLargePauseWidget()) - } - - @Composable - override fun RenderLyric(isLargePauseWidget: Boolean) { - super.RenderLyric(isLargePauseWidget = isLargePauseWidget()) - } - - @Composable - override fun RenderCover(isLargePauseWidget: Boolean) { - super.RenderCover(isLargePauseWidget = isLargePauseWidget()) - } - - @Composable - override fun RenderPlayController(isLargePauseWidget: Boolean) { - super.RenderPlayController(isLargePauseWidget = isLargePauseWidget()) - } - - @Composable - override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) { - super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp) - } - - @Composable - override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { - super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2) - } - - override fun calcCdOffsetX(): Dp = if (isPlaying) (cdSize / 2 - 10.dp) else 0.dp - - override fun calcCdOffsetY(): Dp = if (isPlaying) (cdSize / 2 - 10.dp) else 0.dp -} +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +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.compose.ui.unit.sp +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent +import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils + +@SuppressLint("CommitPrefEdits", "DefaultLocale") +abstract class LargeHomeWidgetContent( + override var size: DpSize = AppUtils.HORIZONTAL_RECTANGLE, + override var radius: Dp = 12.dp, + override var cdSize: Dp = 120.dp, +) : HomeWidgetContent() { + + private fun isLargePauseWidget(): Boolean { + return !isPlaying || isShutdown + } + + @Composable + override fun RenderLogo(size: Dp, paddingInDp: Dp) { + super.RenderLogo(size = 30.dp, paddingInDp) + } + + @Composable + override fun RenderCD(isLargePauseWidget: Boolean) { + super.RenderCD(isLargePauseWidget = isLargePauseWidget()) + } + + @Composable + override fun RenderCover(isLargePauseWidget: Boolean) { + super.RenderCover(isLargePauseWidget = isLargePauseWidget()) + } + + @Composable + override fun RenderPlayController(isLargePauseWidget: Boolean) { + super.RenderPlayController(isLargePauseWidget = isLargePauseWidget()) + } + + @Composable + override fun RenderTextArr(stateFontSize: TextUnit, infoFontSize: TextUnit) { + super.RenderTextArr(stateFontSize = 12.sp, infoFontSize = 13.sp) + } + + @Composable + override fun RenderFavorite(imageSize: Dp, paddingInDp: Dp) { + super.RenderFavorite(imageSize = 28.dp, paddingInDp = radius / 2) + } + + override fun calcCdOffsetX(): Dp = + if (isPlaying) cdSize / 2 + 5.dp else 12.dp + + override fun calcCdOffsetY(): Dp = + if (isPlaying) cdSize / 2 + 5.dp else 12.dp +} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt index 608567b..b8f4a93 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidget.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.large_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget import androidx.glance.appwidget.SizeMode diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt similarity index 75% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt index 94975aa..546c8a9 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/large_home_widget/WhiteLargeHomeWidgetReceiver.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.large_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt index 6916ac8..011e9c0 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidget.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.small_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget import androidx.glance.appwidget.SizeMode diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt similarity index 75% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt index e0cb4d4..472ade3 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/BlackSmallHomeWidgetReceiver.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.small_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt new file mode 100644 index 0000000..a1de136 --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt @@ -0,0 +1,9 @@ +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.HomeWidgetContent + +abstract class SmallHomeWidgetContent( + override var radius: Dp = 6.dp +) : HomeWidgetContent() \ No newline at end of file diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt index 7c91044..44ef419 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidget.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.small_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget import androidx.glance.appwidget.SizeMode diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt similarity index 75% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt index 6446263..0ace171 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/home_widget/small_home_widget/WhiteSmallHomeWidgetReceiver.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.home_widget.small_home_widget +package com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDeepLinkHandler.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDeepLinkHandler.kt similarity index 58% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDeepLinkHandler.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDeepLinkHandler.kt index 3a387bf..d78cc5d 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDeepLinkHandler.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDeepLinkHandler.kt @@ -1,9 +1,9 @@ -package top.zhushenwudi.llmp.native.platform +package com.zhushenwudi.lovelivemusicplayer.native.platform -import top.zhushenwudi.llmp.native.NativeEvents -import top.zhushenwudi.llmp.platform.DeepLinkHandler -import top.zhushenwudi.llmp.platform.DeepLinkParser -import top.zhushenwudi.llmp.platform.DeepLinkPayload +import com.zhushenwudi.lovelivemusicplayer.native.NativeEvents +import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkHandler +import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkParser +import com.zhushenwudi.lovelivemusicplayer.platform.DeepLinkPayload class AndroidDeepLinkHandler( private val onDeepLink: ((DeepLinkPayload) -> Unit)? = null, diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDesktopLyricController.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDesktopLyricController.kt new file mode 100644 index 0000000..659861c --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidDesktopLyricController.kt @@ -0,0 +1,85 @@ +package com.zhushenwudi.lovelivemusicplayer.native.platform + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.core.net.toUri +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost +import com.zhushenwudi.lovelivemusicplayer.platform.DesktopLyricController +import com.zhushenwudi.lovelivemusicplayer.player.PlaybackService + +/** + * 桌面歌词控制器 — 对齐 Flutter [DesktopLyricPlugin] + [MainActivity]。 + * 叠加层由 [PlaybackService] 承载(共享 Media3 FGS)。 + */ +class AndroidDesktopLyricController( + context: android.content.Context, +) : DesktopLyricController { + private val appContext = context.applicationContext + private var playing: Boolean = false + + override var enabled: Boolean = false + + override fun pipAutoOpen(enable: Boolean): Boolean { + if (enable) { + val ctx = AndroidUiHost.activity ?: appContext + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + "package:${appContext.packageName}".toUri(), + ) + val act = AndroidUiHost.activity + if (act != null) { + @Suppress("DEPRECATION") + act.startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION) + } else { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) + } + return false + } + } + enabled = enable + if (!enable) { + PlaybackService.hideDesktopLyric(appContext) + } + return true + } + + override fun show() { + if (!enabled) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(appContext)) { + return + } + PlaybackService.showDesktopLyric(appContext) + } + + override fun hide() { + PlaybackService.hideDesktopLyric(appContext) + } + + override fun updateLines(line1: String?, line2: String?, currentLine: Int) { + PlaybackService.updateDesktopLyric(line1, line2, currentLine) + } + + override fun setPlaying(playing: Boolean) { + this.playing = playing + } + + override fun isOverlayShowing(): Boolean = PlaybackService.isDesktopLyricShowing() + + fun isPlaying(): Boolean = playing + + companion object { + const val REQUEST_CODE_OVERLAY_PERMISSION = 0x10 + + fun onOverlayPermissionResult(granted: Boolean) { + if (!granted) { + val act = AndroidUiHost.activity ?: return + Toast.makeText(act, I18n.t("no_permission"), Toast.LENGTH_LONG).show() + } + } + } +} diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt new file mode 100644 index 0000000..d167ceb --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidHomeWidgetController.kt @@ -0,0 +1,129 @@ +package com.zhushenwudi.lovelivemusicplayer.native.platform + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import androidx.core.content.edit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver +import com.zhushenwudi.lovelivemusicplayer.native.home_widget.small_home_widget.WhiteSmallHomeWidgetReceiver +import com.zhushenwudi.lovelivemusicplayer.native.util.AppUtils +import com.zhushenwudi.lovelivemusicplayer.native.util.ImageUtil +import com.zhushenwudi.lovelivemusicplayer.platform.HomeWidgetController +import java.io.File + +class AndroidHomeWidgetController( + context: Context, +) : HomeWidgetController { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var preImagePath: String? = null + private var lastContent: WidgetContent? = null + + private val receiverClasses = listOf( + WhiteSmallHomeWidgetReceiver::class.java, + BlackSmallHomeWidgetReceiver::class.java, + WhiteLargeHomeWidgetReceiver::class.java, + BlackLargeHomeWidgetReceiver::class.java, + ) + + override fun update( + songName: String?, + artist: String?, + isPlaying: Boolean, + favorite: Boolean, + coverPath: String?, + playText: String, + ) { + val content = WidgetContent( + songName = songName.orEmpty(), + artist = artist.orEmpty(), + isPlaying = isPlaying, + favorite = favorite, + playText = playText, + ) + val contentChanged = content != lastContent + val coverChanged = !coverPath.isNullOrBlank() && coverPath != preImagePath + if (!contentChanged && !coverChanged) return + + if (contentChanged) { + lastContent = content + appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) + .edit(commit = true) { + putString("songName", content.songName) + putString("songArtist", content.artist) + putBoolean("songFavorite", content.favorite) + putBoolean("isPlaying", content.isPlaying) + putString("playText", content.playText) + if (content.songName.isNotBlank() || content.isPlaying) { + putBoolean("isShutdown", false) + } + } + refresh() + } + if (coverChanged) coverPath?.let(::processCover) + } + + override fun setShutdown(shutdown: Boolean) { + appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE) + .edit(commit = true) { + putBoolean("isShutdown", shutdown) + if (shutdown) { + putBoolean("isPlaying", false) + } + } + refresh() + } + + override fun refresh() { + receiverClasses.forEach { receiverClass -> + sendWidgetUpdate(receiverClass) + } + } + + private fun processCover(coverPath: String) { + if (preImagePath == coverPath || !File(coverPath).exists()) return + preImagePath = coverPath + scope.launch { + try { + val originBitmap = BitmapFactory.decodeFile(coverPath) + val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180) + val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap) + ImageUtil.savePathToSp(appContext, handledCoverPath) + val color = ImageUtil.parsePhotoDomainColor(coverBitmap) + ImageUtil.saveColorToSp(appContext, color) + originBitmap.recycle() + coverBitmap.recycle() + } catch (_: Exception) { + } finally { + refresh() + } + } + } + + private fun sendWidgetUpdate(receiverClass: Class<*>) { + val ids = AppWidgetManager.getInstance(appContext) + .getAppWidgetIds(ComponentName(appContext, receiverClass)) + if (ids.isEmpty()) return + val intent = Intent(appContext, receiverClass).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + } + appContext.sendBroadcast(intent) + } + + private data class WidgetContent( + val songName: String, + val artist: String, + val isPlaying: Boolean, + val favorite: Boolean, + val playText: String, + ) +} diff --git a/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidShareBridge.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidShareBridge.kt new file mode 100644 index 0000000..9727e8d --- /dev/null +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidShareBridge.kt @@ -0,0 +1,145 @@ +package com.zhushenwudi.lovelivemusicplayer.native.platform + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import com.tencent.connect.share.QQShare +import com.tencent.tauth.IUiListener +import com.tencent.tauth.Tencent +import com.tencent.tauth.UiError +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.platform.AndroidUiHost +import com.zhushenwudi.lovelivemusicplayer.platform.ShareBridge + +class AndroidShareBridge( + context: Context, +) : ShareBridge { + private val appContext = context.applicationContext + + override fun shareText(text: String, title: String?) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + title?.let { putExtra(Intent.EXTRA_TITLE, it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + appContext.startActivity( + Intent.createChooser(intent, title ?: I18n.t("share_chooser")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + + override fun shareUrl(url: String, title: String?) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + title?.let { putExtra(Intent.EXTRA_TITLE, it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + appContext.startActivity( + Intent.createChooser(intent, title ?: I18n.t("share_chooser")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + + override fun shareToQq( + title: String, + text: String, + url: String, + imageUrl: String?, + imagePath: String?, + ) { + val activity = AndroidUiHost.activity + if (activity == null) { + toast(I18n.t("share_error_n", I18n.t("no_activity"))) + return + } + val sdk = tencent(appContext) + if (!sdk.isQQInstalled(appContext)) { + toast(I18n.t("no_qq")) + return + } + val params = Bundle().apply { + putInt(QQShare.SHARE_TO_QQ_KEY_TYPE, QQShare.SHARE_TO_QQ_TYPE_DEFAULT) + putString(QQShare.SHARE_TO_QQ_TITLE, title) + putString(QQShare.SHARE_TO_QQ_SUMMARY, text) + putString(QQShare.SHARE_TO_QQ_TARGET_URL, url) + putString(QQShare.SHARE_TO_QQ_APP_NAME, "LLMP") + val preview = imageUrl?.takeIf { it.startsWith("http") } ?: Const.SHARE_DEFAULT_LOGO + putString(QQShare.SHARE_TO_QQ_IMAGE_URL, preview) + } + runCatching { + sdk.shareToQQ(activity, params, listener) + }.onFailure { e -> + toast(I18n.t("share_error_n", e.message ?: e.javaClass.simpleName)) + } + } + + companion object { + val listener: IUiListener = QqShareUiListener() + + @Volatile + private var instance: Tencent? = null + + fun tencent(context: Context): Tencent { + Tencent.setIsPermissionGranted(true, Build.MODEL) + instance?.let { return it } + val created = Tencent.createInstance( + Const.QQ_APP_ID, + context.applicationContext, + "${context.packageName}.fileprovider", + ) + instance = created + return created + } + + fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + Tencent.onActivityResultData(requestCode, resultCode, data, listener) + } + + fun handleIntentResult(intent: Intent?) { + if (intent == null) return + Tencent.handleResultData(intent, listener) + } + + fun toast(message: String) { + val ctx = instanceContext + if (ctx == null) return + Handler(Looper.getMainLooper()).post { + Toast.makeText(ctx, "\u202A$message\u202C", Toast.LENGTH_SHORT).show() + } + } + + @Volatile + private var instanceContext: Context? = null + } + + init { + instanceContext = appContext + } +} + +private class QqShareUiListener : IUiListener { + override fun onComplete(response: Any?) { + AndroidShareBridge.toast(I18n.t("share_success")) + } + + override fun onError(e: UiError?) { + val code = e?.errorCode ?: -1 + val detail = listOfNotNull(e?.errorMessage, e?.errorDetail) + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(" ") + val suffix = if (detail.isEmpty()) "$code" else "$code $detail" + AndroidShareBridge.toast(I18n.t("share_error_n", suffix)) + } + + override fun onCancel() { + AndroidShareBridge.toast(I18n.t("share_cancel")) + } + + override fun onWarning(code: Int) = Unit +} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidUsbMountMonitor.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidUsbMountMonitor.kt similarity index 70% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidUsbMountMonitor.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidUsbMountMonitor.kt index 8a8ee77..33d9aa4 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidUsbMountMonitor.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/AndroidUsbMountMonitor.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.platform +package com.zhushenwudi.lovelivemusicplayer.native.platform import android.content.BroadcastReceiver import android.content.Context @@ -8,8 +8,8 @@ import android.os.Build import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import top.zhushenwudi.llmp.platform.FileSystem -import top.zhushenwudi.llmp.platform.UsbMountMonitor +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.platform.UsbMountMonitor enum class UsbMountEvent { MOUNTED, @@ -22,12 +22,14 @@ class AndroidUsbMountMonitor( ) : UsbMountMonitor { private val appContext = context.applicationContext private var receiver: BroadcastReceiver? = null + private var onChanged: (() -> Unit)? = null private val _events = MutableSharedFlow(extraBufferCapacity = 8) val events: SharedFlow = _events.asSharedFlow() - var onMount: (() -> Unit)? = null - var onEject: (() -> Unit)? = null + override fun setOnChanged(listener: (() -> Unit)?) { + onChanged = listener + } override fun start() { if (receiver != null) return @@ -39,19 +41,16 @@ class AndroidUsbMountMonitor( receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { - Intent.ACTION_MEDIA_MOUNTED -> { - _events.tryEmit(UsbMountEvent.MOUNTED) - onMount?.invoke() - } - Intent.ACTION_MEDIA_EJECT -> { - _events.tryEmit(UsbMountEvent.EJECTED) - onEject?.invoke() - } + Intent.ACTION_MEDIA_MOUNTED -> _events.tryEmit(UsbMountEvent.MOUNTED) + Intent.ACTION_MEDIA_EJECT -> _events.tryEmit(UsbMountEvent.EJECTED) + else -> return } + onChanged?.invoke() } } + // 系统 MEDIA_* 广播来自其他进程,必须 EXPORTED,否则 Android 13+ 收不到。 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + appContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED) } else { @Suppress("UnspecifiedRegisterReceiverFlag") appContext.registerReceiver(receiver, filter) diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/installAndroidPlatformServices.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/installAndroidPlatformServices.kt similarity index 61% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/installAndroidPlatformServices.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/installAndroidPlatformServices.kt index 412de74..dfaa673 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/installAndroidPlatformServices.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/platform/installAndroidPlatformServices.kt @@ -1,10 +1,11 @@ -package top.zhushenwudi.llmp.native.platform +package com.zhushenwudi.lovelivemusicplayer.native.platform import android.content.Context -import top.zhushenwudi.llmp.platform.AndroidMediaSessionBridge -import top.zhushenwudi.llmp.platform.PlatformServices -import top.zhushenwudi.llmp.platform.createFileSystem -import top.zhushenwudi.llmp.native.UmengBridge +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.native.UmengBridge +import com.zhushenwudi.lovelivemusicplayer.platform.AndroidMediaSessionBridge +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices +import com.zhushenwudi.lovelivemusicplayer.platform.createFileSystem fun installAndroidPlatformServices(context: Context) { val appContext = context.applicationContext @@ -23,4 +24,5 @@ fun installAndroidPlatformServices(context: Context) { ) (PlatformServices.usbMount as? AndroidUsbMountMonitor)?.start() + AppContainer.bindUsbMountListener() } diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/AppUtils.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/AppUtils.kt similarity index 84% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/AppUtils.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/AppUtils.kt index 7dcdfc4..5473cc6 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/AppUtils.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/AppUtils.kt @@ -1,14 +1,16 @@ -package top.zhushenwudi.llmp.native.util +package com.zhushenwudi.lovelivemusicplayer.native.util import android.app.ActivityManager import android.content.Context import android.content.Intent import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import top.zhushenwudi.llmp.native.UmengBridge +import com.zhushenwudi.lovelivemusicplayer.native.UmengBridge object AppUtils { private const val PREFIX_URL = "llmp://" + const val HOME_WIDGET_SCHEME = "homeWidgetExample" + const val HOME_WIDGET_URI_PREFIX = "$HOME_WIDGET_SCHEME://" const val SAVE_FILE_NAME = "sharedImage.png" const val SP_NAME = "HomeWidgetPreferences" @@ -40,6 +42,7 @@ object AppUtils { return false } + @Suppress("DEPRECATION") private fun getRunningService(mContext: Context): ArrayList { val myManager = mContext.applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/ImageUtil.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/ImageUtil.kt similarity index 96% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/ImageUtil.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/ImageUtil.kt index d97d692..39a6a6c 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/util/ImageUtil.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/native/util/ImageUtil.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.native.util +package com.zhushenwudi.lovelivemusicplayer.native.util import android.annotation.SuppressLint import android.content.Context @@ -62,7 +62,7 @@ object ImageUtil { val circularBitmap = createBitmap(bitmap.width, bitmap.height) val paint = Paint() paint.isAntiAlias = true - paint.setShader(BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)) + paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) val canvas = Canvas(circularBitmap) canvas.drawCircle(bitmap.width / 2f, bitmap.height / 2f, bitmap.width / 2f, paint) return circularBitmap @@ -121,7 +121,7 @@ object ImageUtil { null, Shader.TileMode.CLAMP, ) - paint.setShader(gradient) + paint.shader = gradient canvas.drawRect(rect, paint) return bitmap } diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/player/PlaybackService.kt b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt similarity index 86% rename from androidApp/src/main/kotlin/top/zhushenwudi/llmp/player/PlaybackService.kt rename to androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt index 033c942..9820465 100644 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/player/PlaybackService.kt +++ b/androidApp/src/main/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlaybackService.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player import android.app.NotificationChannel import android.app.NotificationManager @@ -20,15 +20,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import top.zhushenwudi.llmp.MainActivity -import top.zhushenwudi.llmp.R -import top.zhushenwudi.llmp.native.DesktopLyricOverlay +import com.zhushenwudi.lovelivemusicplayer.MainActivity +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.R +import com.zhushenwudi.lovelivemusicplayer.native.DesktopLyricOverlay /** - * Media3 [MediaSessionService] hosting the shared ExoPlayer + optional desktop-lyric overlay. + * Media3 [androidx.media3.session.MediaSessionService],承载共享 ExoPlayer + 可选桌面歌词叠加层。 * - * One FGS notification (Media3 media style) covers playback; floating lyrics no longer post a - * second "歌词服务" notification. + * 一个 FGS 通知(Media3 媒体样式)覆盖播放;悬浮歌词不再发第二条「歌词服务」通知。 */ class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null @@ -58,7 +58,7 @@ class PlaybackService : MediaSessionService() { mediaSession = session addSession(session) - // Satisfy startForegroundService() until Media3 posts the real media notification. + // 在 Media3 发布真实媒体通知前满足 startForegroundService()。 val notification = bootstrapNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( @@ -138,7 +138,7 @@ class PlaybackService : MediaSessionService() { private fun bootstrapNotification() = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) - .setContentText("准备播放") + .setContentText(I18n.t("ready_to_play")) .setSmallIcon(R.drawable.logo) .setContentIntent(sessionActivityIntent()) .setOngoing(true) @@ -160,8 +160,8 @@ class PlaybackService : MediaSessionService() { companion object { const val CHANNEL_ID = "llmp_playback" const val NOTIF_ID = 1001 - const val ACTION_SHOW_DESKTOP_LYRIC = "top.zhushenwudi.llmp.SHOW_DESKTOP_LYRIC" - const val ACTION_HIDE_DESKTOP_LYRIC = "top.zhushenwudi.llmp.HIDE_DESKTOP_LYRIC" + const val ACTION_SHOW_DESKTOP_LYRIC = "com.zhushenwudi.lovelivemusicplayer.SHOW_DESKTOP_LYRIC" + const val ACTION_HIDE_DESKTOP_LYRIC = "com.zhushenwudi.lovelivemusicplayer.HIDE_DESKTOP_LYRIC" @Volatile private var instance: PlaybackService? = null @@ -171,7 +171,7 @@ class PlaybackService : MediaSessionService() { ContextCompat.startForegroundService(context, intent) } - /** Show floating lyrics; reuses this service's Media3 FGS notification. */ + /** 显示悬浮歌词;复用本服务的 Media3 FGS 通知。 */ fun showDesktopLyric(context: Context) { val existing = instance if (existing != null) { @@ -184,14 +184,14 @@ class PlaybackService : MediaSessionService() { ContextCompat.startForegroundService(context, intent) } - /** Hide floating lyrics without stopping media playback / FGS. */ + /** 隐藏悬浮歌词,不停止媒体播放/FGS。 */ fun hideDesktopLyric(context: Context) { val existing = instance if (existing != null) { existing.hideDesktopLyricInternal() return } - // Service not running — nothing to hide. + // 服务未运行,无需隐藏。 } fun updateDesktopLyric(line1: String?, line2: String?, currentLine: Int) { diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/MainActivity.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/MainActivity.kt deleted file mode 100644 index 2a9f24b..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/MainActivity.kt +++ /dev/null @@ -1,205 +0,0 @@ -package top.zhushenwudi.llmp - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.provider.Settings -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.lifecycle.lifecycleScope -import java.io.File -import kotlinx.coroutines.launch -import top.zhushenwudi.llmp.db.DriverFactory -import top.zhushenwudi.llmp.db.createDatabase -import top.zhushenwudi.llmp.native.NativeEvents -import top.zhushenwudi.llmp.native.platform.installAndroidPlatformServices -import top.zhushenwudi.llmp.native.util.AppUtils -import top.zhushenwudi.llmp.platform.AndroidUiHost -import top.zhushenwudi.llmp.platform.PlatformServices -import top.zhushenwudi.llmp.platform.initAndroidFileSystem -import top.zhushenwudi.llmp.player.PlaybackService -import top.zhushenwudi.llmp.player.PlayerController -import top.zhushenwudi.llmp.settings.createAppSettings - -class MainActivity : ComponentActivity() { - private var player: PlayerController? = null - - private val pickImageLauncher = registerForActivityResult( - ActivityResultContracts.GetContent(), - ) { uri -> - if (uri == null) { - AndroidUiHost.imagePickHandler?.invoke(null) - return@registerForActivityResult - } - val path = runCatching { - val dir = File(filesDir, "bg") - if (!dir.exists()) dir.mkdirs() - val out = File(dir, "${System.currentTimeMillis()}.jpg") - contentResolver.openInputStream(uri)?.use { input -> - out.outputStream().use { output -> input.copyTo(output) } - } - out.absolutePath - }.getOrNull() - AndroidUiHost.imagePickHandler?.invoke(path) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - AndroidUiHost.activity = this - AndroidUiHost.launchImagePicker = { pickImageLauncher.launch("image/*") } - initAndroidFileSystem(applicationContext) - - if (!AppContainer.isInitialized) { - val settings = createAppSettings(applicationContext) - val database = createDatabase(DriverFactory(applicationContext)) - player = PlayerController(applicationContext) - AppContainer.init( - settings = settings, - database = database, - player = player!!, - ) - AppContainer.musicTransfer.setPlatform("android") - } else { - player = AppContainer.player - } - - installAndroidPlatformServices(applicationContext) - PlatformServices.desktopLyric.enabled = AppContainer.settings.enableDesktopLyric - PlatformServices.analytics.initIfAgreed( - getSharedPreferences("my_prefs", MODE_PRIVATE).getBoolean("key_agreed", false), - ) - - getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) { - putBoolean("isShutdown", false) - } - - handleIncomingIntent(intent) - - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - moveTaskToBack(true) - } - }, - ) - - requestNotifyPermission() - startPlaybackService() - - lifecycleScope.launch { - NativeEvents.widgetEvents.collect { uri -> - when { - uri.endsWith("toggle_play") -> player?.toggle() - uri.endsWith("toggle_love") -> { - val id = player?.state?.value?.current?.musicId ?: return@collect - val favorite = AppContainer.toggleLove(id) - val current = player?.state?.value?.current - PlatformServices.homeWidget.update( - songName = current?.musicName, - artist = current?.artist, - isPlaying = player?.state?.value?.isPlaying == true, - favorite = favorite, - coverPath = current?.let { - AppContainer.pathResolver.absoluteCoverPath(it) - }, - ) - } - } - } - } - - setContent { App() } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - handleIncomingIntent(intent) - } - - override fun onResume() { - super.onResume() - AndroidUiHost.activity = this - AppUtils.getSchemeData(intent) { url -> - PlatformServices.deepLink.handle(url) - } - // Foreground: hide floating lyric (PlaybackService keeps media FGS). - if (PlatformServices.desktopLyric.enabled) { - PlatformServices.desktopLyric.hide() - } - } - - override fun onPause() { - super.onPause() - val lyric = PlatformServices.desktopLyric - val playing = player?.state?.value?.isPlaying == true - if (lyric.enabled && playing) { - if (!Settings.canDrawOverlays(this)) { - // Permission missing — skip start; Settings toggle requests it. - } else { - lyric.show() - } - } - } - - override fun onDestroy() { - if (AndroidUiHost.activity === this) { - AndroidUiHost.activity = null - AndroidUiHost.launchImagePicker = null - } - if (isFinishing) { - getSharedPreferences(AppUtils.SP_NAME, MODE_PRIVATE).edit(commit = true) { - putString("curJpLrc", "") - putString("nextJpLrc", "") - putBoolean("isPlaying", false) - putBoolean("isShutdown", true) - } - PlatformServices.homeWidget.setShutdown(true) - PlatformServices.desktopLyric.hide() - } - super.onDestroy() - } - - private fun handleIncomingIntent(intent: Intent?) { - val data = intent?.data?.toString() ?: return - when { - data.startsWith("homeWidgetExample://") -> { - NativeEvents.emitWidgetEvent(data) - intent.data = null - } - data.startsWith("llmp://") || data.startsWith("llmp:") -> { - PlatformServices.deepLink.handle(data) - intent.data = null - } - } - } - - private fun requestNotifyPermission() { - if (Build.VERSION.SDK_INT >= 33) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 100, - ) - } - } - } - - private fun startPlaybackService() { - PlaybackService.start(this) - } -} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/WebViewActivity.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/WebViewActivity.kt deleted file mode 100644 index f919571..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/WebViewActivity.kt +++ /dev/null @@ -1,44 +0,0 @@ -package top.zhushenwudi.llmp.native - -import android.annotation.SuppressLint -import android.os.Bundle -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView - -class WebViewActivity : ComponentActivity() { - @SuppressLint("SetJavaScriptEnabled") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val url = intent.getStringExtra(EXTRA_URL).orEmpty() - val title = intent.getStringExtra(EXTRA_TITLE) - title?.let { setTitle(it) } - - setContent { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - webViewClient = WebViewClient() - if (url.startsWith("file:///android_asset/")) { - loadUrl(url) - } else if (url.isNotBlank()) { - loadUrl(url) - } - } - }, - ) - } - } - - companion object { - const val EXTRA_URL = "url" - const val EXTRA_TITLE = "title" - } -} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt deleted file mode 100644 index ccb52ef..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/home_widget/small_home_widget/SmallHomeWidgetContent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package top.zhushenwudi.llmp.native.home_widget.small_home_widget - -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import top.zhushenwudi.llmp.native.home_widget.HomeWidgetContent - -abstract class SmallHomeWidgetContent( - override var radius: Dp = 6.dp, -) : HomeWidgetContent() diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDesktopLyricController.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDesktopLyricController.kt deleted file mode 100644 index 7c4be9c..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidDesktopLyricController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package top.zhushenwudi.llmp.native.platform - -import android.content.Context -import android.content.Intent -import android.provider.Settings -import top.zhushenwudi.llmp.platform.DesktopLyricController -import top.zhushenwudi.llmp.player.PlaybackService -import androidx.core.net.toUri - -/** - * Desktop lyric controller — overlay is hosted by [PlaybackService] (shared Media3 FGS). - */ -class AndroidDesktopLyricController( - private val context: Context, -) : DesktopLyricController { - private val appContext = context.applicationContext - - override var enabled: Boolean = false - private var playing: Boolean = false - - override fun show() { - enabled = true - PlaybackService.showDesktopLyric(appContext) - } - - override fun hide() { - // Keep [enabled] as caller decides (settings off vs app resume). - PlaybackService.hideDesktopLyric(appContext) - } - - override fun updateLines(line1: String?, line2: String?, currentLine: Int) { - PlaybackService.updateDesktopLyric(line1, line2, currentLine) - } - - override fun setPlaying(playing: Boolean) { - this.playing = playing - } - - fun isPlaying(): Boolean = playing - - fun isOverlayShowing(): Boolean = PlaybackService.isDesktopLyricShowing() - - fun requestOverlayPermission(context: Context): Intent? { - return if (!Settings.canDrawOverlays(context)) { - Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - "package:${context.packageName}".toUri(), - ) - } else { - null - } - } -} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidHomeWidgetController.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidHomeWidgetController.kt deleted file mode 100644 index da7e7ac..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidHomeWidgetController.kt +++ /dev/null @@ -1,112 +0,0 @@ -package top.zhushenwudi.llmp.native.platform - -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import androidx.core.content.edit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import top.zhushenwudi.llmp.native.home_widget.large_home_widget.BlackLargeHomeWidgetReceiver -import top.zhushenwudi.llmp.native.home_widget.large_home_widget.WhiteLargeHomeWidgetReceiver -import top.zhushenwudi.llmp.native.home_widget.small_home_widget.BlackSmallHomeWidgetReceiver -import top.zhushenwudi.llmp.native.home_widget.small_home_widget.WhiteSmallHomeWidgetReceiver -import top.zhushenwudi.llmp.native.util.AppUtils -import top.zhushenwudi.llmp.native.util.ImageUtil -import top.zhushenwudi.llmp.platform.HomeWidgetController -import java.io.File - -class AndroidHomeWidgetController( - context: Context, -) : HomeWidgetController { - private val appContext = context.applicationContext - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var preImagePath: String? = null - - private val receiverClasses = listOf( - WhiteSmallHomeWidgetReceiver::class.java, - BlackSmallHomeWidgetReceiver::class.java, - WhiteLargeHomeWidgetReceiver::class.java, - BlackLargeHomeWidgetReceiver::class.java, - ) - - override fun update( - songName: String?, - artist: String?, - isPlaying: Boolean, - favorite: Boolean, - lyricLine1: String?, - lyricLine2: String?, - currentLine: Int, - coverPath: String?, - playText: String, - ) { - appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE).edit(commit = true) { - putString("songName", songName ?: "") - putString("songArtist", artist ?: "") - putBoolean("songFavorite", favorite) - putBoolean("isPlaying", isPlaying) - putString("playText", playText) - putString("lyricLine1", lyricLine1 ?: "") - putString("lyricLine2", lyricLine2 ?: "") - putInt("currentLine", currentLine) - } - coverPath?.let { processCover(it) } ?: refresh() - } - - override fun setShutdown(shutdown: Boolean) { - appContext.getSharedPreferences(AppUtils.SP_NAME, Context.MODE_PRIVATE).edit(commit = true) { - putBoolean("isShutdown", shutdown) - if (shutdown) { - putBoolean("isPlaying", false) - } - } - refresh() - } - - override fun refresh() { - receiverClasses.forEach { receiverClass -> - sendWidgetUpdate(receiverClass) - } - } - - private fun processCover(coverPath: String) { - if (preImagePath == coverPath) { - refresh() - return - } - if (!File(coverPath).exists()) { - refresh() - return - } - preImagePath = coverPath - scope.launch { - try { - val originBitmap = BitmapFactory.decodeFile(coverPath) - val coverBitmap = ImageUtil.squareAndCircularBitmap(originBitmap, 180) - val handledCoverPath = ImageUtil.saveBitmapToFile(appContext, coverBitmap) - ImageUtil.savePathToSp(appContext, handledCoverPath) - val color = ImageUtil.parsePhotoDomainColor(coverBitmap) - ImageUtil.saveColorToSp(appContext, color) - originBitmap.recycle() - coverBitmap.recycle() - } catch (_: Exception) { - } finally { - refresh() - } - } - } - - private fun sendWidgetUpdate(receiverClass: Class<*>) { - val intent = Intent(appContext, receiverClass).apply { - action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - val ids = AppWidgetManager.getInstance(appContext) - .getAppWidgetIds(ComponentName(appContext, receiverClass)) - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - } - appContext.sendBroadcast(intent) - } -} diff --git a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidShareBridge.kt b/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidShareBridge.kt deleted file mode 100644 index e894145..0000000 --- a/androidApp/src/main/kotlin/top/zhushenwudi/llmp/native/platform/AndroidShareBridge.kt +++ /dev/null @@ -1,35 +0,0 @@ -package top.zhushenwudi.llmp.native.platform - -import android.content.Context -import android.content.Intent -import top.zhushenwudi.llmp.platform.ShareBridge - -class AndroidShareBridge( - private val context: Context, -) : ShareBridge { - private val appContext = context.applicationContext - - override fun shareText(text: String, title: String?) { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - title?.let { putExtra(Intent.EXTRA_TITLE, it) } - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - appContext.startActivity( - Intent.createChooser(intent, title ?: "分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } - - override fun shareUrl(url: String, title: String?) { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - title?.let { putExtra(Intent.EXTRA_TITLE, it) } - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - appContext.startActivity( - Intent.createChooser(intent, title ?: "分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } -} diff --git a/androidApp/src/main/res/drawable/ic_launcher.xml b/androidApp/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index ad59b35..0000000 --- a/androidApp/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/androidApp/src/main/res/drawable/player_play_next.xml b/androidApp/src/main/res/drawable/player_play_next.xml new file mode 100644 index 0000000..7f2fc52 --- /dev/null +++ b/androidApp/src/main/res/drawable/player_play_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/player_play_prev.xml b/androidApp/src/main/res/drawable/player_play_prev.xml new file mode 100644 index 0000000..77d0e3f --- /dev/null +++ b/androidApp/src/main/res/drawable/player_play_prev.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/androidApp/src/main/res/layout/glance_default_loading_layout.xml b/androidApp/src/main/res/layout/glance_default_loading_layout.xml index b80a664..15437ad 100644 --- a/androidApp/src/main/res/layout/glance_default_loading_layout.xml +++ b/androidApp/src/main/res/layout/glance_default_loading_layout.xml @@ -1,10 +1,9 @@ - - - + + + diff --git a/androidApp/src/main/res/layout/view_lyric.xml b/androidApp/src/main/res/layout/view_lyric.xml index 1cf91b0..f3b4b7d 100644 --- a/androidApp/src/main/res/layout/view_lyric.xml +++ b/androidApp/src/main/res/layout/view_lyric.xml @@ -55,7 +55,8 @@ android:layout_alignParentStart="true" android:layout_marginTop="5dp" android:layout_marginStart="25dp" - android:background="@drawable/ic_close" /> + android:background="@drawable/ic_close" + android:contentDescription="@string/lyric_close" /> + android:background="@drawable/ic_pip" + android:contentDescription="@string/lyric_pip" /> + android:background="@drawable/logo" + android:contentDescription="@null" /> + android:background="@drawable/ic_translate" + android:contentDescription="@string/lyric_translate" /> \ No newline at end of file diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_large.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_large.png index 88cb6d8..05a5050 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_large.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_large.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_small.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_small.png index 88cb6d8..a816e1b 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_small.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_black_small.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_large.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_large.png index 88cb6d8..49cd992 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_large.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_large.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_small.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_small.png index 88cb6d8..3234c2c 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_small.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_widget_white_small.png differ diff --git a/androidApp/src/main/res/values-de/strings.xml b/androidApp/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..64a2e0f --- /dev/null +++ b/androidApp/src/main/res/values-de/strings.xml @@ -0,0 +1,8 @@ + + + LLMP KMP + Wird abgespielt + Desktop-Lyrics schließen + Bild-in-Bild + Übersetzte Lyrics + diff --git a/androidApp/src/main/res/values-en/strings.xml b/androidApp/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..4656c9d --- /dev/null +++ b/androidApp/src/main/res/values-en/strings.xml @@ -0,0 +1,8 @@ + + + LLMP KMP + Now Playing + Close desktop lyrics + Picture in Picture + Translated lyrics + diff --git a/androidApp/src/main/res/values-ja/strings.xml b/androidApp/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..a066a69 --- /dev/null +++ b/androidApp/src/main/res/values-ja/strings.xml @@ -0,0 +1,8 @@ + + + LLMP KMP + 再生中 + デスクトップ歌詞を閉じる + ピクチャインピクチャ + 翻訳歌詞 + diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index 6a82c51..4bfc9c3 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -2,4 +2,7 @@ LLMP KMP 正在播放 + 关闭桌面歌词 + 画中画 + 翻译歌词 diff --git a/androidApp/src/main/res/xml-v31/black_large_home_widget.xml b/androidApp/src/main/res/xml-v31/black_large_home_widget.xml new file mode 100644 index 0000000..58ae3a4 --- /dev/null +++ b/androidApp/src/main/res/xml-v31/black_large_home_widget.xml @@ -0,0 +1,11 @@ + + diff --git a/androidApp/src/main/res/xml-v31/black_small_home_widget.xml b/androidApp/src/main/res/xml-v31/black_small_home_widget.xml new file mode 100644 index 0000000..d8043a7 --- /dev/null +++ b/androidApp/src/main/res/xml-v31/black_small_home_widget.xml @@ -0,0 +1,11 @@ + + diff --git a/androidApp/src/main/res/xml-v31/white_large_home_widget.xml b/androidApp/src/main/res/xml-v31/white_large_home_widget.xml new file mode 100644 index 0000000..0757f25 --- /dev/null +++ b/androidApp/src/main/res/xml-v31/white_large_home_widget.xml @@ -0,0 +1,11 @@ + + diff --git a/androidApp/src/main/res/xml-v31/white_small_home_widget.xml b/androidApp/src/main/res/xml-v31/white_small_home_widget.xml new file mode 100644 index 0000000..074c84c --- /dev/null +++ b/androidApp/src/main/res/xml-v31/white_small_home_widget.xml @@ -0,0 +1,11 @@ + + diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..f3c011c --- /dev/null +++ b/androidApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 6a69ddd..deb3e82 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,3 +7,14 @@ plugins { alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.sqldelight) apply false } + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + +// expect/actual class 在 Kotlin 2.x 仍为 Beta;统一抑制 IDE/Gradle 警告(KT-61573)。 +subprojects { + tasks.withType>().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b63bd1c..168c0e8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -9,12 +9,17 @@ plugins { } kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + android { - namespace = "top.zhushenwudi.llmp.composeapp" + namespace = "com.zhushenwudi.lovelivemusicplayer.composeapp" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() compilerOptions { jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xexpect-actual-classes") } androidResources { enable = true @@ -28,6 +33,7 @@ kotlin { iosTarget.binaries.framework { baseName = "ComposeApp" isStatic = true + export(projects.shared) } } @@ -41,7 +47,7 @@ kotlin { implementation(libs.ktor.client.darwin) } commonMain.dependencies { - implementation(projects.shared) + api(projects.shared) implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) @@ -65,7 +71,7 @@ kotlin { compose { resources { - packageOfResClass = "top.zhushenwudi.llmp.generated.resources" + packageOfResClass = "com.zhushenwudi.lovelivemusicplayer.generated.resources" } } diff --git a/composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.android.kt similarity index 80% rename from composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.android.kt rename to composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.android.kt index c414279..0baf046 100644 --- a/composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.android.kt +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.android.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable diff --git a/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.android.kt new file mode 100644 index 0000000..f67f54b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.android.kt @@ -0,0 +1,12 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.image + +import coil3.PlatformContext +import okio.Path +import okio.Path.Companion.toPath +import java.io.File + +internal actual fun persistentImageCacheDirectory(context: PlatformContext): Path { + val dir = File(context.cacheDir, "llmp_image_cache") + if (!dir.exists()) dir.mkdirs() + return dir.absolutePath.toPath() +} diff --git a/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.android.kt new file mode 100644 index 0000000..e2e0799 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.android.kt @@ -0,0 +1,22 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.platform + +import android.content.ClipData +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.toClipEntry +import kotlinx.coroutines.launch + +@Composable +actual fun rememberCopyTextHandler(): (String) -> Unit { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + return { text -> + scope.launch { + clipboard.setClipEntry( + ClipData.newPlainText("text", text).toClipEntry(), + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.android.kt b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.android.kt new file mode 100644 index 0000000..082ff84 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.android.kt @@ -0,0 +1,11 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.scroll + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable + +@Composable +actual fun ScrollToTopHandler(listState: LazyListState) = Unit + +@Composable +actual fun ScrollToTopHandler(gridState: LazyGridState) = Unit diff --git a/composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.android.kt b/composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.android.kt deleted file mode 100644 index 4ddb283..0000000 --- a/composeApp/src/androidMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.android.kt +++ /dev/null @@ -1,29 +0,0 @@ -package top.zhushenwudi.llmp.ui.player - -import android.graphics.BitmapFactory -import androidx.compose.ui.graphics.Color -import androidx.palette.graphics.Palette -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Flutter color_thief getColorFromImage → Color.fromARGB(150, r, g, b). - * Android Palette dominant is closest available substitute. - */ -actual suspend fun extractDominantColor(coverPath: String?): Color? = - withContext(Dispatchers.Default) { - if (coverPath.isNullOrBlank()) return@withContext null - val file = File(coverPath) - if (!file.isFile) return@withContext null - val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return@withContext null - try { - val dominant = Palette.from(bitmap).generate().getDominantColor(0xFFFFAE00.toInt()) - val r = android.graphics.Color.red(dominant) - val g = android.graphics.Color.green(dominant) - val b = android.graphics.Color.blue(dominant) - Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = FlutterPaletteAlpha) - } finally { - bitmap.recycle() - } - } diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list.svg index 45aba01..05f3148 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list2.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list2.svg index aa515fc..86c3e2c 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list2.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_play_list2.svg @@ -1,14 +1,25 @@ - - + + - + + stroke-linecap="round" /> + - - + d="m18.85167 16.2v5.25" + stroke-linecap="round" /> + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_song_sheet.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_song_sheet.svg index bf83753..e74472a 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_song_sheet.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_add_song_sheet.svg @@ -1,5 +1,11 @@ - - - + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete.svg index d636779..23130cd 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete_2.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete_2.svg index 186ed47..f40fa77 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete_2.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_delete_2.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_edit.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_edit.svg index d2ebc8f..c393857 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_edit.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_edit.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_new_song_list.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_new_song_list.svg index ba3be13..2079191 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_new_song_list.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_new_song_list.svg @@ -1,8 +1,20 @@ - - - - + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_see_album.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_see_album.svg index 4827721..48c4947 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_see_album.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_see_album.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_song_info.svg b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_song_info.svg index 74eed43..fce4a40 100644 --- a/composeApp/src/commonMain/composeResources/drawable/dialog_ic_song_info.svg +++ b/composeApp/src/commonMain/composeResources/drawable/dialog_ic_song_info.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_ai_pic.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_ai_pic.svg index 1e32195..42b3066 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_ai_pic.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_ai_pic.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_background.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_background.svg index 84557ba..0af3af5 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_background.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_background.svg @@ -1,6 +1,11 @@ - - + + + fill="rgba(102,102,102,1)" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_calendar.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_calendar.svg index 47ee4e8..9362090 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_calendar.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_calendar.svg @@ -1,4 +1,7 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_colorful.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_colorful.svg index 5a672ed..3732018 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_colorful.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_colorful.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_download.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_download.svg index d7fa12d..48c7480 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_download.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_download.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_sync.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_sync.svg index f4f139b..9d1733a 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_sync.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_data_sync.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_day_night.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_day_night.svg index 51305f3..e8469b0 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_day_night.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_day_night.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_debug.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_debug.svg index 23fcab5..6f1dbb3 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_debug.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_debug.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_desktop_lyric.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_desktop_lyric.svg index df6dac3..868622d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_desktop_lyric.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_desktop_lyric.svg @@ -1,5 +1,7 @@ - - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_http.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_http.svg index 72cf0a1..6edff2d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_http.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_http.svg @@ -1,5 +1,8 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_inspect.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_inspect.svg index 6bfb3a7..c3050ca 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_inspect.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_inspect.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_quick_trans.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_quick_trans.svg index c01e733..fbbf6ca 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_quick_trans.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_quick_trans.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_reset.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_reset.svg index 2ad9394..05a6946 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_reset.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_reset.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_sd.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_sd.svg index 14a13ab..62c1933 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_sd.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_sd.svg @@ -1,5 +1,7 @@ - - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_secret.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_secret.svg index 74eed43..fce4a40 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_secret.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_secret.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_setting.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_setting.svg index 86c0ea9..843c745 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_setting.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_setting.svg @@ -1,6 +1,11 @@ - - + + + fill="rgba(102,102,102,1)" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_share.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_share.svg index 806fdf4..0bda64d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_share.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_share.svg @@ -1,6 +1,11 @@ - - + + + fill="rgba(102,102,102,1)" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_system_theme.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_system_theme.svg index 6725d74..89f2926 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_system_theme.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_system_theme.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_timer.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_timer.svg index 866c518..7b71afd 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_timer.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_timer.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_update.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_update.svg index 3056c0a..74a63ea 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_update.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_drawer_update.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_allstars.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_allstars.svg index 4f507b9..51a858d 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_allstars.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_allstars.svg @@ -1,4 +1,7 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_aqours.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_aqours.svg index a88b60c..369b493 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_aqours.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_aqours.svg @@ -1,4 +1,7 @@ - + @@ -23,7 +26,11 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_bluebird.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_bluebird.svg index d4f8916..dc65fd6 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_bluebird.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_bluebird.svg @@ -1,24 +1,44 @@ - - + - - - - - - - - - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_hasunosora.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_hasunosora.svg index fac73e9..324d5ae 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_hasunosora.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_hasunosora.svg @@ -1,4 +1,7 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_liella.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_liella.svg index 94bc861..d0ab920 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_liella.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_liella.svg @@ -1,9 +1,20 @@ - - + + - + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_lovelive.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_lovelive.svg index 15b2bb1..ffd6d28 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_lovelive.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_lovelive.svg @@ -1,5 +1,9 @@ - - + - - - + + - - - + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_musical.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_musical.svg index 097a405..556784e 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_musical.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_musical.svg @@ -1,4 +1,5 @@ - + @@ -50,9 +51,29 @@ - - - - - + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_nijigasaki.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_nijigasaki.svg index a507d20..d05b844 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_nijigasaki.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_nijigasaki.svg @@ -1,4 +1,7 @@ - + @@ -38,7 +41,9 @@ - + @@ -57,12 +62,16 @@ - - - - \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_us.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_us.svg index 2d8a3d2..bbd9b6b 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_us.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_us.svg @@ -1,4 +1,7 @@ - + @@ -8,5 +11,9 @@ - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_yohane_day.svg b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_yohane_day.svg index 0c2b9cf..01cce40 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drawer_logo_yohane_day.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drawer_logo_yohane_day.svg @@ -1,4 +1,5 @@ - + + - + 编组 4 - - - - - - - + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_exit.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_exit.svg index af5409d..4971662 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_exit.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_exit.svg @@ -1,11 +1,30 @@ - + 编组 - - - - - + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_favoriate.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_favoriate.svg index c3cb5c3..f4eee07 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_favoriate.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_favoriate.svg @@ -1,13 +1,45 @@ - + 编组 4 - - - - - - - + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_favorite_bottom.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_favorite_bottom.svg index 35dd4cb..62861af 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_favorite_bottom.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_favorite_bottom.svg @@ -1,13 +1,38 @@ - + 编组 2 - - - - - - - + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_pause_button.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_pause_button.svg index c6170e2..ed50daa 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_pause_button.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_pause_button.svg @@ -1,5 +1,11 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_play_button.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_play_button.svg index f1ca3cc..fe847f3 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_play_button.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_play_button.svg @@ -1,7 +1,27 @@ - - - - + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/drive_car_playlist_bottom.svg b/composeApp/src/commonMain/composeResources/drawable/drive_car_playlist_bottom.svg index 1b60b9a..cd4cbc7 100644 --- a/composeApp/src/commonMain/composeResources/drawable/drive_car_playlist_bottom.svg +++ b/composeApp/src/commonMain/composeResources/drawable/drive_car_playlist_bottom.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/logo_svg_logo.svg b/composeApp/src/commonMain/composeResources/drawable/logo_svg_logo.svg index cf74cb8..6cc3571 100644 --- a/composeApp/src/commonMain/composeResources/drawable/logo_svg_logo.svg +++ b/composeApp/src/commonMain/composeResources/drawable/logo_svg_logo.svg @@ -1,9 +1,12 @@ - + - - + - diff --git a/composeApp/src/commonMain/composeResources/drawable/main_ic_draggable.svg b/composeApp/src/commonMain/composeResources/drawable/main_ic_draggable.svg index e402ac7..15e3972 100644 --- a/composeApp/src/commonMain/composeResources/drawable/main_ic_draggable.svg +++ b/composeApp/src/commonMain/composeResources/drawable/main_ic_draggable.svg @@ -1,5 +1,7 @@ - - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/main_ic_function.svg b/composeApp/src/commonMain/composeResources/drawable/main_ic_function.svg index 559e435..f9605a8 100644 --- a/composeApp/src/commonMain/composeResources/drawable/main_ic_function.svg +++ b/composeApp/src/commonMain/composeResources/drawable/main_ic_function.svg @@ -1,5 +1,8 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/main_ic_more.svg b/composeApp/src/commonMain/composeResources/drawable/main_ic_more.svg index c06e728..5a4b145 100644 --- a/composeApp/src/commonMain/composeResources/drawable/main_ic_more.svg +++ b/composeApp/src/commonMain/composeResources/drawable/main_ic_more.svg @@ -1,7 +1,21 @@ - - - - - + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/main_ic_search.svg b/composeApp/src/commonMain/composeResources/drawable/main_ic_search.svg index abd4677..a5a16d0 100644 --- a/composeApp/src/commonMain/composeResources/drawable/main_ic_search.svg +++ b/composeApp/src/commonMain/composeResources/drawable/main_ic_search.svg @@ -1,4 +1,7 @@ - - \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/main_ic_sort_desc.svg b/composeApp/src/commonMain/composeResources/drawable/main_ic_sort_desc.svg index 48ac014..1ab8d98 100644 --- a/composeApp/src/commonMain/composeResources/drawable/main_ic_sort_desc.svg +++ b/composeApp/src/commonMain/composeResources/drawable/main_ic_sort_desc.svg @@ -1,5 +1,9 @@ - - \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_jp.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_jp.svg index db2a93f..42adfe3 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_jp.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_jp.svg @@ -1,6 +1,15 @@ - - + - 原 + 原 + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_love.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_love.svg index e72a394..a046fc4 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_love.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_love.svg @@ -1,8 +1,19 @@ - - - + + + + stroke="#666" + stroke-linecap="round" + stroke-linejoin="round" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_next.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_next.svg index 3247517..6a260d7 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_next.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_next.svg @@ -1,4 +1,6 @@ - - + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_pause.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_pause.svg index d22cdc0..87d2827 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_pause.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_pause.svg @@ -1,7 +1,20 @@ - - - - + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_play.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_play.svg index 508017f..e562dcd 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_play.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_play.svg @@ -1,8 +1,15 @@ - - + + + fill="#000" + fill-rule="nonzero" + transform="translate(5 2)" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_playlist.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_playlist.svg index 5da79cc..8e797da 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_playlist.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_playlist.svg @@ -1,9 +1,18 @@ - - + - + d="m11.5 0v14.5" + stroke-linecap="round" /> + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_prev.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_prev.svg index df07968..b26f355 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_prev.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_prev.svg @@ -1,4 +1,7 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_recycle.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_recycle.svg index db8a310..efa3301 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_recycle.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_recycle.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_roma.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_roma.svg index a4a1122..6bcaa96 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_roma.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_roma.svg @@ -1,6 +1,15 @@ - - + - 音 + 音 + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_shuffle.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_shuffle.svg index e6e777f..fbfe757 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_shuffle.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_shuffle.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_single.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_single.svg index 75e9c5a..bd889fc 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_single.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_single.svg @@ -1,5 +1,10 @@ - - + + diff --git a/composeApp/src/commonMain/composeResources/drawable/player_play_zh.svg b/composeApp/src/commonMain/composeResources/drawable/player_play_zh.svg index 8f94e3e..f29ffb2 100644 --- a/composeApp/src/commonMain/composeResources/drawable/player_play_zh.svg +++ b/composeApp/src/commonMain/composeResources/drawable/player_play_zh.svg @@ -1,6 +1,15 @@ - - + - 译 + 译 + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/sync_icon_computer.svg b/composeApp/src/commonMain/composeResources/drawable/sync_icon_computer.svg index b2a1393..d513854 100644 --- a/composeApp/src/commonMain/composeResources/drawable/sync_icon_computer.svg +++ b/composeApp/src/commonMain/composeResources/drawable/sync_icon_computer.svg @@ -1,6 +1,10 @@ - + + fill="#F940A7" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/sync_icon_data_sync.svg b/composeApp/src/commonMain/composeResources/drawable/sync_icon_data_sync.svg index e5a102f..e0b875b 100644 --- a/composeApp/src/commonMain/composeResources/drawable/sync_icon_data_sync.svg +++ b/composeApp/src/commonMain/composeResources/drawable/sync_icon_data_sync.svg @@ -1,51 +1,61 @@ - - - + + + + fill="#EDF4FF" /> + fill="#D8E3F0" /> - + fill="#96DDFF" /> + - - + fill="#EDF4FF" /> + + + fill="#96DDFF" /> + fill="#69BAF9" /> + fill="#FFC670" /> + fill="#3D3D63" /> + fill="#3D3D63" /> + fill="#3D3D63" /> + fill="#3D3D63" /> + fill="#3D3D63" /> + fill="#3D3D63" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/sync_icon_phone.svg b/composeApp/src/commonMain/composeResources/drawable/sync_icon_phone.svg index 604d0be..8e46600 100644 --- a/composeApp/src/commonMain/composeResources/drawable/sync_icon_phone.svg +++ b/composeApp/src/commonMain/composeResources/drawable/sync_icon_phone.svg @@ -1,5 +1,9 @@ - + diff --git a/composeApp/src/commonMain/composeResources/drawable/sync_icon_scan_qr.svg b/composeApp/src/commonMain/composeResources/drawable/sync_icon_scan_qr.svg index 4852af2..38f5b6c 100644 --- a/composeApp/src/commonMain/composeResources/drawable/sync_icon_scan_qr.svg +++ b/composeApp/src/commonMain/composeResources/drawable/sync_icon_scan_qr.svg @@ -1,7 +1,13 @@ - + - + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_album.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_album.svg index 0f01b87..c4c69ee 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_album.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_album.svg @@ -1,5 +1,9 @@ - + + fill="#d1e0f3" + fill-rule="evenodd" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_love.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_love.svg index 7266dc6..ddb75ea 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_love.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_love.svg @@ -1,19 +1,50 @@ - - - - - - - + + + + - - - + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_music.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_music.svg index 11e1438..11598f1 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_music.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_music.svg @@ -1,19 +1,50 @@ - - - - - - - + + + + - - - + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_playlist.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_playlist.svg index 2e78400..8f08106 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_playlist.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_playlist.svg @@ -1,5 +1,9 @@ - + + fill="#d1e0f3" + fill-rule="evenodd" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_recently.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_recently.svg index cbb8766..f976d75 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_recently.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_recently.svg @@ -1,5 +1,9 @@ - + + fill="#d1e0f3" + transform="translate(-301 -10)" /> \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/tab_tab_singer.svg b/composeApp/src/commonMain/composeResources/drawable/tab_tab_singer.svg index 52c5039..ea8a1e6 100644 --- a/composeApp/src/commonMain/composeResources/drawable/tab_tab_singer.svg +++ b/composeApp/src/commonMain/composeResources/drawable/tab_tab_singer.svg @@ -1,4 +1,7 @@ - + diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt new file mode 100644 index 0000000..fe79864 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/App.kt @@ -0,0 +1,286 @@ +package com.zhushenwudi.lovelivemusicplayer + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Modifier +import androidx.compose.ui.zIndex +import kotlinx.coroutines.delay +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.runtime.collectAsState +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.zhushenwudi.lovelivemusicplayer.navigation.LlmpTransitionMs +import com.zhushenwudi.lovelivemusicplayer.navigation.Routes +import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler +import com.zhushenwudi.lovelivemusicplayer.navigation.llmpSlideFadeEnter +import com.zhushenwudi.lovelivemusicplayer.navigation.llmpSlideFadeExit +import com.zhushenwudi.lovelivemusicplayer.navigation.llmpSlideFadePopEnter +import com.zhushenwudi.lovelivemusicplayer.navigation.llmpSlideFadePopExit +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LlmpTheme +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.MainShell +import com.zhushenwudi.lovelivemusicplayer.ui.icons.InstallCoilSvg +import com.zhushenwudi.lovelivemusicplayer.ui.screens.DailyScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.DataSyncScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.DriveModeScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.LogsScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.MoegirlScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.PermissionsScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.ScanScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.SdScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.SettingsScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.SplashScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.TachieScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.TransferScreen +import com.zhushenwudi.lovelivemusicplayer.i18n.SyncI18nWithSystemLocale +import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity + +@Composable +fun App() { + val settings = AppContainer.settings + var followSystem by remember { mutableStateOf(settings.followSystemTheme) } + var darkPref by remember { mutableStateOf(settings.isDark) } + val systemDark = isSystemInDarkTheme() + val darkTheme = if (followSystem) systemDark else darkPref + // 有 AI 启动图则先进入启动页,否则进入主页 + val startDestination = if (settings.aiPicture) Routes.SPLASH else Routes.MAIN + + SyncI18nWithSystemLocale() + ObserveDesktopLyricLifecycle() + LlmpTheme(darkTheme = darkTheme) { + InstallCoilSvg() + // 按设计稿 375×812 缩放 UI 密度,使列表/顶栏/迷你播放器尺寸一致。 + // 底部栏在 MainShell 中通过 ProvideUnscaledDensity 保持未缩放。 + ProvideDesignDensity { + val navController = rememberNavController() + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + val transferState by AppContainer.musicTransfer.state.collectAsState() + val dark = LocalDarkTheme.current + val navBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary + val showMainShell = currentRoute != null && currentRoute != Routes.SPLASH + val overlayOpen = currentRoute != null && + currentRoute != Routes.MAIN && + currentRoute != Routes.SPLASH && + currentRoute != Routes.PLAYER + // 返回动画结束前保持嵌套页在上层,避免主页提前盖住滑出过程。 + var shellInFront by remember { mutableStateOf(!overlayOpen) } + LaunchedEffect(overlayOpen) { + if (overlayOpen) { + shellInFront = false + } else { + delay(LlmpTransitionMs.toLong()) + shellInFront = true + } + } + Box( + Modifier + .fillMaxSize() + .background(navBg), + ) { + // 主页常驻。抽屉嵌套页返回时只滑走上层面,不拆 6 页曲库 + WeSlide。 + if (showMainShell) { + Box( + Modifier + .fillMaxSize() + .zIndex(if (shellInFront) 1f else 0f), + ) { + MainShell( + isTopDestination = currentRoute == Routes.MAIN, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onOpenRoute = { route -> + if (route == Routes.TRANSFER) { + AppContainer.musicTransfer.resetForOpen() + } + navController.navigate(route) + }, + ) + } + } + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier + .fillMaxSize() + .zIndex(if (shellInFront) 0f else 1f), + enterTransition = { llmpSlideFadeEnter() }, + exitTransition = { llmpSlideFadeExit() }, + popEnterTransition = { llmpSlideFadePopEnter() }, + popExitTransition = { llmpSlideFadePopExit() }, + ) { + composable( + Routes.SPLASH, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + SplashScreen( + onFinished = { + navController.navigate(Routes.MAIN) { + popUpTo(Routes.SPLASH) { inclusive = true } + launchSingleTop = true + } + }, + ) + } + composable( + Routes.MAIN, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + // 返回栈占位。主页在下层绘制,这里不能铺不透明层,否则会挡住点击。 + } + composable( + Routes.PLAYER, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }, + ) { + LaunchedEffect(Unit) { + val popped = + navController.popBackStack(Routes.MAIN, inclusive = false) + if (!popped) { + navController.navigate(Routes.MAIN) { + launchSingleTop = true + } + } + AppContainer.weSlide.show() + } + } + composable(Routes.SETTINGS) { + SettingsScreen( + darkTheme = darkPref, + followSystem = followSystem, + onFollowSystemChange = { + followSystem = it + settings.followSystemTheme = it + }, + onDarkThemeChange = { + darkPref = it + settings.isDark = it + followSystem = false + settings.followSystemTheme = false + }, + onBack = { navController.popBackStack() }, + onOpenSd = { navController.navigate(Routes.SD) }, + onOpenLogs = { navController.navigate(Routes.LOGS) }, + onOpenPermissions = { navController.navigate(Routes.PERMISSIONS) }, + ) + } + composable(Routes.SCAN) { + val previous = navController.previousBackStackEntry?.destination?.route + ScanScreen( + onBack = { navController.popBackStack() }, + onConnectedHost = { host -> + when (previous) { + Routes.DATA_SYNC -> AppContainer.dataSync.connect(host) + else -> AppContainer.musicTransfer.connect(host) + } + navController.popBackStack() + }, + ) + } + composable(Routes.TRANSFER) { + TransferScreen( + onBack = { navController.popBackStack() }, + onOpenScan = { navController.navigate(Routes.SCAN) }, + ) + } + composable(Routes.DATA_SYNC) { + DataSyncScreen( + onBack = { navController.popBackStack() }, + onOpenScan = { navController.navigate(Routes.SCAN) }, + ) + } + composable(Routes.DRIVE_MODE) { + DriveModeScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.SD) { + SdScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.PERMISSIONS) { + PermissionsScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.MOEGIRL) { + MoegirlScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.TACHIE) { + TachieScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.DAILY) { + DailyScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.LOGS) { + LogsScreen(onBack = { navController.popBackStack() }) + } + } + // 盖过 NavHost 的预测性返回:手势返回改成立即 pop,避免转场卡在中间态。 + InterceptNestedNavBack( + navController = navController, + currentRoute = currentRoute, + transferConnected = transferState.connected, + ) + } + } + } +} + +/** + * 嵌套页(设置/传歌/SD…)用手势返回时,不要走 NavHost 的预测性 pop。 + * 预测性转场被取消或中途交接后,返回栈会停在半动画,后面的嵌套页点不开、也滑不回去。 + */ +@Composable +private fun InterceptNestedNavBack( + navController: NavHostController, + currentRoute: String?, + transferConnected: Boolean, +) { + val intercept = currentRoute != null && + currentRoute != Routes.MAIN && + currentRoute != Routes.SPLASH && + currentRoute != Routes.PLAYER && + !(currentRoute == Routes.TRANSFER && transferConnected) + DetailBackHandler(enabled = intercept) { + navController.popBackStack() + } +} + +/** 前后台切换时同步桌面歌词显隐。 */ +@Composable +private fun ObserveDesktopLyricLifecycle() { + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> AppContainer.setAppInBackground(true) + Lifecycle.Event.ON_RESUME -> AppContainer.setAppInBackground(false) + else -> Unit + } + } + owner.lifecycle.addObserver(observer) + if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + AppContainer.setAppInBackground(true) + } + onDispose { owner.lifecycle.removeObserver(observer) } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt new file mode 100644 index 0000000..0091e1f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/AppContainer.kt @@ -0,0 +1,771 @@ +package com.zhushenwudi.lovelivemusicplayer + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import com.zhushenwudi.lovelivemusicplayer.AppContainer.scope +import com.zhushenwudi.lovelivemusicplayer.data.DataImportService +import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.db.LlmpDatabase +import com.zhushenwudi.lovelivemusicplayer.db.MusicRepository +import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey +import com.zhushenwudi.lovelivemusicplayer.domain.Lyric +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric +import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser +import com.zhushenwudi.lovelivemusicplayer.lyric.LyricService +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.network.WsClient +import com.zhushenwudi.lovelivemusicplayer.network.createHttpClient +import com.zhushenwudi.lovelivemusicplayer.platform.CarPlayCatalog +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.platform.createFileSystem +import com.zhushenwudi.lovelivemusicplayer.player.PlayerController +import com.zhushenwudi.lovelivemusicplayer.player.SleepTimer +import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings +import com.zhushenwudi.lovelivemusicplayer.settings.withSortOrder +import com.zhushenwudi.lovelivemusicplayer.splash.SplashPhotoService +import com.zhushenwudi.lovelivemusicplayer.transfer.DataSyncService +import com.zhushenwudi.lovelivemusicplayer.transfer.MusicTransferService +import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlideController +import com.zhushenwudi.lovelivemusicplayer.update.UpdateChecker +import com.zhushenwudi.lovelivemusicplayer.util.PathResolver +import com.zhushenwudi.lovelivemusicplayer.widget.WidgetCoverCache + +object AppContainer { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var lyricJob: Job? = null + private var desktopLyricJob: Job? = null + private var desktopPlayingJob: Job? = null + private var homeWidgetJob: Job? = null + private var playQueueJob: Job? = null + private var historyJob: Job? = null + private var carPlayNowPlayingJob: Job? = null + private val _playingLyric = MutableStateFlow(LoadedLyric()) + private val _historyRevision = MutableStateFlow(0L) + private val _loveRevision = MutableStateFlow(0L) + private val _usbRevision = MutableStateFlow(0L) + private val _libraryRevision = MutableStateFlow(0L) + + /** 当前曲目完整歌词,切歌时刷新。 */ + val playingLyric: StateFlow = _playingLyric.asStateFlow() + + /** 写入播放历史时递增,用于刷新最近播放列表。 */ + val historyRevision: StateFlow = _historyRevision.asStateFlow() + + /** 收藏状态变化时递增,用于刷新收藏列表。 */ + val loveRevision: StateFlow = _loveRevision.asStateFlow() + + /** USB 插拔后递增,设置/存储页刷新卷列表。 */ + val usbRevision: StateFlow = _usbRevision.asStateFlow() + + /** 切存储清库后递增,刷新曲库 UI。 */ + val libraryRevision: StateFlow = _libraryRevision.asStateFlow() + + /** 主页 WeSlide 面板控制器。 */ + val weSlide = WeSlideController() + val weSlideFooter = WeSlideController(initial = true) + + /** 当前团组,MainShell 重组后仍保留。 */ + var currentGroup: GroupKey = GroupKey.GROUP_ALL + + lateinit var settings: AppSettings + private set + lateinit var database: LlmpDatabase + private set + lateinit var musicRepository: MusicRepository + private set + lateinit var library: LibraryRepository + private set + lateinit var player: PlayerController + private set + lateinit var fileSystem: FileSystem + private set + lateinit var pathResolver: PathResolver + private set + lateinit var ossApi: OssApi + private set + lateinit var dataImport: DataImportService + private set + lateinit var lyricService: LyricService + private set + lateinit var musicTransfer: MusicTransferService + private set + lateinit var dataSync: DataSyncService + private set + lateinit var updateChecker: UpdateChecker + private set + lateinit var sleepTimer: SleepTimer + private set + lateinit var splashPhoto: SplashPhotoService + private set + private lateinit var widgetCoverCache: WidgetCoverCache + private var desktopLyricMusicId: String? = null + private var desktopLyricSnapshot: LoadedLyric? = null + private var latestCurIndex: Int = LYRIC_INDEX_UNSET + private var originLine1Text: String? = null + private var originLine2Text: String? = null + /** 进入后台才允许弹出桌面歌词;前台一律隐藏。 */ + private var appInBackground: Boolean = false + + val platform get() = PlatformServices + val carPlayCatalog: CarPlayCatalog get() = PlatformServices.carPlayCatalog + + val isInitialized: Boolean + get() = ::settings.isInitialized + + fun init( + settings: AppSettings, + database: LlmpDatabase, + player: PlayerController, + fileSystem: FileSystem = createFileSystem(), + ) { + this.settings = settings + this.database = database + this.musicRepository = MusicRepository(database) + this.library = LibraryRepository(database) + this.player = player + this.fileSystem = fileSystem + this.pathResolver = PathResolver(fileSystem, settings) + // musicPath 保持相对路径;仅播放引擎解析为 file/http URI。 + player.resolvePlaybackUri = { pathResolver.playbackUri(it) } + // Android 通知封面用 file:// / https;iOS Now Playing 优先绝对路径。 + player.resolveCoverUri = { music -> + pathResolver.absoluteCoverPath(music) + ?.takeIf { fileSystem.exists(it) } + ?: pathResolver.coverDisplayUri(music) + } + + val http = createHttpClient() + this.ossApi = OssApi(http) + this.widgetCoverCache = WidgetCoverCache( + fileSystem = fileSystem, + pathResolver = pathResolver, + ossApi = ossApi, + scope = scope, + onCoverReady = { notifyWidgetCoverReady() }, + ) + this.dataImport = DataImportService(ossApi, library, settings, pathResolver) + this.lyricService = LyricService(ossApi, library) + bindLyricLoader() + bindDesktopLyric() + bindHomeWidget() + this.musicTransfer = MusicTransferService( + WsClient(http), + ossApi, + library, + fileSystem, + pathResolver, + scope, + onImported = { + _libraryRevision.value++ + refreshCarPlayCatalog() + }, + ) + this.dataSync = DataSyncService(WsClient(http), library, scope) + this.updateChecker = UpdateChecker(ossApi) + this.sleepTimer = SleepTimer(scope) { + player.pause() + } + this.splashPhoto = SplashPhotoService(fileSystem, ossApi, scope) + + // 绑定 CarPlay 播放到播放器;从曲库快照刷新。 + PlatformServices.carPlayCatalog.onPlay = { musicId, queue -> + val items = queue.mapNotNull { library.getMusicById(it.musicId) } + val idx = items.indexOfFirst { it.musicId == musicId }.coerceAtLeast(0) + if (items.isNotEmpty()) { + player.setQueue(items, idx) + player.play() + persistPlayQueue() + } + } + refreshCarPlayCatalog() + PlatformServices.desktopLyric.pipAutoOpen(settings.enableDesktopLyric) + PlatformServices.carPlay.bind(PlatformServices.carPlayCatalog) + bindCarPlayNowPlaying() + + // 启动时恢复播放队列(不自动播放) + restoreSavedPlayQueue(needPlay = false) + bindPlayQueuePersistence() + bindHistoryOnTrackChange() + initSdStorage() + bindUsbMountListener() + } + + /** + * 对齐 Flutter `SDUtils.init`: + * Android 空 `SP_SD_PATH` 时写入默认外部 files 目录;iOS 不持久化。 + */ + fun initSdStorage() { + if (!isInitialized) return + if (!PlatformInfo.isIos && settings.sdPath.isBlank()) { + settings.sdPath = fileSystem.musicRoot + } + ensureStorageDirs() + } + + fun ensureStorageDirs() { + if (!isInitialized) return + val root = pathResolver.storageRoot() + fileSystem.ensureDir(root + "bg/") + fileSystem.ensureDir(root + "splash/") + } + + /** 对齐 Flutter `SDUtils.setUsbMountListener`。Android 在平台服务安装后再绑一次。 */ + fun bindUsbMountListener() { + if (!isInitialized || PlatformInfo.isIos) return + PlatformServices.usbMount.setOnChanged { onUsbStorageChanged() } + PlatformServices.usbMount.start() + } + + private fun onUsbStorageChanged() { + scope.launch { + delay(1_000) + val pathList = fileSystem.listUsbRoots() + if (pathList.isEmpty()) return@launch + val defPath = settings.sdPath + val stillMounted = pathList.any { defPath.contains(it) } + if (!stillMounted) { + clearAllMusicThroughUsb() + settings.sdPath = "" + initSdStorage() + } + _usbRevision.value++ + } + } + + /** + * 对齐 Flutter `DBLogic.clearAllMusicThroughUsb`: + * 切存储 / 当前卷被拔出时清空曲库与用户列表。 + */ + fun clearAllMusicThroughUsb() { + if (!isInitialized) return + settings.dataVersion = "" + library.clearMusic() + library.clearAlbums() + library.clearArtists() + library.clearMenus() + library.clearLove() + library.clearHistory() + clearPlayQueue() + refreshCarPlayCatalog() + _libraryRevision.value++ + } + + fun storageDisplayName(path: String): String { + val key = path.split("/").getOrNull(2) ?: path.substringAfterLast('/').ifBlank { path } + return if (key == "emulated") I18n.t("internal_storage") else key + } + + fun listStorageDevices(): List { + val paths = fileSystem.listUsbRoots() + val defPath = settings.sdPath + return paths.map { path -> + SdStorageDevice( + name = storageDisplayName(path), + path = path, + chosen = defPath.contains(path), + ) + } + } + + fun selectStorageDevice(item: SdStorageDevice): Boolean { + if (!fileSystem.exists(item.path)) { + PlatformUi.showToast(I18n.t("storage_not_found")) + _usbRevision.value++ + return false + } + clearAllMusicThroughUsb() + settings.sdPath = item.path.trimEnd('/', '\\') + "/" + ensureStorageDirs() + _usbRevision.value++ + return true + } + + /** 持久化播放队列(仅非空时写入)。 */ + fun persistPlayQueue( + queue: List = player.state.value.queue, + playingId: String? = player.state.value.current?.musicId, + ) { + if (!isInitialized) return + if (queue.isEmpty()) return + library.savePlayList(queue, playingId) + } + + /** 从播放队列移除指定曲目并更新数据库。 */ + fun removeFromPlayQueue(index: Int) { + player.removeAt(index) + val queue = player.state.value.queue + if (queue.isEmpty()) library.clearPlayList() else persistPlayQueue() + } + + /** 清空播放队列。 */ + fun clearPlayQueue() { + player.clearQueue() + library.clearPlayList() + _playingLyric.value = LoadedLyric() + } + + /** + * 从数据库恢复播放队列,默认不自动播放。 + */ + fun restoreSavedPlayQueue(needPlay: Boolean = false) { + if (!isInitialized) return + val rows = library.loadPlayListRows() + if (rows.isEmpty()) return + val ids = rows.map { it.musicId } + val playingId = rows.firstOrNull { it.isPlaying != 0L }?.musicId + val musics = library.getMusicByIdsPreservingOrder(ids, settings.enableHttp) + if (musics.isEmpty()) return + val index = playingId + ?.let { pid -> musics.indexOfFirst { it.musicId == pid } } + ?.takeIf { it >= 0 } + ?: 0 + player.setQueue(musics, index) + player.setLoopMode(settings.loopMode) + if (needPlay) player.play() + } + + /** 队列或当前曲目变化时持久化。 */ + private fun bindPlayQueuePersistence() { + playQueueJob?.cancel() + playQueueJob = scope.launch { + player.state + .map { st -> st.queue.map { it.musicId } to st.current?.musicId } + .distinctUntilChanged() + .collect { + persistPlayQueue() + } + } + } + + /** + * 写入播放历史并通知最近播放 UI 刷新。 + * 切歌或重播同一曲目(id 不变)时调用。 + */ + fun recordPlayingHistory(musicId: String?) { + if (!isInitialized || musicId.isNullOrBlank()) return + library.addHistory(musicId) + _historyRevision.value = _historyRevision.value + 1 + } + + /** + * 切换收藏状态并刷新收藏列表观察者。 + * @return 新的收藏状态 + */ + fun toggleLove(musicId: String?): Boolean { + if (!isInitialized || musicId.isNullOrBlank()) return false + val loved = library.toggleLove(musicId) + player.syncLoveState(musicId, loved) + _loveRevision.value = _loveRevision.value + 1 + refreshCarPlayCatalog() + return loved + } + + /** + * 批量设置收藏。`add=true` 加入我喜欢,`false` 取消。 + * 对齐 Flutter [PlayerLogic.toggleLoveList]。 + */ + fun toggleLoveList(musicList: List, add: Boolean) { + if (!isInitialized) return + musicList.mapNotNull { it.musicId }.distinct().forEach { id -> + val loved = library.getMusicById(id)?.isLove == true + if (add != loved) { + val nowLoved = library.toggleLove(id) + player.syncLoveState(id, nowLoved) + } + } + _loveRevision.value = _loveRevision.value + 1 + refreshCarPlayCatalog() + } + + /** 新建手机歌单(id 101…200)。已满返回 false。 */ + fun addPhoneMenu(name: String, musicIds: List): Boolean { + if (!isInitialized) return false + val id = library.nextPhoneMenuId() + if (id < 0) return false + library.upsertMenu( + Menu( + id = id, + isPhone = true, + music = musicIds, + date = "", + name = name, + ), + ) + _libraryRevision.value++ + refreshCarPlayCatalog() + return true + } + + /** 向已有歌单追加歌曲(去重)。 */ + fun insertMusicToMenu(menu: Menu, musicIds: List): Boolean { + if (!isInitialized) return false + library.upsertMenu(menu.copy(music = (menu.music + musicIds).distinct())) + _libraryRevision.value++ + refreshCarPlayCatalog() + return true + } + + /** + * 每次实际切歌时更新播放历史,使「最近播放」跟随播放顺序。 + */ + private fun bindHistoryOnTrackChange() { + historyJob?.cancel() + historyJob = scope.launch { + player.state + .map { it.current?.musicId } + .distinctUntilChanged() + .collect { musicId -> + recordPlayingHistory(musicId) + } + } + } + + fun refreshCarPlayCatalog() { + if (!isInitialized) return + val lists = library.findAllListByGroup("all", settings.enableHttp) + val current = player.state.value.current + PlatformServices.carPlayCatalog.setLibrary( + allMusic = lists.music.withSortOrder(settings.sortOrder), + albums = lists.albums.withSortOrder(settings.albumsSortOrder), + love = lists.loved.withSortOrder(settings.loveSortOrder), + recent = lists.recent.withSortOrder(settings.recentSortOrder), + menuList = lists.menus, + musicCover = { pathResolver.coverDisplayUri(it) }, + albumCover = { pathResolver.coverDisplayUri(it) }, + ) + PlatformServices.carPlayCatalog.updateNowPlaying( + music = current, + coverUri = current?.let { pathResolver.coverDisplayUri(it) }, + playing = player.state.value.isPlaying, + favorite = current?.isLove == true, + loopMode = player.state.value.loopMode, + ) + PlatformServices.carPlay.refreshCatalog() + } + + /** + * 切歌时只刷新 CarPlay「正在播放」条目,避免重建根模板打断导航。 + */ + private fun bindCarPlayNowPlaying() { + carPlayNowPlayingJob?.cancel() + carPlayNowPlayingJob = scope.launch { + player.state + .map { + listOf( + it.current?.musicId, + it.current?.musicName, + it.current?.isLove, + it.isPlaying, + it.loopMode, + ) + } + .distinctUntilChanged() + .collect { + if (!isInitialized) return@collect + val current = player.state.value.current + val cover: (Music) -> String? = { pathResolver.coverDisplayUri(it) } + PlatformServices.carPlayCatalog.updateNowPlaying( + music = current, + coverUri = current?.let(cover), + playing = player.state.value.isPlaying, + favorite = current?.isLove == true, + loopMode = player.state.value.loopMode, + ) + val recent = library.findAllListByGroup( + group = "all", + enableHttp = settings.enableHttp, + ).recent.withSortOrder(settings.recentSortOrder) + PlatformServices.carPlayCatalog.updateRecent(recent, cover) + PlatformServices.carPlay.refreshNowPlaying() + } + } + } + + /** 当前曲目变化时加载歌词(不强制刷新)。 */ + private fun bindLyricLoader() { + lyricJob?.cancel() + lyricJob = scope.launch { + player.state + .map { it.current?.musicId } + .distinctUntilChanged() + .collectLatest { musicId -> + if (musicId.isNullOrBlank()) { + _playingLyric.value = LoadedLyric() + return@collectLatest + } + // 先清掉上一首歌词;携带当前 uid,避免另一条 Flow 在网络返回前 + // 把旧歌词按新歌曲的进度推给桌面歌词/小组件。 + _playingLyric.value = LoadedLyric(raw = Lyric(uid = musicId)) + val music = library.getMusicById(musicId) + ?: player.state.value.current + ?: return@collectLatest + val loaded = runCatching { + lyricService.getLrc(music, forceRefresh = false) + }.getOrDefault(LoadedLyric()) + // collectLatest 能取消上一首的加载;再校验一次,防止不可取消的 + // 数据源晚返回后覆盖当前歌曲。 + if (player.state.value.current?.musicId == musicId) { + _playingLyric.value = loaded + } + } + } + } + + /** + * 根据播放位置与已加载歌词同步桌面悬浮歌词。 + */ + private fun bindDesktopLyric() { + desktopPlayingJob?.cancel() + desktopPlayingJob = scope.launch { + player.state + .map { it.isPlaying } + .distinctUntilChanged() + .collect { + syncDesktopLyricVisibility() + } + } + desktopLyricJob?.cancel() + desktopLyricJob = scope.launch { + combine( + player.state.map { it.positionMs to it.current?.musicId }, + _playingLyric, + ) { pair, lyric -> pair to lyric } + .collect { (pair, lyric) -> + val (positionMs, musicId) = pair + val lyricChanged = lyric !== desktopLyricSnapshot + val isFetch = musicId != desktopLyricMusicId || lyricChanged + if (isFetch) { + desktopLyricMusicId = musicId + desktopLyricSnapshot = lyric + latestCurIndex = LYRIC_INDEX_UNSET + originLine1Text = null + originLine2Text = null + } + val currentLyric = lyric.takeIf { + musicId != null && it.raw?.uid == musicId + } ?: LoadedLyric(raw = Lyric(uid = musicId)) + pushDesktopLyric(positionMs, currentLyric, isFetch) + } + } + } + + /** 切歌 / 播放状态变化时同步主屏小组件(对齐 Flutter HomeWidgetUtil)。 */ + private fun bindHomeWidget() { + homeWidgetJob?.cancel() + homeWidgetJob = scope.launch { + player.state + .map { st -> + Triple(st.current?.musicId, st.isPlaying, st.current?.isLove == true) + } + .distinctUntilChanged() + .collect { + pushHomeWidget() + } + } + } + + private fun String.replaceLine(): String = replace("\r", "").replace("\n", "") + + /** + * 对齐 Flutter [LyricLogic.changePlayingLyric]。 + * @param isFetch 切歌 / 重新搜词 / 切换译文时强制刷新。 + */ + private fun pushDesktopLyric(positionMs: Long, lyric: LoadedLyric, isFetch: Boolean) { + val musicId = player.state.value.current?.musicId + if (musicId == null) return + + if (lyric.main.isEmpty()) { + if (!isFetch && latestCurIndex == LYRIC_INDEX_EMPTY) return + latestCurIndex = LYRIC_INDEX_EMPTY + setNowPlayingLyricLine(null) + postDesktopLyric(I18n.t("no_lyrics"), "", -1) + return + } + + val curIndex = LrcParser.currentIndex(lyric.main, positionMs) + if (isFetch) latestCurIndex = LYRIC_INDEX_UNSET + if (curIndex == latestCurIndex) return + if (curIndex < 0) { + latestCurIndex = LYRIC_INDEX_BEFORE_FIRST + setNowPlayingLyricLine(null) + postDesktopLyric(I18n.t("no_lyrics"), "", -1) + return + } + if (curIndex >= lyric.main.size) return + latestCurIndex = curIndex + + val mainText = lyric.main[curIndex].text.replaceLine() + setNowPlayingLyricLine(mainText) + val lrcType = lyricService.lrcType + + if (lrcType > 0) { + var extText = lyric.translation.getOrNull(curIndex)?.text?.replaceLine() + if (mainText.isEmpty()) extText = "" + postDesktopLyric(mainText, extText, 0) + return + } + + val extText = lyric.main.getOrNull(curIndex + 1)?.text?.replaceLine() + if (isFetch) { + postDesktopLyric(mainText, extText) + return + } + if (mainText == originLine1Text) { + postDesktopLyric(null, extText) + return + } + if (mainText == originLine2Text) { + postDesktopLyric(extText, null, 2) + return + } + postDesktopLyric(mainText, extText) + } + + private fun postDesktopLyric( + lyricLine1: String?, + lyricLine2: String?, + currentLine: Int = 1, + ) { + if (lyricLine1 != null) originLine1Text = lyricLine1 + if (lyricLine2 != null) originLine2Text = lyricLine2 + PlatformServices.desktopLyric.updateLines(lyricLine1, lyricLine2, currentLine) + } + + /** Now Playing 标题只用当前句,不走桌面歌词的换行/高亮动画。 */ + private fun setNowPlayingLyricLine(text: String?) { + player.setNowPlayingLyric(text?.takeIf { it.isNotBlank() && it != I18n.t("no_lyrics") }) + } + + private fun pushHomeWidget( + updateCover: Boolean = true, + ) { + val st = player.state.value + val current = st.current ?: return + val coverPath = if (updateCover) widgetCoverCache.resolveForWidget(current) else null + PlatformServices.homeWidget.update( + songName = current.musicName, + artist = current.artist, + isPlaying = st.isPlaying, + favorite = current.isLove == true, + coverPath = coverPath, + playText = I18n.t("play_text"), + ) + } + + private fun notifyWidgetCoverReady() { + val st = player.state.value + if (st.current == null) return + pushHomeWidget( + updateCover = true, + ) + } + + /** 小组件封面:优先读本地缓存,HTTP 由 [WidgetCoverCache] 异步落盘。 */ + fun widgetCoverSource(music: Music): String? = widgetCoverCache.resolveForWidget(music) + + /** + * 对齐 Flutter [SystemLogic.openDesktopLyric]。 + * 权限不足时返回 false,调用方应回滚开关。 + */ + fun setDesktopLyricEnabled(enable: Boolean): Boolean { + val ok = PlatformServices.desktopLyric.pipAutoOpen(enable) + if (ok) { + settings.enableDesktopLyric = enable + syncDesktopLyricVisibility() + } + return ok + } + + /** + * 由 Activity / Scene 生命周期调用。 + * 桌面歌词只在「后台 + 设置开启 + 正在播放」同时成立时显示。 + */ + fun setAppInBackground(inBackground: Boolean) { + if (!isInitialized) return + appInBackground = inBackground + syncDesktopLyricVisibility() + } + + private fun syncDesktopLyricVisibility() { + if (!isInitialized) return + val lyric = PlatformServices.desktopLyric + val playing = player.state.value.isPlaying + lyric.setPlaying(playing) + val shouldShow = appInBackground && settings.enableDesktopLyric && playing + if (shouldShow) lyric.show() else lyric.hide() + } + + /** 对齐 Flutter 悬浮窗「译文」按钮:`LyricLogic.toggleTranslate` + `changePlayingLyric(true)`。 */ + fun cycleDesktopLyricType() { + lyricService.toggleType() + applyLyricDisplayType() + } + + /** 重新加载当前曲目歌词(网络 + 数据库缓存)。 */ + suspend fun reloadPlayingLyric(forceRefresh: Boolean = false): LoadedLyric { + val musicId = player.state.value.current?.musicId ?: return LoadedLyric().also { + _playingLyric.value = it + } + val music = library.getMusicById(musicId) ?: player.state.value.current + ?: return LoadedLyric().also { _playingLyric.value = it } + val result = runCatching { + lyricService.fetchLrc(music, forceRefresh = forceRefresh) + }.getOrNull() + val loaded = result?.loaded ?: LoadedLyric() + _playingLyric.value = loaded + if (forceRefresh) { + PlatformUi.showToast( + if (result?.ok == true) I18n.t("search_lyric_success") else I18n.t("search_lyric_failed"), + ) + } + return loaded + } + + /** + * 在应用 `scope` 中异步重新加载歌词。 + * 可从立即关闭的对话框安全调用(Compose UI scope 会被取消)。 + */ + fun requestReloadPlayingLyric(forceRefresh: Boolean = false) { + scope.launch { reloadPlayingLyric(forceRefresh = forceRefresh) } + } + + fun launch(block: suspend () -> Unit) { + scope.launch { block() } + } + + /** 仅重新解析已缓存的 fullLrc 并更新 UI。 */ + fun applyLyricDisplayType(): LoadedLyric { + latestCurIndex = LYRIC_INDEX_UNSET + val loaded = lyricService.toLoaded() + _playingLyric.value = loaded + return loaded + } + + private const val LYRIC_INDEX_UNSET = Int.MIN_VALUE + private const val LYRIC_INDEX_EMPTY = Int.MIN_VALUE + 1 + private const val LYRIC_INDEX_BEFORE_FIRST = -1 +} + +/** 对齐 Flutter `SdCard`:存储卷展示名、应用私有路径、是否选中。 */ +data class SdStorageDevice( + val name: String, + val path: String, + val chosen: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ComposeI18n.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ComposeI18n.kt new file mode 100644 index 0000000..42f4c32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ComposeI18n.kt @@ -0,0 +1,21 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.text.intl.Locale + +@Composable +fun SyncI18nWithSystemLocale() { + val locale = Locale.current + LaunchedEffect(locale.language, locale.region) { + I18n.setLanguageTag("${locale.language}-${locale.region}") + } +} + +@Composable +fun tr(key: String, vararg args: Any?): String { + val lang by I18n.lang.collectAsState() + return I18n.t(key, *args).also { lang } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/Routes.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/Routes.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/Routes.kt index 47c79c5..dd2d256 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/Routes.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.navigation +package com.zhushenwudi.lovelivemusicplayer.navigation object Routes { const val SPLASH = "splash" @@ -26,17 +26,20 @@ object Routes { fun parseLibraryDetail(route: String): LibraryDetail? = when { route.startsWith("album/") -> route.removePrefix("album/").takeIf { it.isNotEmpty() }?.let(LibraryDetail::Album) + route.startsWith("artist/") -> route.removePrefix("artist/").takeIf { it.isNotEmpty() }?.let(LibraryDetail::Artist) + route.startsWith("menu/") -> route.removePrefix("menu/").toLongOrNull()?.let(LibraryDetail::Menu) + else -> null } } /** - * Flutter nested Navigator detail destinations — hosted over home (home stays composed) - * so Hero / shared-element pop does not rebuild the library lists. + * 嵌套详情路由 — 叠加在主页之上(主页保持组合) + * 使 Hero 共享元素返回时不重建曲库列表。 */ sealed class LibraryDetail { data class Album(val albumId: String) : LibraryDetail() @@ -44,19 +47,19 @@ sealed class LibraryDetail { data class Menu(val menuId: Long) : LibraryDetail() } -enum class TopSection(val title: String) { - LIBRARY("歌库"), - MINE("我的"), +enum class TopSection(val titleKey: String) { + LIBRARY("song_lib"), + MINE("mine"), } -/** Bottom bar tabs for the current top section (3 icons). */ -enum class BottomTab(val title: String) { - SONGS("歌曲"), - ALBUMS("专辑"), - ARTISTS("歌手"), - LOVE("我喜欢"), - MENUS("歌单"), - RECENT("最近播放"), +/** 当前顶部区域对应的底部栏标签(3 个图标)。 */ +enum class BottomTab(val titleKey: String) { + SONGS("music"), + ALBUMS("album"), + ARTISTS("singer"), + LOVE("iLove"), + MENUS("songMenu"), + RECENT("history"), ; companion object { @@ -65,7 +68,7 @@ enum class BottomTab(val title: String) { TopSection.MINE -> listOf(LOVE, MENUS, RECENT) } - /** page = topTabIndex * 3 + bottomIndex */ + /** page = topTabIndex * 3 + bottomIndex(页码公式) */ fun fromPage(page: Int): BottomTab = when (page.coerceIn(0, 5)) { 0 -> SONGS 1 -> ALBUMS @@ -77,14 +80,14 @@ enum class BottomTab(val title: String) { } } -/** Full library content pages (6), driven by top*3 + bottom. */ -enum class LibraryTab(val title: String) { - SONGS("歌曲"), - ALBUMS("专辑"), - ARTISTS("歌手"), - LOVE("我喜欢"), - MENUS("歌单"), - RECENT("最近播放"), +/** 曲库全部内容页(6 页),由 top*3 + bottom 驱动。 */ +enum class LibraryTab(val titleKey: String) { + SONGS("music"), + ALBUMS("album"), + ARTISTS("singer"), + LOVE("iLove"), + MENUS("songMenu"), + RECENT("history"), ; companion object { diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/SharedNav.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/SharedNav.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/SharedNav.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/SharedNav.kt index 5590c1b..7473b2c 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/navigation/SharedNav.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/navigation/SharedNav.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalSharedTransitionApi::class) -package top.zhushenwudi.llmp.navigation +package com.zhushenwudi.lovelivemusicplayer.navigation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility @@ -28,7 +28,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry -/** Flutter Hero tags: album{id} / singer{uid} / menu{id}. */ +/** Hero 共享元素键:album{id} / singer{uid} / menu{id}。 */ object HeroKeys { fun album(albumId: String) = "album$albumId" fun singer(uid: String) = "singer$uid" @@ -38,19 +38,19 @@ object HeroKeys { val LocalSharedTransitionScope = compositionLocalOf { null } val LocalNavAnimatedVisibilityScope = compositionLocalOf { null } -/** Currently open library detail — list items hide their hero source when matched. */ +/** 当前打开的曲库详情 — 匹配项隐藏列表侧 Hero 源。 */ val LocalOpenLibraryDetail = compositionLocalOf { null } -/** Flutter GetX defaultTransitionDuration = 300ms. */ +/** 页面转场动画时长 300ms。 */ const val LlmpTransitionMs = 300 val HeroRoundedClip: Shape = RoundedCornerShape(12.dp) val HeroCircleClip: Shape = CircleShape /** - * Flutter GetX [RightToLeftFadeTransition]: - * - Incoming: slide from right + fadeIn - * - Outgoing (secondary): slide left only — **no fadeOut** (avoids white flash through both layers) + * 右进左出淡入淡出转场: + * - 进入:从右侧滑入 + 淡入 + * - 退出(次级):仅左滑 — 不淡出(避免双层叠加白闪) */ fun AnimatedContentTransitionScope.llmpSlideFadeEnter(): EnterTransition = slideInHorizontally( @@ -77,8 +77,8 @@ fun AnimatedContentTransitionScope.llmpSlideFadePopExit(): Ex ) + fadeOut(animationSpec = tween(LlmpTransitionMs, easing = EaseOutQuad)) /** - * Shared-element modifier for a cover already inside an [AnimatedVisibilityScope]. - * Place **after** size / aspectRatio; clip is applied via overlay clip (not parent clip). + * 已在 [androidx.compose.animation.AnimatedVisibilityScope] 内的封面共享元素修饰符。 + * 放在 size / aspectRatio 之后;裁剪通过 overlay clip 应用。 */ @Composable fun Modifier.llmpSharedElement( @@ -101,7 +101,7 @@ fun Modifier.llmpSharedElement( } /** - * Detail-page cover: uses [LocalNavAnimatedVisibilityScope] from the detail overlay. + * 详情页封面:使用详情叠加层的 [com.zhushenwudi.lovelivemusicplayer.navigation.LocalNavAnimatedVisibilityScope]。 */ @Composable fun Modifier.llmpDetailSharedElement( @@ -113,10 +113,10 @@ fun Modifier.llmpDetailSharedElement( } /** - * List-side hero source (Google snack pattern): hide when this item's detail is open so - * only one end of the shared element is "visible" at a time — avoids flicker / small-icon flash. + * 列表侧 Hero 源:对应详情打开时隐藏, + * 确保共享元素仅一端可见,避免闪烁/小图标闪现。 * - * [slotModifier] should include size (e.g. fillMaxWidth + aspectRatio, or size(48.dp)). + * `slotModifier` 应包含尺寸(如 fillMaxWidth + aspectRatio 或 size(48.dp))。 */ @Composable fun HeroCoverSlot( diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.kt similarity index 53% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.kt index a32f3f0..f9e1f7a 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.kt @@ -1,7 +1,7 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import androidx.compose.runtime.Composable -/** System back while library detail overlay is open. */ +/** 曲库详情叠加层打开时的系统返回。 */ @Composable expect fun DetailBackHandler(enabled: Boolean, onBack: () -> Unit) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/share/QqShare.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/share/QqShare.kt new file mode 100644 index 0000000..9709f7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/share/QqShare.kt @@ -0,0 +1,190 @@ +package com.zhushenwudi.lovelivemusicplayer.share + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices +import com.zhushenwudi.lovelivemusicplayer.util.sha512Hex + +/** + * 对齐 H5 `jumper.vue`: + * - type=1 + data=SharedMusic JSON → /play(用 neteaseId 拉流和封面) + * - type=2 + data=kvKey → /share + * - 无 type → /open-app + */ +object QqShare { + private val json = Json { + encodeDefaults = true + explicitNulls = true + ignoreUnknownKeys = true + } + + fun shareApp() { + AppContainer.launch { + sendToQq( + title = I18n.t("share_app"), + text = "", + url = Const.SHARE_QQ_PAGE, + imageUrl = Const.SHARE_DEFAULT_LOGO, + ) + } + } + + fun shareMusic(music: Music) { + val id = music.musicId ?: return + val name = music.musicName ?: return + AppContainer.launch { + val payload = SharedMusicPayload(id = id, name = name, neteaseId = music.neteaseId) + sendToQq( + title = I18n.t("outer_share_music"), + text = name, + url = sharePageUrl("1", json.encodeToString(payload)), + imageUrl = resolveCoverUrl(music), + ) + } + } + + fun shareMenu(menu: Menu) { + if (menu.music.isEmpty()) return + AppContainer.launch { + val musicList = menu.music.mapNotNull { musicId -> + val music = AppContainer.library.getMusicById(musicId) ?: return@mapNotNull null + val id = music.musicId ?: return@mapNotNull null + val name = music.musicName ?: return@mapNotNull null + SharedMusicPayload(id = id, name = name, neteaseId = music.neteaseId) + } + if (musicList.isEmpty()) return@launch + val value = json.encodeToString(ShareMenuPayload(menuName = menu.name, musicList = musicList)) + val key = sha512Hex(value) + val resp = runCatching { + AppContainer.ossApi.postJson( + Const.SHARE_KV_URL, + json.encodeToString(ShareKvRequest(key = key, value = value)), + ) + }.getOrNull() ?: return@launch + val ok = runCatching { + json.decodeFromString(resp).success + }.getOrNull() == true + if (!ok) return@launch + val first = menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) } + sendToQq( + title = I18n.t("outer_share_menu"), + text = menu.name, + url = sharePageUrl("2", key), + imageUrl = first?.let { resolveCoverUrl(it) } ?: Const.SHARE_DEFAULT_LOGO, + ) + } + } + + private fun sendToQq( + title: String, + text: String, + url: String, + imageUrl: String, + ) { + PlatformServices.share.shareToQq( + title = title, + text = text, + url = url, + imageUrl = imageUrl, + imagePath = null, + ) + } + + /** + * `https://shareqq.zhushenwudi.top/?type=1&data=...` + * H5 jumper 用正则从 href 取 type/data,再 decodeURIComponent(data)。 + */ + private fun sharePageUrl(type: String, data: String): String = + "${Const.SHARE_QQ_PAGE}?type=$type&data=${encodeUriComponent(data)}" + + private suspend fun resolveCoverUrl(music: Music): String { + val picUrl = fetchNeteasePicUrl(music.neteaseId) + if (!picUrl.isNullOrBlank()) return toHttps(picUrl) + val httpCover = AppContainer.pathResolver.coverDisplayUri(music) + ?.takeIf { it.startsWith("http") } + return httpCover?.let(::toHttps) ?: Const.SHARE_DEFAULT_LOGO + } + + private suspend fun fetchNeteasePicUrl(neteaseId: String?): String? { + val id = neteaseId?.trim().orEmpty() + if (id.isEmpty()) return null + return runCatching { + val body = AppContainer.ossApi.fetchText("${Const.BACKEND_URL}?ids=$id") + json.decodeFromString(body).songs.firstOrNull()?.al?.picUrl + ?.takeIf { it.isNotBlank() } + }.getOrNull() + } + + private fun toHttps(url: String): String = + if (url.startsWith("http://")) "https://${url.removePrefix("http://")}" else url + + /** + * 对齐 JS `encodeURIComponent`:只放过 ASCII 的字母数字和 `-_.!~*'()`。 + * 不能对 UTF-8 字节做 `isLetterOrDigit()`,否则 `E5`/`E3` 会被当成 `å`/`ã` 原样写入, + * 日文歌名会变成 `å%83%95ã%82%89...`。 + */ + private fun encodeUriComponent(value: String): String { + val bytes = value.encodeToByteArray() + return buildString(bytes.size * 3) { + for (b in bytes) { + val ub = b.toInt() and 0xFF + if (isUriComponentUnreserved(ub)) { + append(ub.toChar()) + } else { + append('%') + append(ub.toString(16).uppercase().padStart(2, '0')) + } + } + } + } + + private fun isUriComponentUnreserved(ub: Int): Boolean { + val c = ub.toChar() + return ub < 0x80 && (c.isLetterOrDigit() || c in "-_.!~*'()") + } +} + +@Serializable +private data class SharedMusicPayload( + @SerialName("_id") val id: String, + val name: String, + val neteaseId: String? = null, +) + +@Serializable +private data class ShareMenuPayload( + val menuName: String, + val musicList: List, +) + +@Serializable +private data class ShareKvRequest( + val key: String, + val value: String, +) + +@Serializable +private data class ShareKvResponse( + val success: Boolean = false, +) + +@Serializable +private data class NeteaseSongDetail( + val songs: List = emptyList(), +) + +@Serializable +private data class NeteaseSong( + val al: NeteaseAlbumInfo? = null, +) + +@Serializable +private data class NeteaseAlbumInfo( + val picUrl: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/ColorMs.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/ColorMs.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/ColorMs.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/ColorMs.kt index 6d08950..f329b44 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/ColorMs.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/ColorMs.kt @@ -1,8 +1,8 @@ -package top.zhushenwudi.llmp.theme +package com.zhushenwudi.lovelivemusicplayer.theme import androidx.compose.ui.graphics.Color -/** Flutter ColorMs 精确色板 */ +/** 应用精确色板 */ object ColorMs { val LightPrimary = Color(0xFFF2F8FF) val NightPrimary = Color(0xFF303030) @@ -37,12 +37,12 @@ object ColorMs { val Dark2B333A = Color(0xFF2B333A) val E7F2FF = Color(0xFFE7F2FF) - // Flutter leftovers + // 扩展色 val B2246A = Color(0xFFB2246A) val EBF3FE = Color(0xFFEBF3FE) val BFBFBF = Color(0xFFBFBFBF) - // Daily / drive / sync + // 每日推荐 / 驾驶模式 / 同步 val DailyPageBg = Color(0xFFF7D7EA) val DailyItemText = Color(0xFF370521) val DailyRecentHeader = Color(0xB5B5E2FC) diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/LlmpTheme.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/LlmpTheme.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/LlmpTheme.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/LlmpTheme.kt index 1b939e0..29a920f 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/theme/LlmpTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/theme/LlmpTheme.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.theme +package com.zhushenwudi.lovelivemusicplayer.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,8 +16,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.Font -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.generated.resources.katong +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.generated.resources.katong val LocalDarkTheme = staticCompositionLocalOf { false } @@ -58,7 +58,7 @@ fun LlmpTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - // KaTong is only for「歌库/我的」tabs (TopLibraryTabs); keep Material defaults elsewhere. + // KaTong 字体仅用于「歌库/我的」标签(TopLibraryTabs);其余保持 Material 默认。 val base = Typography() val typography = base.copy( titleMedium = base.titleMedium.copy(fontWeight = FontWeight.Medium), diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/HomeUiState.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/HomeUiState.kt similarity index 73% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/HomeUiState.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/HomeUiState.kt index 18ad9e3..53689a3 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/HomeUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/HomeUiState.kt @@ -1,11 +1,11 @@ -package top.zhushenwudi.llmp.ui +package com.zhushenwudi.lovelivemusicplayer.ui import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState /** - * Flutter [HomeController] — survives NavHost leave when opening detail routes, - * so tab index + list scroll restore on back. + * 主页 UI 状态 — 打开详情路由离开 NavHost 后仍保留, + * 返回时恢复标签页索引与列表滚动位置。 */ object HomeUiState { var pageIndex: Int = 0 diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/MainShell.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt similarity index 58% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/MainShell.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt index f3dd632..2ed8117 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/MainShell.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/MainShell.kt @@ -1,872 +1,944 @@ -package top.zhushenwudi.llmp.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.EaseOutQuad -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.navigation.LibraryDetail -import top.zhushenwudi.llmp.navigation.LibraryTab -import top.zhushenwudi.llmp.navigation.LlmpTransitionMs -import top.zhushenwudi.llmp.navigation.LocalNavAnimatedVisibilityScope -import top.zhushenwudi.llmp.navigation.LocalOpenLibraryDetail -import top.zhushenwudi.llmp.navigation.LocalSharedTransitionScope -import top.zhushenwudi.llmp.navigation.Routes -import top.zhushenwudi.llmp.navigation.TopSection -import top.zhushenwudi.llmp.platform.DetailBackHandler -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.ui.components.AddToMenuDialog -import top.zhushenwudi.llmp.ui.components.AppDrawer -import top.zhushenwudi.llmp.ui.components.BatchActionBar -import top.zhushenwudi.llmp.ui.components.LibrarySelectMode -import top.zhushenwudi.llmp.ui.components.LoadingOverlay -import top.zhushenwudi.llmp.ui.components.LlmpBottomBar -import top.zhushenwudi.llmp.ui.components.NeumorphicGroupLogoButton -import top.zhushenwudi.llmp.ui.components.SongLibraryTopBar -import top.zhushenwudi.llmp.ui.components.TopLibraryTabs -import top.zhushenwudi.llmp.ui.components.TwoButtonDialog -import top.zhushenwudi.llmp.ui.components.UpdateVersionDialog -import top.zhushenwudi.llmp.ui.components.isDefaultGroupLogo -import top.zhushenwudi.llmp.ui.player.FullPlayerScreen -import top.zhushenwudi.llmp.ui.player.LocalMiniPlayerHaze -import top.zhushenwudi.llmp.ui.player.MiniPlayerBar -import top.zhushenwudi.llmp.ui.player.NoMusicColorfulSkin -import top.zhushenwudi.llmp.ui.screens.AlbumDetailScreen -import top.zhushenwudi.llmp.ui.screens.ArtistDetailScreen -import top.zhushenwudi.llmp.ui.screens.LibraryTabContent -import top.zhushenwudi.llmp.ui.screens.MenuDetailScreen -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto -import top.zhushenwudi.llmp.ui.weslide.WeSlide - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun MainShell( - onOpenSettings: () -> Unit, - onOpenRoute: (String) -> Unit, - bgPhoto: String = "", - onBgPhotoChange: (String) -> Unit = {}, -) { - // Restore tab from HomeUiState (survives detail navigation; Flutter HomeController). - val restoredPage = HomeUiState.pageIndex.coerceIn(0, 5) - var topTabIndex by remember { mutableIntStateOf(if (restoredPage > 2) 1 else 0) } - var bottomIndex by remember { mutableIntStateOf(restoredPage % 3) } - var refreshToken by remember { mutableStateOf(0) } - var currentGroup by remember { mutableStateOf(AppContainer.currentGroup) } - var sortOrder by remember { mutableStateOf(AppContainer.settings.sortOrder) } - var selectMode by remember { mutableStateOf(LibrarySelectMode.Normal) } - var searchDraft by remember { mutableStateOf("") } - var searchQuery by remember { mutableStateOf("") } - var checkedIds by remember { mutableStateOf(setOf()) } - var showAddToMenu by remember { mutableStateOf(false) } - var showUnloveConfirm by remember { mutableStateOf(false) } - var loadingMsg by remember { mutableStateOf(null) } - var showOverwriteData by remember { mutableStateOf(false) } - var updateInfo by remember { - mutableStateOf(null) - } - var showAlreadyLatest by remember { mutableStateOf(false) } - // Flutter nested Navigator: overlay detail; keep home composed for smooth Hero pop. - var libraryDetail by remember { mutableStateOf(null) } - var retainedDetail by remember { mutableStateOf(null) } - SideEffect { - if (libraryDetail != null) retainedDetail = libraryDetail - } - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - val dark = LocalDarkTheme.current - val page = topTabIndex * 3 + bottomIndex - val libraryTab = LibraryTab.fromPage(page) - val section = if (topTabIndex == 0) TopSection.LIBRARY else TopSection.MINE - val weSlide = AppContainer.weSlide - val weSlideFooter = AppContainer.weSlideFooter - // Backdrop source for MiniPlayerBar frosted glass (Flutter BackdropFilter σ=30) - val miniPlayerHaze = rememberHazeState() - // Flutter selectMode > 0: lock 歌库/我的 + WeSlide gestures - val batchLock = selectMode == LibrarySelectMode.Batch - val detailOpen = libraryDetail != null - - fun openLibraryDetail(detail: LibraryDetail) { - checkedIds = emptySet() - selectMode = LibrarySelectMode.Normal - weSlide.hide() - libraryDetail = detail - } - - fun openRoute(route: String) { - val detail = Routes.parseLibraryDetail(route) - if (detail != null) { - openLibraryDetail(detail) - } else { - onOpenRoute(route) - } - } - - fun closeLibraryDetail() { - libraryDetail = null - } - - DetailBackHandler(enabled = detailOpen, onBack = ::closeLibraryDetail) - // Flutter PageView: 6 pages, swipe syncs currentIndex + top tab - val contentPagerState = rememberPagerState( - initialPage = restoredPage, - pageCount = { 6 }, - ) - - LaunchedEffect(batchLock) { - weSlide.interactionLocked = batchLock - if (batchLock) { - weSlide.hide() - // Flutter: no drawer while selectMode > 0 - if (drawerState.isOpen) drawerState.close() - } - } - - fun applyPage(index: Int) { - val safe = index.coerceIn(0, 5) - topTabIndex = if (safe > 2) 1 else 0 - bottomIndex = safe % 3 - HomeUiState.pageIndex = safe - } - - val latestPage = rememberUpdatedState(page) - - // Single owner for tab/bottom → pager scroll. Cancels previous anim on rapid taps. - // Flutter PageViewLogic: jumpToPage for 歌库/我的 (and bottom bar) — never slide through middles. - LaunchedEffect(page) { - HomeUiState.pageIndex = page - if (contentPagerState.settledPage != page) { - val distance = kotlin.math.abs(contentPagerState.settledPage - page) - if (distance > 1) { - contentPagerState.scrollToPage(page) - } else { - contentPagerState.animateScrollToPage(page) - } - } - } - // Pager swipe → tabs. Ignore settles while scrolling so a cancelled/old - // animation cannot overwrite a newer 歌库/我的 selection. - LaunchedEffect(contentPagerState) { - snapshotFlow { contentPagerState.settledPage } - .distinctUntilChanged() - .collect { settled -> - if (contentPagerState.isScrollInProgress) return@collect - if (settled != latestPage.value) { - applyPage(settled) - } - } - } - - // Repair stale existFile after storage-root / SD path changes (cheap exists() scan). - LaunchedEffect(Unit) { - runCatching { - AppContainer.library.rescanExistFiles(AppContainer.pathResolver) - } - refreshToken++ - } - - // Align Flutter findAllListByGroup: local existFile filter when HTTP off. - // sortOrder is NOT a key — Flutter toggles by reversing in-memory lists only. - val enableHttp = AppContainer.settings.enableHttp - val historyRevision by AppContainer.historyRevision.collectAsState() - val loveRevision by AppContainer.loveRevision.collectAsState() - val libraryLists = remember(refreshToken, currentGroup, enableHttp) { - runCatching { - AppContainer.library.findAllListByGroup( - group = currentGroup.displayName, - enableHttp = enableHttp, - sortAsc = AppContainer.settings.sortOrder != "DESC", - ) - }.getOrElse { - top.zhushenwudi.llmp.db.LibraryLists( - music = emptyList(), - albums = emptyList(), - artists = emptyList(), - loved = emptyList(), - recent = emptyList(), - menus = emptyList(), - ) - } - } - var music by remember { mutableStateOf(emptyList()) } - var albums by remember { mutableStateOf(emptyList()) } - var artists by remember { mutableStateOf(libraryLists.artists) } - var menus by remember { mutableStateOf(libraryLists.menus) } - var loved by remember { mutableStateOf(emptyList()) } - var recent by remember { mutableStateOf(emptyList()) } - LaunchedEffect(libraryLists) { - music = libraryLists.music - albums = libraryLists.albums - artists = libraryLists.artists - menus = libraryLists.menus - loved = libraryLists.loved - recent = libraryLists.recent - } - // Flutter refreshMusicTimestamp only rewrites recentList (not the whole library). - LaunchedEffect(historyRevision, currentGroup, enableHttp) { - if (historyRevision == 0L) return@LaunchedEffect - runCatching { - AppContainer.library.findAllListByGroup( - group = currentGroup.displayName, - enableHttp = enableHttp, - sortAsc = AppContainer.settings.sortOrder != "DESC", - ).recent - }.getOrNull()?.let { recent = it } - } - // Flutter toggleLove → findAllLoveListByGroup. - LaunchedEffect(loveRevision, currentGroup, enableHttp) { - if (loveRevision == 0L) return@LaunchedEffect - runCatching { - AppContainer.library.findAllListByGroup( - group = currentGroup.displayName, - enableHttp = enableHttp, - sortAsc = AppContainer.settings.sortOrder != "DESC", - ) - }.getOrNull()?.let { lists -> - loved = lists.loved - // Keep song/recent rows' isLove flags in sync with Music.isLove column. - fun patch(list: List) = list.map { m -> - val id = m.musicId ?: return@map m - val love = AppContainer.library.getMusicById(id)?.isLove == true - if (m.isLove == love) m else m.copy(isLove = love) - } - music = patch(music) - recent = patch(recent) - } - } - // Flutter filterItem: search over song-name on songs/love/recent. - val displayMusic = remember(music, searchQuery, selectMode) { - if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { - music.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } - } else music - } - val displayLoved = remember(loved, searchQuery, selectMode) { - if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { - loved.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } - } else loved - } - val displayRecent = remember(recent, searchQuery, selectMode) { - if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { - recent.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } - } else recent - } - val playerState by AppContainer.player.state.collectAsState() - - fun currentSongList(): List = when (libraryTab) { - LibraryTab.SONGS -> displayMusic - LibraryTab.LOVE -> displayLoved - LibraryTab.RECENT -> displayRecent - else -> emptyList() - } - - fun playMusic(item: Music) { - if (selectMode == LibrarySelectMode.Batch) return - val queue = when (libraryTab) { - LibraryTab.SONGS -> displayMusic - LibraryTab.LOVE -> displayLoved - LibraryTab.RECENT -> displayRecent - else -> listOf(item) - }.ifEmpty { listOf(item) } - val index = queue.indexOfFirst { it.musicId == item.musicId }.coerceAtLeast(0) - val prevId = AppContainer.player.state.value.current?.musicId - AppContainer.player.setQueue(queue, index) - AppContainer.player.setLoopMode(AppContainer.settings.loopMode) - AppContainer.player.play() - AppContainer.persistPlayQueue(queue, item.musicId) - // Track-change binder covers new songs; same-id replay needs an explicit bump. - if (item.musicId != null && item.musicId == prevId) { - AppContainer.recordPlayingHistory(item.musicId) - } - } - - fun playAll() { - val list = when (libraryTab) { - LibraryTab.SONGS -> displayMusic - LibraryTab.LOVE -> displayLoved - LibraryTab.RECENT -> displayRecent - LibraryTab.ALBUMS, LibraryTab.ARTISTS, LibraryTab.MENUS -> displayMusic - } - list.firstOrNull()?.let(::playMusic) - } - - fun sortItem() { - // Flutter HomeController.sortItem: flip SP + reverse in-memory lists. - val next = if (sortOrder == "ASC") "DESC" else "ASC" - sortOrder = next - AppContainer.settings.sortOrder = next - music = music.reversed() - albums = albums.reversed() - loved = loved.reversed() - recent = recent.reversed() - } - - fun closeSelect() { - checkedIds = emptySet() - selectMode = LibrarySelectMode.Normal - } - - fun selectedMusic(): List { - val ids = checkedIds - return currentSongList().filter { it.musicId != null && it.musicId in ids } - } - - fun addMusicListToPlayer(list: List) { - if (list.isEmpty()) return - val st = AppContainer.player.state.value - if (st.queue.isEmpty()) { - AppContainer.player.setQueue(list, 0) - AppContainer.player.setLoopMode(AppContainer.settings.loopMode) - AppContainer.player.play() - AppContainer.persistPlayQueue() - PlatformUi.showToast("添加成功") - closeSelect() - return - } - val existing = st.queue.mapNotNull { it.musicId }.toSet() - val toAdd = list.filter { it.musicId !in existing } - if (toAdd.isEmpty()) { - PlatformUi.showToast("添加成功") - closeSelect() - return - } - val newQueue = st.queue + toAdd - val idx = st.index.coerceAtLeast(0) - val wasPlaying = st.isPlaying - AppContainer.player.setQueue(newQueue, idx) - if (wasPlaying) AppContainer.player.play() - AppContainer.persistPlayQueue() - PlatformUi.showToast("添加成功") - closeSelect() - } - - fun appendToMenu(menu: Menu, list: List) { - val ids = (menu.music + list.mapNotNull { it.musicId }).distinct() - AppContainer.library.upsertMenu(menu.copy(music = ids)) - PlatformUi.showToast("添加成功") - closeSelect() - showAddToMenu = false - refreshToken++ - } - - val countLabel = when (libraryTab) { - LibraryTab.SONGS -> "${displayMusic.size} 首" - LibraryTab.ALBUMS -> "${albums.size} 张" - LibraryTab.ARTISTS -> "${artists.size} 位" - LibraryTab.LOVE -> "${displayLoved.size} 首" - LibraryTab.MENUS -> "${menus.size} 个" - LibraryTab.RECENT -> "${displayRecent.size} 首" - } - val batchSelectable = libraryTab == LibraryTab.SONGS || - libraryTab == LibraryTab.LOVE || - libraryTab == LibraryTab.RECENT - val selectAllChecked = remember(checkedIds, libraryTab, displayMusic, displayLoved, displayRecent) { - val list = currentSongList() - list.isNotEmpty() && list.all { it.musicId != null && it.musicId in checkedIds } - } - - val shellBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary - val footerNavInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - // Flutter NestedController.closeBottomBar while detail is open. - val footerHeight = when { - detailOpen -> 0.dp - selectMode == LibrarySelectMode.Batch && batchSelectable -> 102.dp + footerNavInset - else -> 56.dp + footerNavInset - } - - fun importData(force: Boolean) { - scope.launch { - loadingMsg = "导入中…" - runCatching { - AppContainer.dataImport.fetchAndImport(force = force) - }.onSuccess { - runCatching { - AppContainer.library.rescanExistFiles(AppContainer.pathResolver) - } - top.zhushenwudi.llmp.platform.PlatformUi.showToast( - "数据更新完成 v${it.version} · ${it.musicCount}首", - ) - refreshToken++ - }.onFailure { - top.zhushenwudi.llmp.platform.PlatformUi.showToast( - it.message ?: "拉取歌曲失败", - ) - } - loadingMsg = null - } - } - - fun handleUpdateData() { - scope.launch { - loadingMsg = "下载中…" - val remote = AppContainer.dataImport.fetchRemoteVersion() - loadingMsg = null - if (remote == null) { - top.zhushenwudi.llmp.platform.PlatformUi.showToast("数据异常或网络失败") - return@launch - } - val local = AppContainer.dataImport.localVersion() - when { - local == remote && AppContainer.library.countMusic() > 0 -> { - showOverwriteData = true - } - local < remote -> importData(force = true) - else -> { - top.zhushenwudi.llmp.platform.PlatformUi.showToast("已是最新版本") - } - } - } - } - - fun handleCheckUpdate() { - scope.launch { - loadingMsg = "检查更新…" - val info = runCatching { AppContainer.updateChecker.check(1) }.getOrNull() - loadingMsg = null - if (info == null) { - showAlreadyLatest = true - } else { - updateInfo = info - } - } - } - - // Rtl wrapper makes ModalNavigationDrawer open from the end (Flutter endDrawer). - CompositionLocalProvider( - LocalLayoutDirection provides LayoutDirection.Rtl, - LocalBgPhoto provides bgPhoto, - ) { - ModalNavigationDrawer( - drawerState = drawerState, - // Batch: never open/close drawer by gesture; logo button also disabled below. - gesturesEnabled = !batchLock && drawerState.isOpen && !weSlide.isOpened, - drawerContent = { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - ModalDrawerSheet( - drawerContainerColor = if (dark) ColorMs.NightPrimary else ColorMs.White, - // Width applied inside AppDrawer (min(0.35*h, w)) - modifier = Modifier, - ) { - AppDrawer( - onClose = { scope.launch { drawerState.close() } }, - onOpenRoute = ::openRoute, - onOpenSettings = onOpenSettings, - onUpdateData = { handleUpdateData() }, - onCheckUpdate = { handleCheckUpdate() }, - onShare = { - top.zhushenwudi.llmp.platform.PlatformServices.share.shareText( - "LoveLive Music Player — https://github.com/zhushenwudi/LoveLiveMusicPlayer", - ) - }, - currentGroup = currentGroup, - onGroupSelect = { key -> - currentGroup = key - AppContainer.currentGroup = key - // Flutter refreshList keeps drawer open; lists refresh via currentGroup. - }, - version = "0.2.0", - ) - } - } - }, - ) { - CompositionLocalProvider( - LocalLayoutDirection provides LayoutDirection.Ltr, - LocalMiniPlayerHaze provides miniPlayerHaze, - ) { - WeSlide( - controller = weSlide, - footerController = weSlideFooter, - panelMinSize = 150.dp, - footerHeight = footerHeight, - // Collapsed panel zone must stay clear; body paints its own bg. - backgroundColor = shellBg, - overlay = true, - overlayColor = shellBg, - // Flutter WeSlide body mask: dark 0.4 / light 0.15 - overlayOpacity = if (dark) 0.4f else 0.15f, - // Flutter we_slide blur:true (panel open). Mini-bar frost is separate (Haze). - blur = true, - blurSigma = 5.dp, - blurColor = Color.Transparent, - parallax = true, - transformScale = true, - hideFooter = true, - hidePanelHeader = true, - isUpSlide = false, - isDismissible = !batchLock && !detailOpen, - body = { - // Home stays composed; list item hides its hero source while detail is open - // (Google shared-element pattern) so matching stays stable. - SharedTransitionLayout(Modifier.fillMaxSize()) { - CompositionLocalProvider( - LocalSharedTransitionScope provides this, - LocalOpenLibraryDetail provides libraryDetail, - ) { - Box(Modifier.fillMaxSize()) { - Column( - Modifier - .fillMaxSize() - .then( - if (detailOpen) { - Modifier - } else { - Modifier.hazeSource(state = miniPlayerHaze) - }, - ) - .background(shellBg) - .statusBarsPadding(), - ) { - Row( - Modifier - .fillMaxWidth() - .height(60.dp) - .padding(start = 8.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TopLibraryTabs( - selected = section, - interactionEnabled = !batchLock && !detailOpen, - onSelect = { next -> - topTabIndex = - if (next == TopSection.LIBRARY) 0 else 1 - }, - modifier = Modifier.weight(1f), - ) - // Flutter HomePageView._getTopHead — logo follows currentGroup. - NeumorphicGroupLogoButton( - group = currentGroup, - onClick = if (batchLock || detailOpen) { - null - } else { - { scope.launch { drawerState.open() } } - }, - size = 36.dp, - background = if (currentGroup.isDefaultGroupLogo()) { - NoMusicColorfulSkin - } else { - shellBg - }, - contentPadding = if (currentGroup.isDefaultGroupLogo()) { - 3.dp - } else { - 0.dp - }, - hasShadow = true, - ) - } - - if (libraryTab != LibraryTab.ARTISTS && - libraryTab != LibraryTab.MENUS - ) { - SongLibraryTopBar( - countLabel = countLabel, - libraryTab = libraryTab, - selectMode = selectMode, - sortAsc = sortOrder != "DESC", - searchQuery = searchDraft, - checkedCount = checkedIds.size, - selectAll = selectAllChecked, - onPlayAll = ::playAll, - onSearchOpen = { - selectMode = LibrarySelectMode.Search - searchDraft = "" - searchQuery = "" - }, - onSearchQueryChange = { searchDraft = it }, - onSearchSubmit = { searchQuery = it }, - onSearchCancel = { - searchDraft = "" - searchQuery = "" - selectMode = LibrarySelectMode.Normal - }, - onSort = ::sortItem, - onBatchOpen = { - if (batchSelectable) { - checkedIds = emptySet() - selectMode = LibrarySelectMode.Batch - } - }, - onSelectAll = { checked -> - val list = currentSongList() - checkedIds = if (checked) { - list.mapNotNull { it.musicId }.toSet() - } else { - emptySet() - } - }, - onBatchFinish = { closeSelect() }, - ) - Spacer(Modifier.height(4.dp)) - } - - HorizontalPager( - state = contentPagerState, - modifier = Modifier.weight(1f).fillMaxWidth(), - verticalAlignment = Alignment.Top, - beyondViewportPageCount = 1, - userScrollEnabled = !weSlide.isOpened && - !detailOpen && - selectMode == LibrarySelectMode.Normal, - ) { pagerPage -> - LibraryTabContent( - tab = LibraryTab.fromPage(pagerPage), - music = displayMusic, - albums = albums, - artists = artists, - menus = menus, - loved = displayLoved, - recent = displayRecent, - playingId = playerState.current?.musicId, - selectMode = selectMode == LibrarySelectMode.Batch && - LibraryTab.fromPage(pagerPage) in setOf( - LibraryTab.SONGS, - LibraryTab.LOVE, - LibraryTab.RECENT, - ), - checkedIds = checkedIds, - onCheckedChange = { id, checked -> - checkedIds = if (checked) { - checkedIds + id - } else { - checkedIds - id - } - }, - onPlay = ::playMusic, - onOpenAlbum = { - openLibraryDetail(LibraryDetail.Album(it)) - }, - onOpenArtist = { - openLibraryDetail(LibraryDetail.Artist(it)) - }, - onOpenMenu = { - openLibraryDetail(LibraryDetail.Menu(it)) - }, - onRefreshLibrary = { refreshToken++ }, - ) - } - } - - // Slide only (no fade) — fade would reveal list thumbs under hero. - AnimatedVisibility( - visible = detailOpen, - enter = slideInHorizontally( - animationSpec = tween( - LlmpTransitionMs, - easing = EaseOutQuad, - ), - initialOffsetX = { it }, - ), - exit = slideOutHorizontally( - animationSpec = tween( - LlmpTransitionMs, - easing = EaseOutQuad, - ), - targetOffsetX = { it }, - ), - modifier = Modifier.fillMaxSize(), - ) { - CompositionLocalProvider( - LocalNavAnimatedVisibilityScope provides this, - ) { - when (val detail = retainedDetail) { - is LibraryDetail.Album -> AlbumDetailScreen( - albumId = detail.albumId, - onBack = ::closeLibraryDetail, - onOpenPlayer = { - closeLibraryDetail() - weSlide.show() - }, - ) - is LibraryDetail.Artist -> ArtistDetailScreen( - artistId = detail.artistId, - onBack = ::closeLibraryDetail, - onOpenPlayer = { - closeLibraryDetail() - weSlide.show() - }, - ) - is LibraryDetail.Menu -> MenuDetailScreen( - menuId = detail.menuId, - onBack = ::closeLibraryDetail, - onOpenPlayer = { - closeLibraryDetail() - weSlide.show() - }, - ) - null -> Unit - } - } - } - } - } - } - }, - panelHeader = { - MiniPlayerBar( - onOpenPlayer = { weSlide.show() }, - interactionEnabled = !batchLock && !detailOpen, - ) - }, - panel = { - FullPlayerScreen( - onBack = { weSlide.hide() }, - onOpenAlbum = { openLibraryDetail(LibraryDetail.Album(it)) }, - ) - }, - footer = { - if (detailOpen) { - // Flutter: bottom bar closed on detail - } else if (selectMode == LibrarySelectMode.Batch && batchSelectable) { - BatchActionBar( - showUnlove = libraryTab == LibraryTab.LOVE, - onAddToPlaylist = { addMusicListToPlayer(selectedMusic()) }, - onAddToMenu = { - if (selectedMusic().isNotEmpty()) showAddToMenu = true - }, - onUnlove = { - if (selectedMusic().isNotEmpty()) showUnloveConfirm = true - }, - ) - } else { - LlmpBottomBar( - section = section, - selectedIndex = bottomIndex, - onSelect = { index -> - bottomIndex = index - }, - onSectionChange = { next -> - val top = if (next == TopSection.LIBRARY) 0 else 1 - if (topTabIndex != top) { - topTabIndex = top - } - }, - ) - } - }, - ) - } - } - - if (showAddToMenu) { - AddToMenuDialog( - menus = menus, - onPick = { menu -> appendToMenu(menu, selectedMusic()) }, - onCreate = { name -> - val id = AppContainer.library.nextPhoneMenuId() - val menu = Menu( - id = id, - isPhone = true, - music = emptyList(), - date = "", - name = name, - ) - AppContainer.library.upsertMenu(menu) - appendToMenu(menu, selectedMusic()) - }, - onDismiss = { showAddToMenu = false }, - ) - } - if (showUnloveConfirm) { - TwoButtonDialog( - title = "确认取消喜欢?", - showMessage = false, - onConfirm = { - selectedMusic().forEach { m -> - AppContainer.toggleLove(m.musicId) - } - showUnloveConfirm = false - closeSelect() - }, - onDismiss = { showUnloveConfirm = false }, - ) - } - - loadingMsg?.let { LoadingOverlay(it) } - if (showOverwriteData) { - TwoButtonDialog( - title = "已是最新版本,是否覆盖?", - showMessage = false, - onConfirm = { importData(force = true) }, - onDismiss = { showOverwriteData = false }, - ) - } - if (showAlreadyLatest) { - TwoButtonDialog( - title = "当前已是最新版本", - showMessage = false, - confirmText = "知道了", - cancelText = "关闭", - onConfirm = { showAlreadyLatest = false }, - onDismiss = { showAlreadyLatest = false }, - ) - } - updateInfo?.let { info -> - UpdateVersionDialog( - versionName = info.version, - changelog = info.changelog, - onUpdate = { - val url = info.apkUrl - if (url.isNotBlank()) { - top.zhushenwudi.llmp.platform.PlatformUi.openExternalUrl(url) - } else { - top.zhushenwudi.llmp.platform.PlatformUi.showToast("未提供下载地址") - } - updateInfo = null - }, - onDismiss = { updateInfo = null }, - ) - } - } -} +package com.zhushenwudi.lovelivemusicplayer.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.EaseOutQuad +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +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.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.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryDetail +import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab +import com.zhushenwudi.lovelivemusicplayer.navigation.LlmpTransitionMs +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalNavAnimatedVisibilityScope +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalOpenLibraryDetail +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalSharedTransitionScope +import com.zhushenwudi.lovelivemusicplayer.navigation.Routes +import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection +import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.settings.withSortOrder +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.components.AddToMenuDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.AppDrawer +import com.zhushenwudi.lovelivemusicplayer.ui.components.BatchActionBar +import com.zhushenwudi.lovelivemusicplayer.ui.components.LibrarySelectMode +import com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar +import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay +import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicGroupLogoButton +import com.zhushenwudi.lovelivemusicplayer.ui.components.SongLibraryTopBar +import com.zhushenwudi.lovelivemusicplayer.ui.components.TopLibraryTabs +import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoButtonDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.UpdateVersionDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.isDefaultGroupLogo +import com.zhushenwudi.lovelivemusicplayer.ui.player.FullPlayerScreen +import com.zhushenwudi.lovelivemusicplayer.ui.player.LocalMiniPlayerHaze +import com.zhushenwudi.lovelivemusicplayer.ui.player.MiniPlayerBar +import com.zhushenwudi.lovelivemusicplayer.ui.screens.AlbumDetailScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.ArtistDetailScreen +import com.zhushenwudi.lovelivemusicplayer.ui.screens.LibraryTabContent +import com.zhushenwudi.lovelivemusicplayer.ui.screens.MenuDetailScreen +import com.zhushenwudi.lovelivemusicplayer.ui.scroll.LibraryScrollToTopHandler +import com.zhushenwudi.lovelivemusicplayer.ui.scroll.StatusBarScrollToTopTap +import com.zhushenwudi.lovelivemusicplayer.ui.scroll.rememberStatusBarScrollToTopAction +import com.zhushenwudi.lovelivemusicplayer.ui.theme.HomeListChrome +import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset +import com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity +import com.zhushenwudi.lovelivemusicplayer.ui.theme.asUnscaledInScaledContext +import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledNavigationBarsBottom +import com.zhushenwudi.lovelivemusicplayer.ui.theme.unscaledStatusBarsTop +import com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun MainShell( + onOpenSettings: () -> Unit, + onOpenRoute: (String) -> Unit, + isTopDestination: Boolean = true, +) { + // 从 HomeUiState 恢复标签页(详情导航后仍保留)。 + val restoredPage = HomeUiState.pageIndex.coerceIn(0, 5) + var topTabIndex by remember { mutableIntStateOf(if (restoredPage > 2) 1 else 0) } + var bottomIndex by remember { mutableIntStateOf(restoredPage % 3) } + var refreshToken by remember { mutableStateOf(0) } + var currentGroup by remember { mutableStateOf(AppContainer.currentGroup) } + var songsSort by remember { mutableStateOf(AppContainer.settings.sortOrder) } + var albumsSort by remember { mutableStateOf(AppContainer.settings.albumsSortOrder) } + var loveSort by remember { mutableStateOf(AppContainer.settings.loveSortOrder) } + var recentSort by remember { mutableStateOf(AppContainer.settings.recentSortOrder) } + var selectMode by remember { mutableStateOf(LibrarySelectMode.Normal) } + var searchDraft by remember { mutableStateOf("") } + var searchQuery by remember { mutableStateOf("") } + var checkedIds by remember { mutableStateOf(setOf()) } + var showAddToMenu by remember { mutableStateOf(false) } + var showUnloveConfirm by remember { mutableStateOf(false) } + var loadingMsg by remember { mutableStateOf(null) } + var showOverwriteData by remember { mutableStateOf(false) } + var updateInfo by remember { + mutableStateOf(null) + } + var showAlreadyLatest by remember { mutableStateOf(false) } + // 嵌套详情叠加在主页上,主页保持组合以实现流畅 Hero 动画。 + var libraryDetail by remember { mutableStateOf(null) } + var retainedDetail by remember { mutableStateOf(null) } + SideEffect { + if (libraryDetail != null) retainedDetail = libraryDetail + } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val dark = LocalDarkTheme.current + val page = topTabIndex * 3 + bottomIndex + val libraryTab = LibraryTab.fromPage(page) + val section = if (topTabIndex == 0) TopSection.LIBRARY else TopSection.MINE + val weSlide = AppContainer.weSlide + val weSlideFooter = AppContainer.weSlideFooter + // 迷你播放器毛玻璃的 Haze 背景源 + val miniPlayerHaze = rememberHazeState() + // 选择模式 > 0 时锁定歌库/我的标签与 WeSlide 手势 + val batchLock = selectMode == LibrarySelectMode.Batch + val detailOpen = libraryDetail != null + LaunchedEffect(weSlide.isOpened, detailOpen) { + if (weSlide.isOpened || detailOpen) { + weSlideFooter.hide() + } else { + weSlideFooter.show() + } + } + + fun openLibraryDetail(detail: LibraryDetail) { + checkedIds = emptySet() + selectMode = LibrarySelectMode.Normal + weSlide.hide() + libraryDetail = detail + } + + fun openRoute(route: String) { + val detail = Routes.parseLibraryDetail(route) + if (detail != null) { + openLibraryDetail(detail) + } else { + onOpenRoute(route) + } + } + + fun closeLibraryDetail() { + libraryDetail = null + } + + DetailBackHandler(enabled = isTopDestination && detailOpen, onBack = ::closeLibraryDetail) + DetailBackHandler( + enabled = isTopDestination && weSlide.isOpened && !detailOpen, + onBack = { weSlide.hide() }, + ) + DetailBackHandler( + enabled = isTopDestination && + !detailOpen && + !weSlide.isOpened && + drawerState.currentValue == DrawerValue.Closed, + onBack = { PlatformUi.moveTaskToBack() }, + ) + // 6 页横向滑动,同步当前页与顶部标签 + val contentPagerState = rememberPagerState( + initialPage = restoredPage, + pageCount = { 6 }, + ) + + LaunchedEffect(batchLock) { + weSlide.interactionLocked = batchLock + if (batchLock) { + weSlide.hide() + // 选择模式 > 0 时禁止打开抽屉 + if (drawerState.isOpen) drawerState.close() + } + } + + fun applyPage(index: Int) { + val safe = index.coerceIn(0, 5) + topTabIndex = if (safe > 2) 1 else 0 + bottomIndex = safe % 3 + HomeUiState.pageIndex = safe + } + + val latestPage = rememberUpdatedState(page) + + // 标签/底栏 → 分页器滚动的唯一驱动,快速点击时取消上次动画。 + // 歌库/我的切换时直接跳转页面,不滑过中间页。 + LaunchedEffect(page) { + HomeUiState.pageIndex = page + if (contentPagerState.settledPage != page) { + val distance = kotlin.math.abs(contentPagerState.settledPage - page) + if (distance > 1) { + contentPagerState.scrollToPage(page) + } else { + contentPagerState.animateScrollToPage(page) + } + } + } + // 分页器滑动 → 标签。滚动中忽略 settle,避免已取消/过期的 + // 动画覆盖较新的歌库/我的选择。 + LaunchedEffect(contentPagerState) { + snapshotFlow { contentPagerState.settledPage } + .distinctUntilChanged() + .collect { settled -> + if (contentPagerState.isScrollInProgress) return@collect + if (settled != latestPage.value) { + applyPage(settled) + } + } + } + + // 存储根/SD 路径变更后修复过期的 existFile(轻量 exists 扫描)。 + LaunchedEffect(Unit) { + runCatching { + AppContainer.library.rescanExistFiles(AppContainer.pathResolver) + } + refreshToken++ + } + + // HTTP 关闭时按本地 existFile 过滤曲库。 + // 正倒序按页独立计算,不写进查询 key。 + val enableHttp = AppContainer.settings.enableHttp + val historyRevision by AppContainer.historyRevision.collectAsState() + val loveRevision by AppContainer.loveRevision.collectAsState() + val libraryRevision by AppContainer.libraryRevision.collectAsState() + val libraryLists = remember(refreshToken, currentGroup, enableHttp, libraryRevision) { + runCatching { + AppContainer.library.findAllListByGroup( + group = currentGroup.displayName, + enableHttp = enableHttp, + ) + }.getOrElse { + com.zhushenwudi.lovelivemusicplayer.db.LibraryLists( + music = emptyList(), + albums = emptyList(), + artists = emptyList(), + loved = emptyList(), + recent = emptyList(), + menus = emptyList(), + ) + } + } + var music by remember { mutableStateOf(emptyList()) } + var albums by remember { mutableStateOf(emptyList()) } + var artists by remember { mutableStateOf(libraryLists.artists) } + var menus by remember { mutableStateOf(libraryLists.menus) } + var loved by remember { mutableStateOf(emptyList()) } + var recent by remember { mutableStateOf(emptyList()) } + LaunchedEffect(libraryLists) { + music = libraryLists.music + albums = libraryLists.albums + artists = libraryLists.artists + menus = libraryLists.menus + loved = libraryLists.loved + recent = libraryLists.recent + } + // 播放历史更新时仅刷新最近播放列表。 + LaunchedEffect(historyRevision, currentGroup, enableHttp) { + if (historyRevision == 0L) return@LaunchedEffect + runCatching { + AppContainer.library.findAllListByGroup( + group = currentGroup.displayName, + enableHttp = enableHttp, + ).recent + }.getOrNull()?.let { recent = it } + } + // 收藏切换时刷新收藏列表。 + LaunchedEffect(loveRevision, currentGroup, enableHttp) { + if (loveRevision == 0L) return@LaunchedEffect + runCatching { + AppContainer.library.findAllListByGroup( + group = currentGroup.displayName, + enableHttp = enableHttp, + ) + }.getOrNull()?.let { lists -> + loved = lists.loved + // 同步歌曲/最近播放行的 isLove 与 Music.isLove 列。 + fun patch(list: List) = list.map { m -> + val id = m.musicId ?: return@map m + val love = AppContainer.library.getMusicById(id)?.isLove == true + if (m.isLove == love) m else m.copy(isLove = love) + } + music = patch(music) + recent = patch(recent) + } + } + // 搜索模式:在歌曲/收藏/最近播放中按歌名过滤。 + val displayMusic = remember(music, songsSort, searchQuery, selectMode) { + val ordered = music.withSortOrder(songsSort) + if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { + ordered.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } + } else ordered + } + val displayAlbums = remember(albums, albumsSort) { + albums.withSortOrder(albumsSort) + } + val displayLoved = remember(loved, loveSort, searchQuery, selectMode) { + val ordered = loved.withSortOrder(loveSort) + if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { + ordered.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } + } else ordered + } + val displayRecent = remember(recent, recentSort, searchQuery, selectMode) { + val ordered = recent.withSortOrder(recentSort) + if (selectMode == LibrarySelectMode.Search && searchQuery.isNotBlank()) { + ordered.filter { it.musicName?.contains(searchQuery, ignoreCase = true) == true } + } else ordered + } + val currentSortAsc = when (libraryTab) { + LibraryTab.SONGS -> songsSort != "DESC" + LibraryTab.ALBUMS -> albumsSort != "DESC" + LibraryTab.LOVE -> loveSort != "DESC" + LibraryTab.RECENT -> recentSort != "DESC" + else -> true + } + val playingId by remember { + AppContainer.player.state.map { it.current?.musicId }.distinctUntilChanged() + }.collectAsState(AppContainer.player.state.value.current?.musicId) + + fun currentSongList(): List = when (libraryTab) { + LibraryTab.SONGS -> displayMusic + LibraryTab.LOVE -> displayLoved + LibraryTab.RECENT -> displayRecent + else -> emptyList() + } + + fun playMusic(item: Music) { + if (selectMode == LibrarySelectMode.Batch) return + val queue = when (libraryTab) { + LibraryTab.SONGS -> displayMusic + LibraryTab.LOVE -> displayLoved + LibraryTab.RECENT -> displayRecent + else -> listOf(item) + }.ifEmpty { listOf(item) } + val index = queue.indexOfFirst { it.musicId == item.musicId }.coerceAtLeast(0) + val prevId = AppContainer.player.state.value.current?.musicId + AppContainer.player.setQueue(queue, index) + AppContainer.player.setLoopMode(AppContainer.settings.loopMode) + AppContainer.player.play() + AppContainer.persistPlayQueue(queue, item.musicId) + // 切歌绑定器覆盖新曲;同 id 重播需显式触发历史更新。 + if (item.musicId != null && item.musicId == prevId) { + AppContainer.recordPlayingHistory(item.musicId) + } + } + + fun playAll() { + val list = when (libraryTab) { + LibraryTab.SONGS -> displayMusic + LibraryTab.LOVE -> displayLoved + LibraryTab.RECENT -> displayRecent + LibraryTab.ALBUMS, LibraryTab.ARTISTS, LibraryTab.MENUS -> displayMusic + } + list.firstOrNull()?.let(::playMusic) + } + + fun sortItem() { + fun toggle(current: String): String = if (current == "ASC") "DESC" else "ASC" + when (libraryTab) { + LibraryTab.SONGS -> { + songsSort = toggle(songsSort) + AppContainer.settings.sortOrder = songsSort + } + LibraryTab.ALBUMS -> { + albumsSort = toggle(albumsSort) + AppContainer.settings.albumsSortOrder = albumsSort + } + LibraryTab.LOVE -> { + loveSort = toggle(loveSort) + AppContainer.settings.loveSortOrder = loveSort + } + LibraryTab.RECENT -> { + recentSort = toggle(recentSort) + AppContainer.settings.recentSortOrder = recentSort + } + LibraryTab.ARTISTS, LibraryTab.MENUS -> Unit + } + } + + fun closeSelect() { + checkedIds = emptySet() + selectMode = LibrarySelectMode.Normal + } + + fun selectedMusic(): List { + val ids = checkedIds + return currentSongList().filter { it.musicId != null && it.musicId in ids } + } + + fun addMusicListToPlayer(list: List) { + if (list.isEmpty()) return + val st = AppContainer.player.state.value + if (st.queue.isEmpty()) { + AppContainer.player.setQueue(list, 0) + AppContainer.player.setLoopMode(AppContainer.settings.loopMode) + AppContainer.player.play() + AppContainer.persistPlayQueue() + PlatformUi.showToast(I18n.t("add_success")) + closeSelect() + return + } + val existing = st.queue.mapNotNull { it.musicId }.toSet() + val toAdd = list.filter { it.musicId !in existing } + if (toAdd.isEmpty()) { + PlatformUi.showToast(I18n.t("add_success")) + closeSelect() + return + } + val newQueue = st.queue + toAdd + val idx = st.index.coerceAtLeast(0) + val wasPlaying = st.isPlaying + AppContainer.player.setQueue(newQueue, idx) + if (wasPlaying) AppContainer.player.play() + AppContainer.persistPlayQueue() + PlatformUi.showToast(I18n.t("add_success")) + closeSelect() + } + + val countLabel = when (libraryTab) { + LibraryTab.SONGS -> tr("count_songs", displayMusic.size) + LibraryTab.ALBUMS -> tr("count_albums", displayAlbums.size) + LibraryTab.ARTISTS -> tr("count_artists", artists.size) + LibraryTab.LOVE -> tr("count_songs", displayLoved.size) + LibraryTab.MENUS -> tr("count_menus", menus.size) + LibraryTab.RECENT -> tr("count_songs", displayRecent.size) + } + val batchSelectable = libraryTab == LibraryTab.SONGS || + libraryTab == LibraryTab.LOVE || + libraryTab == LibraryTab.RECENT + val selectAllChecked = + remember(checkedIds, libraryTab, displayMusic, displayLoved, displayRecent) { + val list = currentSongList() + list.isNotEmpty() && list.all { it.musicId != null && it.musicId in checkedIds } + } + + val shellBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary + val footerNavInset = unscaledNavigationBarsBottom() + val footerHeightUnscaled = when { + selectMode == LibrarySelectMode.Batch && batchSelectable -> 102.dp + footerNavInset + else -> HomeListChrome.BottomBarContentHeight + footerNavInset + } + val miniPlayerHeight = HomeListChrome.MiniPlayerHeight + val footerHeight = footerHeightUnscaled.asUnscaledInScaledContext() + val panelMinSize = miniPlayerHeight + HomeListChrome.MiniPlayerFooterGap + footerHeight + val statusBarTop = unscaledStatusBarsTop().asUnscaledInScaledContext() + + fun importData(force: Boolean) { + scope.launch { + loadingMsg = I18n.t("importing") + runCatching { + AppContainer.dataImport.fetchAndImport(force = force) + }.onSuccess { + runCatching { + AppContainer.library.rescanExistFiles(AppContainer.pathResolver) + } + AppContainer.refreshCarPlayCatalog() + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.showToast( + I18n.t("data_update_done_ver", it.version, it.musicCount), + ) + refreshToken++ + }.onFailure { + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.showToast( + it.message ?: I18n.t("fetch_songs_fail"), + ) + } + loadingMsg = null + } + } + + fun handleUpdateData() { + scope.launch { + loadingMsg = I18n.t("downloading_short") + val remote = AppContainer.dataImport.fetchRemoteVersion() + loadingMsg = null + if (remote == null) { + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.showToast(I18n.t("fetch_songs_fail_or_net")) + return@launch + } + val local = AppContainer.dataImport.localVersion() + when { + local == remote -> showOverwriteData = true + local < remote -> importData(force = true) + } + } + } + + fun handleCheckUpdate() { + scope.launch { + loadingMsg = I18n.t("check_update") + if (PlatformInfo.isIos) { + val needUpdate = runCatching { AppContainer.updateChecker.checkIos() }.getOrNull() + loadingMsg = null + if (needUpdate == true) { + PlatformUi.openExternalUrl(Const.APPSTORE_PAGE_URL) + } else { + showAlreadyLatest = true + } + return@launch + } + val info = runCatching { AppContainer.updateChecker.check(1) }.getOrNull() + loadingMsg = null + if (info == null) { + showAlreadyLatest = true + } else { + updateInfo = info + } + } + } + + // Rtl 包裹使抽屉从右侧打开。 + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, + ) { + ModalNavigationDrawer( + drawerState = drawerState, + // 批量模式:禁用手势开关抽屉,Logo 按钮亦禁用。 + gesturesEnabled = !batchLock && drawerState.isOpen && !weSlide.isOpened, + drawerContent = { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + BoxWithConstraints(Modifier.fillMaxHeight()) { + // 与原项目 endDrawer:min(0.35 * 屏高, 屏宽),不用 Material 默认 240–360dp。 + val drawerWidth = min(maxHeight * 0.35f, maxWidth) + Surface( + color = if (dark) ColorMs.NightPrimary else ColorMs.White, + modifier = Modifier + .align(Alignment.CenterEnd) + .width(drawerWidth) + .fillMaxHeight(), + ) { + AppDrawer( + onClose = { scope.launch { drawerState.close() } }, + onOpenRoute = ::openRoute, + onOpenSettings = onOpenSettings, + onUpdateData = { handleUpdateData() }, + onCheckUpdate = { handleCheckUpdate() }, + onShare = { + com.zhushenwudi.lovelivemusicplayer.share.QqShare.shareApp() + }, + currentGroup = currentGroup, + onGroupSelect = { key -> + currentGroup = key + AppContainer.currentGroup = key + // 切换团组时保持抽屉打开,列表随 currentGroup 刷新。 + }, + version = "0.2.0", + ) + } + } + } + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Ltr, + LocalMiniPlayerHaze provides miniPlayerHaze, + LocalHomeListBottomInset provides HomeListChrome.ListBottomInset, + ) { + WeSlide( + controller = weSlide, + footerController = weSlideFooter, + panelMinSize = panelMinSize, + footerHeight = footerHeight, + // 收起面板区域须保持清晰,body 自行绘制背景。 + backgroundColor = shellBg, + overlay = true, + overlayColor = shellBg, + // WeSlide body 遮罩:深色 0.4 / 浅色 0.15 + overlayOpacity = if (dark) 0.4f else 0.15f, + // 面板打开时 body 模糊;迷你栏毛玻璃由 Haze 单独处理。 + blur = true, + blurSigma = 5.dp, + blurColor = Color.Transparent, + parallax = true, + transformScale = true, + hideFooter = true, + hidePanelHeader = true, + isUpSlide = false, + isDismissible = !batchLock && !detailOpen, + body = { + // 主页保持组合;详情打开时列表项隐藏 Hero 源 + // (共享元素模式)保持匹配稳定。 + SharedTransitionLayout(Modifier.fillMaxSize()) { + CompositionLocalProvider( + LocalSharedTransitionScope provides this, + LocalOpenLibraryDetail provides libraryDetail, + ) { + Box( + Modifier + .fillMaxSize() + .hazeSource(state = miniPlayerHaze), + ) { + Column( + Modifier + .fillMaxSize() + .background(shellBg) + .padding(top = statusBarTop), + ) { + Row( + Modifier + .fillMaxWidth() + .height(60.dp) + .padding(start = 8.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TopLibraryTabs( + selected = section, + interactionEnabled = !batchLock && !detailOpen, + onSelect = { next -> + topTabIndex = + if (next == TopSection.LIBRARY) 0 else 1 + }, + modifier = Modifier.weight(1f), + ) + // 顶部团组 Logo 按钮,随 currentGroup 变化。 + NeumorphicGroupLogoButton( + group = currentGroup, + onClick = if (batchLock || detailOpen) { + null + } else { + { scope.launch { drawerState.open() } } + }, + size = 36.dp, + background = if (currentGroup.isDefaultGroupLogo()) { + Color(0x4DFFAE00) + } else { + shellBg + }, + contentPadding = if (currentGroup.isDefaultGroupLogo()) { + 3.dp + } else { + 0.dp + }, + hasShadow = true, + ) + } + + if (libraryTab != LibraryTab.ARTISTS && + libraryTab != LibraryTab.MENUS + ) { + SongLibraryTopBar( + countLabel = countLabel, + libraryTab = libraryTab, + selectMode = selectMode, + sortAsc = currentSortAsc, + searchQuery = searchDraft, + checkedCount = checkedIds.size, + selectAll = selectAllChecked, + onPlayAll = ::playAll, + onSearchOpen = { + selectMode = LibrarySelectMode.Search + searchDraft = "" + searchQuery = "" + }, + onSearchQueryChange = { searchDraft = it }, + onSearchSubmit = { searchQuery = it }, + onSearchCancel = { + searchDraft = "" + searchQuery = "" + selectMode = LibrarySelectMode.Normal + }, + onSort = ::sortItem, + onBatchOpen = { + if (batchSelectable) { + checkedIds = emptySet() + selectMode = LibrarySelectMode.Batch + } + }, + onSelectAll = { checked -> + val list = currentSongList() + checkedIds = if (checked) { + list.mapNotNull { it.musicId }.toSet() + } else { + emptySet() + } + }, + onBatchFinish = { closeSelect() }, + ) + Spacer(Modifier.height(4.dp)) + } + + HorizontalPager( + state = contentPagerState, + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalAlignment = Alignment.Top, + beyondViewportPageCount = 1, + userScrollEnabled = !weSlide.isOpened && + !detailOpen && + selectMode == LibrarySelectMode.Normal, + ) { pagerPage -> + LibraryTabContent( + tab = LibraryTab.fromPage(pagerPage), + music = displayMusic, + albums = displayAlbums, + artists = artists, + menus = menus, + loved = displayLoved, + recent = displayRecent, + playingId = playingId, + selectMode = selectMode == LibrarySelectMode.Batch && + LibraryTab.fromPage(pagerPage) in setOf( + LibraryTab.SONGS, + LibraryTab.LOVE, + LibraryTab.RECENT, + ), + checkedIds = checkedIds, + onCheckedChange = { id, checked -> + checkedIds = if (checked) { + checkedIds + id + } else { + checkedIds - id + } + }, + onPlay = ::playMusic, + onOpenAlbum = { + openLibraryDetail(LibraryDetail.Album(it)) + }, + onOpenArtist = { + openLibraryDetail(LibraryDetail.Artist(it)) + }, + onOpenMenu = { + openLibraryDetail(LibraryDetail.Menu(it)) + }, + onRefreshLibrary = { refreshToken++ }, + ) + } + } + + // 进入:右滑;退出:页面渐隐 + 封面 Hero(零位移保持转场时长)。 + AnimatedVisibility( + visible = detailOpen, + enter = slideInHorizontally( + animationSpec = tween( + LlmpTransitionMs, + easing = EaseOutQuad, + ), + initialOffsetX = { it }, + ) + fadeIn( + animationSpec = tween( + LlmpTransitionMs, + easing = EaseOutQuad, + ), + ), + exit = slideOutHorizontally( + animationSpec = tween( + LlmpTransitionMs, + easing = EaseOutQuad, + ), + targetOffsetX = { 0 }, + ), + modifier = Modifier.fillMaxSize(), + ) { + CompositionLocalProvider( + LocalNavAnimatedVisibilityScope provides this, + ) { + when (val detail = retainedDetail) { + is LibraryDetail.Album -> AlbumDetailScreen( + albumId = detail.albumId, + onBack = ::closeLibraryDetail, + onOpenPlayer = { + closeLibraryDetail() + weSlide.show() + }, + ) + + is LibraryDetail.Artist -> ArtistDetailScreen( + artistId = detail.artistId, + onBack = ::closeLibraryDetail, + onOpenPlayer = { + closeLibraryDetail() + weSlide.show() + }, + ) + + is LibraryDetail.Menu -> MenuDetailScreen( + menuId = detail.menuId, + onBack = ::closeLibraryDetail, + onOpenPlayer = { + closeLibraryDetail() + weSlide.show() + }, + ) + + null -> Unit + } + } + } + + // 点击状态栏将当前曲库列表滚回顶部。 + if (!detailOpen) { + LibraryScrollToTopHandler(libraryTab) + StatusBarScrollToTopTap( + height = statusBarTop, + enabled = !batchLock && !weSlide.isOpened, + onScrollToTop = rememberStatusBarScrollToTopAction(), + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + } + } + }, + panelHeader = { + MiniPlayerBar( + onOpenPlayer = { weSlide.show() }, + interactionEnabled = !batchLock, + ) + }, + panel = { + FullPlayerScreen( + onBack = { weSlide.hide() }, + onOpenAlbum = { openLibraryDetail(LibraryDetail.Album(it)) }, + ) + }, + footer = { + ProvideUnscaledDensity { + if (detailOpen) { + // 详情页不画底栏,只保留槽位高度动画给迷你条下移。 + } else if (selectMode == LibrarySelectMode.Batch && batchSelectable) { + BatchActionBar( + showUnlove = libraryTab == LibraryTab.LOVE, + onAddToPlaylist = { addMusicListToPlayer(selectedMusic()) }, + onAddToMenu = { + if (selectedMusic().isNotEmpty()) showAddToMenu = true + }, + onUnlove = { + if (selectedMusic().isNotEmpty()) showUnloveConfirm = true + }, + ) + } else { + LlmpBottomBar( + modifier = Modifier.fillMaxSize(), + section = section, + selectedIndex = bottomIndex, + onSelect = { index -> + bottomIndex = index + }, + onSectionChange = { next -> + val top = if (next == TopSection.LIBRARY) 0 else 1 + if (topTabIndex != top) { + topTabIndex = top + } + }, + ) + } + } + }, + ) + } + } + } + + if (showAddToMenu) { + AddToMenuDialog( + musicList = selectedMusic(), + onDismiss = { showAddToMenu = false }, + onMenuChanged = { ok -> + if (ok) { + closeSelect() + refreshToken++ + } + }, + ) + } + if (showUnloveConfirm) { + TwoButtonDialog( + title = tr("confirm_unlove"), + showMessage = false, + onConfirm = { + selectedMusic().forEach { m -> + AppContainer.toggleLove(m.musicId) + } + showUnloveConfirm = false + closeSelect() + }, + onDismiss = { showUnloveConfirm = false }, + ) + } + + loadingMsg?.let { LoadingOverlay(it) } + if (showOverwriteData) { + TwoButtonDialog( + title = tr("now_is_latest"), + showMessage = false, + onConfirm = { importData(force = true) }, + onDismiss = { showOverwriteData = false }, + ) + } + if (showAlreadyLatest) { + TwoButtonDialog( + title = tr("no_need_update"), + showMessage = false, + confirmText = tr("got_it"), + cancelText = tr("close"), + onConfirm = { showAlreadyLatest = false }, + onDismiss = { showAlreadyLatest = false }, + ) + } + updateInfo?.let { info -> + UpdateVersionDialog( + versionName = info.version, + changelog = info.changelog, + onUpdate = { + val url = info.apkUrl + if (url.isNotBlank()) { + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openExternalUrl(url) + } else { + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.showToast(I18n.t("no_download_url")) + } + updateInfo = null + }, + onDismiss = { updateInfo = null }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AlbumGridItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AlbumGridItem.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt index 0f0432a..b2bc799 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AlbumGridItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt @@ -1,12 +1,10 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.material3.Text import androidx.compose.runtime.Composable @@ -17,14 +15,14 @@ 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 top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.navigation.HeroCoverSlot -import top.zhushenwudi.llmp.navigation.HeroKeys -import top.zhushenwudi.llmp.navigation.HeroRoundedClip -import top.zhushenwudi.llmp.navigation.LocalOpenLibraryDetail -import top.zhushenwudi.llmp.navigation.hidesAlbum -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroCoverSlot +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroKeys +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroRoundedClip +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalOpenLibraryDetail +import com.zhushenwudi.lovelivemusicplayer.navigation.hidesAlbum +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme @Composable fun AlbumGridItem( @@ -39,7 +37,8 @@ fun AlbumGridItem( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 5.dp, vertical = 4.dp), + // Material 3 双行网格单元内边距。 + .padding(horizontal = 5.dp, vertical = AlbumGridItemVerticalPadding), horizontalAlignment = Alignment.CenterHorizontally, ) { HeroCoverSlot( @@ -56,7 +55,6 @@ fun AlbumGridItem( modifier = Modifier.fillMaxSize().then(heroMod), ) } - Spacer(Modifier.height(6.dp)) Text( text = album.albumName.orEmpty(), color = if (dark) ColorMs.White else ColorMs.Text333, @@ -65,6 +63,7 @@ fun AlbumGridItem( maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, + lineHeight = 16.sp, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt new file mode 100644 index 0000000..8e4b5e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AppDrawer.kt @@ -0,0 +1,360 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import org.jetbrains.compose.resources.ExperimentalResourceApi +import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.Routes +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.theme.neuShadowColor +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp + +data class DrawerMenuItem( + val titleKey: String, + val route: String?, + val action: DrawerAction = DrawerAction.Navigate, + val icon: SvgAsset, +) + +enum class DrawerAction { + Navigate, + UpdateData, + CheckUpdate, + Share, +} + +/** 与 Flutter `DrawerLayout` 对齐的抽屉尺寸。 */ +private object DrawerLayout { + val logoSlotHeight = 130.dp + val defaultLogoSize = 165.dp + val groupLogoSize = 130.dp + val logoToTitleGap = 5.dp + val titleFontSize = 17.3.sp + val headerToGroupsGap = 10.dp + val groupRowGap = 12.dp + val groupsToMenuGap = 10.dp + val groupButtonWidth = 120.dp + val groupButtonHeight = 50.dp + val groupButtonGap = 10.dp + val menuOuterPaddingH = 8.dp + val menuInnerPaddingH = 10.dp + val menuInnerPaddingV = 12.dp + val menuItemGap = 8.dp + val menuToVersionGap = 6.dp + val versionHeight = 24.dp + val versionFontSize = 12.sp +} + +private data class GroupLogo( + val key: GroupKey, + val asset: SvgAsset, +) + +@OptIn(ExperimentalResourceApi::class) +@Composable +fun AppDrawer( + onClose: () -> Unit, + onOpenRoute: (String) -> Unit, + onOpenSettings: () -> Unit, + onUpdateData: () -> Unit = {}, + onCheckUpdate: () -> Unit = {}, + onShare: () -> Unit = {}, + currentGroup: GroupKey = GroupKey.GROUP_ALL, + onGroupSelect: (GroupKey) -> Unit = {}, + version: String = "0.2.0", + modifier: Modifier = Modifier, +) { + val dark = LocalDarkTheme.current + val bg = if (dark) ColorMs.NightPrimary else ColorMs.White + val groups = listOf( + GroupLogo(GroupKey.GROUP_ALL, SvgAsset("drawable/drawer_logo_lovelive.svg")), + GroupLogo(GroupKey.GROUP_US, SvgAsset("drawable/drawer_logo_us.svg")), + GroupLogo(GroupKey.GROUP_AQOURS, SvgAsset("drawable/drawer_logo_aqours.svg")), + GroupLogo(GroupKey.GROUP_NIJIGASAKI, SvgAsset("drawable/drawer_logo_nijigasaki.svg")), + GroupLogo(GroupKey.GROUP_LIELLA, SvgAsset("drawable/drawer_logo_liella.svg")), + GroupLogo(GroupKey.GROUP_HASUNOSORA, SvgAsset("drawable/drawer_logo_hasunosora.svg")), + GroupLogo( + GroupKey.GROUP_YOHANE, + SvgAsset( + if (dark) "drawable/drawer_logo_yohane_night.svg" + else "drawable/drawer_logo_yohane_day.svg", + ), + ), + GroupLogo(GroupKey.GROUP_MUSICAL, SvgAsset("drawable/drawer_logo_musical.svg")), + GroupLogo(GroupKey.GROUP_BLUEBIRD, SvgAsset("drawable/drawer_logo_bluebird.svg")), + GroupLogo(GroupKey.GROUP_COMBINE, SvgAsset("drawable/drawer_logo_allstars.svg")), + ) + val groupRows = groups.chunked(2) + val menus = listOf( + DrawerMenuItem( + "daily_news", + Routes.DAILY, + icon = SvgAsset("drawable/drawer_drawer_calendar.svg") + ), + DrawerMenuItem( + "music_trans", + Routes.TRANSFER, + icon = SvgAsset("drawable/drawer_drawer_quick_trans.svg") + ), + DrawerMenuItem( + "data_sync", + Routes.DATA_SYNC, + icon = SvgAsset("drawable/drawer_drawer_data_sync.svg") + ), + DrawerMenuItem( + "fetch_songs", + null, + DrawerAction.UpdateData, + SvgAsset("drawable/drawer_drawer_data_download.svg") + ), + DrawerMenuItem( + "drive_mode", + Routes.DRIVE_MODE, + icon = SvgAsset("drawable/drawer_drawer_car.svg") + ), + DrawerMenuItem( + "update", + null, + DrawerAction.CheckUpdate, + SvgAsset("drawable/drawer_drawer_update.svg") + ), + DrawerMenuItem( + "system_settings", + Routes.SETTINGS, + DrawerAction.Navigate, + SvgAsset("drawable/drawer_drawer_setting.svg") + ), + DrawerMenuItem( + "share", + null, + DrawerAction.Share, + SvgAsset("drawable/drawer_drawer_share.svg") + ), + ) + // 根据 currentGroup 获取团组 Logo + val topLogoSize = if (currentGroup.isDefaultGroupLogo()) { + DrawerLayout.defaultLogoSize + } else { + DrawerLayout.groupLogoSize + } + + Column( + modifier = modifier + .fillMaxSize() + .background(bg) + .statusBarsPadding() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(DrawerLayout.logoSlotHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + GroupLogoImage( + group = currentGroup, + modifier = Modifier.size(topLogoSize), + ) + } + Spacer(Modifier.height(DrawerLayout.logoToTitleGap)) + Text( + text = "LoveLiveMusicPlayer", + color = if (dark) ColorMs.EDF5FF else ColorMs.Text333, + fontSize = DrawerLayout.titleFontSize, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(DrawerLayout.headerToGroupsGap)) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = DrawerLayout.menuOuterPaddingH) + .shadow( + elevation = 6.dp, + shape = RoundedCornerShape(8.dp), + ambientColor = neuShadowColor(dark), + spotColor = neuShadowColor(dark), + ) + .clip(RoundedCornerShape(8.dp)) + .background(bg) + .padding( + horizontal = DrawerLayout.menuInnerPaddingH, + vertical = DrawerLayout.menuInnerPaddingV, + ) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + groupRows.forEachIndexed { index, row -> + Row( + horizontalArrangement = Arrangement.spacedBy(DrawerLayout.groupButtonGap), + verticalAlignment = Alignment.CenterVertically, + ) { + row.forEach { group -> + key(group.asset.path) { + GroupButton( + group = group, + selected = currentGroup == group.key, + onClick = { onGroupSelect(group.key) }, + modifier = Modifier + .width(DrawerLayout.groupButtonWidth) + .height(DrawerLayout.groupButtonHeight), + ) + } + } + } + if (index < groupRows.lastIndex) { + Spacer(Modifier.height(DrawerLayout.groupRowGap)) + } + } + Spacer(Modifier.height(DrawerLayout.groupsToMenuGap)) + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DrawerLayout.menuItemGap), + ) { + menus.forEach { item -> + DrawerFunctionRow( + item = item, + onClick = { + when (item.action) { + // 原项目数据更新 / 版本升级不关抽屉,弹窗叠在抽屉上。 + DrawerAction.UpdateData -> onUpdateData() + DrawerAction.CheckUpdate -> onCheckUpdate() + DrawerAction.Share -> { + onClose() + onShare() + } + DrawerAction.Navigate -> { + onClose() + when (item.route) { + Routes.SETTINGS -> onOpenSettings() + null -> Unit + else -> onOpenRoute(item.route) + } + } + } + }, + ) + } + } + } + Spacer(Modifier.height(DrawerLayout.menuToVersionGap)) + Box( + Modifier + .fillMaxWidth() + .height(DrawerLayout.versionHeight), + contentAlignment = Alignment.Center, + ) { + Text( + "Ver.$version", + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = DrawerLayout.versionFontSize, + ) + } + } +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun GroupButton( + group: GroupLogo, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + NeumorphicSurface( + onClick = onClick, + modifier = modifier, + radius = 8.dp, + background = if (selected) { + Color.Black.copy(alpha = 0.06f) + } else { + Color.Transparent + }, + hasShadow = true, + contentPadding = 0.dp, + ) { + key(group.asset.path) { + AsyncImage( + model = Res.getUri(group.asset.path), + contentDescription = group.key.displayName, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun DrawerFunctionRow( + item: DrawerMenuItem, + onClick: () -> Unit, +) { + val dark = LocalDarkTheme.current + val interaction = remember { MutableInteractionSource() } + val iconTint = if (dark) ColorMs.White else ColorMs.Text333 + Row( + Modifier + .fillMaxWidth() + .height(30.dp) + .clickable( + interactionSource = interaction, + indication = null, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + NeumorphicIconButton( + icon = item.icon, + onClick = null, + size = 28.dp, + iconSize = 15.dp, + radius = 6.dp, + iconColor = iconTint, + hasShadow = true, + contentPadding = 5.dp, + interactionSource = interaction, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = tr(item.titleKey), + color = if (dark) ColorMs.EDF5FF else ColorMs.Text333, + fontSize = hSp(15f), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt new file mode 100644 index 0000000..be88314 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ArtistListItem.kt @@ -0,0 +1,82 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.zhushenwudi.lovelivemusicplayer.domain.Artist +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroCircleClip +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroCoverSlot +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroKeys +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalOpenLibraryDetail +import com.zhushenwudi.lovelivemusicplayer.navigation.hidesArtist +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme + +@Composable +fun ArtistListItem( + artist: Artist, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val dark = LocalDarkTheme.current + val openDetail = LocalOpenLibraryDetail.current + Row( + modifier = modifier + .fillMaxWidth() + .height(TwoLineListItemHeight) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HeroCoverSlot( + key = HeroKeys.singer(artist.uid), + visible = !openDetail.hidesArtist(artist.uid), + clipShape = HeroCircleClip, + slotModifier = Modifier.size(ListLeadingCoverSize), + ) { heroMod -> + ArtistPhotoImage( + artist = artist, + size = ListLeadingCoverSize, + modifier = Modifier.fillMaxSize().then(heroMod), + ) + } + Spacer(Modifier.width(10.dp)) + TwoLineListTextColumn( + modifier = Modifier.weight(1f), + title = { + Text( + text = artist.name, + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = ListItemTitleSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = ListItemTitleLineHeight, + ) + }, + subtitle = { + Text( + text = tr("count_songs", artist.music.size), + color = ColorMs.Gray999, + fontSize = ListItemSubtitleSize, + lineHeight = ListItemSubtitleLineHeight, + ) + }, + ) + Spacer(Modifier.width(10.dp)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BatchActionBar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BatchActionBar.kt new file mode 100644 index 0000000..d9818a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BatchActionBar.kt @@ -0,0 +1,157 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +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 com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset + +/** + * 批量操作底栏:高度 102,顶部圆角 16,等宽列, + * 新拟态 36 图标,着色 D1E0F3 / #666,标题 15sp。 + */ +@Composable +fun BatchActionBar( + showUnlove: Boolean, + onAddToPlaylist: () -> Unit, + onAddToMenu: () -> Unit, + onUnlove: () -> Unit, + modifier: Modifier = Modifier, + /** 歌单详情:删除选中曲目。 */ + showRemoveFromMenu: Boolean = false, + onRemoveFromMenu: () -> Unit = {}, +) { + val dark = LocalDarkTheme.current + val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary + val iconTint = if (dark) ColorMs.UnselectedIcon else ColorMs.Gray666 + val textColor = if (dark) ColorMs.UnselectedIcon else ColorMs.Text333 + val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + + Column( + modifier = modifier + .fillMaxWidth() + .shadow( + elevation = 16.dp, + shape = shape, + ambientColor = bg, + spotColor = bg, + ) + .clip(shape) + .background(bg) + .navigationBarsPadding(), + ) { + // 底栏内容区高度 102.h(系统导航栏上方) + Row( + Modifier + .fillMaxWidth() + .height(102.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BatchActionItem( + icon = SvgAsset("drawable/dialog_ic_add_play_list2.svg"), + title = tr("add_to_playlist_short"), + iconTint = iconTint, + textColor = textColor, + onClick = onAddToPlaylist, + modifier = Modifier.weight(1f), + ) + BatchActionItem( + icon = SvgAsset("drawable/dialog_ic_add_play_list.svg"), + title = tr("add_to_menu"), + iconTint = iconTint, + textColor = textColor, + onClick = onAddToMenu, + modifier = Modifier.weight(1f), + ) + if (showUnlove) { + BatchActionItem( + icon = SvgAsset("drawable/dialog_ic_delete_2.svg"), + title = tr("cancel_i_like"), + iconTint = iconTint, + textColor = textColor, + onClick = onUnlove, + modifier = Modifier.weight(1f), + ) + } + if (showRemoveFromMenu) { + BatchActionItem( + icon = SvgAsset("drawable/dialog_ic_delete_2.svg"), + title = tr("delete_from_menu"), + iconTint = iconTint, + textColor = textColor, + onClick = onRemoveFromMenu, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun BatchActionItem( + icon: SvgAsset, + title: String, + iconTint: androidx.compose.ui.graphics.Color, + textColor: androidx.compose.ui.graphics.Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interaction = remember { MutableInteractionSource() } + Column( + modifier = modifier + .fillMaxHeight() + .clickable( + interactionSource = interaction, + indication = null, + onClick = onClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // 新拟态按钮 36×36,无阴影 + NeumorphicIconButton( + icon = icon, + onClick = null, + size = 36.dp, + iconSize = 20.dp, + radius = 6.dp, + iconColor = iconTint, + hasShadow = false, + contentPadding = 4.dp, + interactionSource = interaction, + ) + Text( + text = title, + color = textColor, + fontSize = 15.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Spacer(Modifier.height(20.dp)) + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BottomBar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BottomBar.kt similarity index 76% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BottomBar.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BottomBar.kt index f283f81..1a4cf68 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BottomBar.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/BottomBar.kt @@ -1,16 +1,17 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Text @@ -24,17 +25,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.distinctUntilChanged -import top.zhushenwudi.llmp.navigation.BottomTab -import top.zhushenwudi.llmp.navigation.TopSection -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.SvgAsset -import top.zhushenwudi.llmp.ui.icons.TintIcon +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.BottomTab +import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon +import com.zhushenwudi.lovelivemusicplayer.ui.theme.HomeListChrome /** - * Flutter WeSlide footer: TabBarView([BottomBar1, BottomBar2]). - * Horizontal swipe switches 歌库 / 我的 and syncs with top tabs. + * WeSlide 底部栏:歌库/我的双页切换。 + * + * 外层 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 的 footer 槽位高度已含导航栏安全区,此处不再叠加 inset, + * 仅在完整槽位内垂直居中标签行。 */ @Composable fun LlmpBottomBar( @@ -45,6 +50,7 @@ fun LlmpBottomBar( modifier: Modifier = Modifier, ) { val dark = LocalDarkTheme.current + val tabStripHeight = HomeListChrome.BottomBarContentHeight val pagerState = rememberPagerState( initialPage = if (section == TopSection.LIBRARY) 0 else 1, pageCount = { 2 }, @@ -52,7 +58,6 @@ fun LlmpBottomBar( val latestSection = rememberUpdatedState(section) val latestOnSectionChange = rememberUpdatedState(onSectionChange) - // Top / content driven section → move footer pager (cancels prior anim on rapid taps) LaunchedEffect(section) { val target = if (section == TopSection.LIBRARY) 0 else 1 if (pagerState.settledPage != target) { @@ -60,8 +65,6 @@ fun LlmpBottomBar( } } - // Footer swipe → section. Ignore in-progress settles so a cancelled anim toward - // 「我的」cannot overwrite a newer 「歌库」selection (and vice versa). LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage } .distinctUntilChanged() @@ -74,17 +77,17 @@ fun LlmpBottomBar( } } - Column( + Box( modifier = modifier - .fillMaxWidth() - .background(if (dark) ColorMs.BottomBarDark else ColorMs.BottomBarLight) - .navigationBarsPadding(), + .fillMaxSize() + .background(if (dark) ColorMs.BottomBarDark else ColorMs.BottomBarLight), + contentAlignment = Alignment.Center, ) { HorizontalPager( state = pagerState, modifier = Modifier .fillMaxWidth() - .height(56.dp), + .height(tabStripHeight), beyondViewportPageCount = 1, ) { page -> val pageSection = if (page == 0) TopSection.LIBRARY else TopSection.MINE @@ -98,14 +101,15 @@ fun LlmpBottomBar( val selected = pageSection == section && index == selectedIndex Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier .weight(1f) + .fillMaxHeight() .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { if (pageSection != section) { - // Section state drives footer pager via LaunchedEffect(section) onSectionChange(pageSection) } onSelect(index) @@ -115,11 +119,11 @@ fun LlmpBottomBar( asset = tab.icon, tint = if (selected) ColorMs.PinkF940A7 else ColorMs.UnselectedIcon, size = 18.dp, - contentDescription = tab.title, + contentDescription = tr(tab.titleKey), ) - Spacer(Modifier.height(5.dp)) + Spacer(Modifier.height(4.dp)) Text( - text = tab.title, + text = tr(tab.titleKey), fontSize = 10.sp, color = if (selected) { ColorMs.BottomSelectedText diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DetailsPage.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DetailsPage.kt similarity index 72% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DetailsPage.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DetailsPage.kt index 7200f97..2bc3b51 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DetailsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DetailsPage.kt @@ -1,5 +1,9 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.EaseOutQuad +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -20,29 +24,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf 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.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.LlmpTransitionMs +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalNavAnimatedVisibilityScope +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons -/** Flutter DetailsListTop height 45 + last-item inset 95 for mini player. */ +/** 对齐 Flutter 末项 `95.h`:只挡住迷你播放器,不再叠加已收起的底栏高度。 */ private val DetailsListBottomInset = 95.dp /** - * Flutter [DetailsPage] + [DetailsBody]: AppHeader, cover, sticky play bar, song list. + * 详情页:顶栏、封面、粘性播放栏、歌曲列表。 */ @Composable fun DetailsPage( @@ -55,11 +63,21 @@ fun DetailsPage( onRemoveFromMenu: (List) -> Unit = {}, ) { val dark = LocalDarkTheme.current - val bgPhoto = LocalBgPhoto.current val scaffoldBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val playerState by AppContainer.player.state.collectAsState() val playingId = playerState.current?.musicId + val navScope = LocalNavAnimatedVisibilityScope.current + val bodyAlpha by ( + navScope?.transition?.animateFloat( + transitionSpec = { tween(LlmpTransitionMs, easing = EaseOutQuad) }, + label = "detailBodyAlpha", + ) { state -> + if (state == EnterExitState.Visible) 1f else 0f + } ?: remember { mutableFloatStateOf(1f) } + ) + val bodyFade = Modifier.graphicsLayer { alpha = bodyAlpha } + var selectMode by remember { mutableStateOf(false) } var checkedIds by remember { mutableStateOf(setOf()) } var showAddToMenu by remember { mutableStateOf(false) } @@ -67,7 +85,6 @@ fun DetailsPage( var moreMusic by remember { mutableStateOf(null) } var songInfoMusic by remember { mutableStateOf(null) } var addToMenuMusic by remember { mutableStateOf(null) } - var menus by remember { mutableStateOf>(emptyList()) } fun closeSelect() { checkedIds = emptySet() @@ -100,14 +117,14 @@ fun DetailsPage( AppContainer.player.setLoopMode(AppContainer.settings.loopMode) AppContainer.player.play() AppContainer.persistPlayQueue() - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) closeSelect() return } val existing = st.queue.mapNotNull { it.musicId }.toSet() val toAdd = list.filter { it.musicId !in existing } if (toAdd.isEmpty()) { - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) closeSelect() return } @@ -117,25 +134,19 @@ fun DetailsPage( AppContainer.player.setQueue(newQueue, idx) if (wasPlaying) AppContainer.player.play() AppContainer.persistPlayQueue() - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) closeSelect() } - fun appendToMenu(menu: Menu, list: List) { - val ids = (menu.music + list.mapNotNull { it.musicId }).distinct() - AppContainer.library.upsertMenu(menu.copy(music = ids)) - PlatformUi.showToast("添加成功") - showAddToMenu = false - closeSelect() - } - - Box( - Modifier - .fillMaxSize() - .background(scaffoldBg), - ) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(scaffoldBg) + .graphicsLayer { alpha = bodyAlpha }, + ) Column(Modifier.fillMaxSize()) { - AppHeader(title = title, onBack = onBack) + AppHeader(title = title, onBack = onBack, modifier = bodyFade) Spacer(Modifier.height(8.dp)) LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -158,7 +169,7 @@ fun DetailsPage( itemsLength = songs.size, selectMode = selectMode, selectAll = songs.isNotEmpty() && - songs.mapNotNull { it.musicId }.all { it in checkedIds }, + songs.mapNotNull { it.musicId }.all { it in checkedIds }, checkedCount = checkedIds.size, onPlayTap = { playQueue() }, onFunctionTap = { selectMode = true }, @@ -171,6 +182,7 @@ fun DetailsPage( }, onCancelTap = ::closeSelect, pinnedBg = scaffoldBg, + modifier = bodyFade, ) } itemsIndexed( @@ -184,7 +196,7 @@ fun DetailsPage( onClick = { playQueue(item) }, onPlayNext = { addMusicPlayNext(item) - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) }, onMore = { moreMusic = item }, selectMode = selectMode, @@ -194,7 +206,7 @@ fun DetailsPage( checkedIds = if (checked) checkedIds + id else checkedIds - id } }, - modifier = Modifier.padding( + modifier = bodyFade.padding( bottom = if (index == songs.lastIndex) 0.dp else 10.dp, ), ) @@ -209,7 +221,6 @@ fun DetailsPage( onAddToPlaylist = { addMusicListToPlayer(selectedMusic()) }, onAddToMenu = { if (selectedMusic().isNotEmpty()) { - menus = AppContainer.library.getAllMenus() showAddToMenu = true } }, @@ -217,7 +228,7 @@ fun DetailsPage( onRemoveFromMenu = { if (selectedMusic().isNotEmpty()) showRemoveConfirm = true }, - modifier = Modifier.align(Alignment.BottomCenter), + modifier = Modifier.align(Alignment.BottomCenter).then(bodyFade), ) } } @@ -227,10 +238,7 @@ fun DetailsPage( music = target, onDismiss = { moreMusic = null }, onOpenAlbum = { /* stay on detail stack */ }, - onOpenAddToMenu = { - menus = AppContainer.library.getAllMenus() - addToMenuMusic = target - }, + onOpenAddToMenu = { addToMenuMusic = target }, onShowSongInfo = { songInfoMusic = target }, onRemove = if (showRemoveFromMenu) { { @@ -247,49 +255,20 @@ fun DetailsPage( } addToMenuMusic?.let { target -> AddToMenuDialog( - menus = menus, - onPick = { menu -> - appendToMenu(menu, listOf(target)) - addToMenuMusic = null - }, - onCreate = { name -> - val id = AppContainer.library.nextPhoneMenuId() - val menu = Menu( - id = id, - isPhone = true, - music = listOfNotNull(target.musicId), - date = "", - name = name, - ) - AppContainer.library.upsertMenu(menu) - addToMenuMusic = null - PlatformUi.showToast("添加成功") - }, + musicList = listOf(target), onDismiss = { addToMenuMusic = null }, ) } if (showAddToMenu) { AddToMenuDialog( - menus = menus.ifEmpty { AppContainer.library.getAllMenus() }, - onPick = { menu -> appendToMenu(menu, selectedMusic()) }, - onCreate = { name -> - val id = AppContainer.library.nextPhoneMenuId() - val menu = Menu( - id = id, - isPhone = true, - music = emptyList(), - date = "", - name = name, - ) - AppContainer.library.upsertMenu(menu) - appendToMenu(menu, selectedMusic()) - }, + musicList = selectedMusic(), onDismiss = { showAddToMenu = false }, + onMenuChanged = { ok -> if (ok) closeSelect() }, ) } if (showRemoveConfirm) { TwoButtonDialog( - title = "确认从歌单删除?", + title = tr("confirm_delete_from_menu_short"), showMessage = false, onConfirm = { onRemoveFromMenu(selectedMusic().mapNotNull { it.musicId }) @@ -301,7 +280,7 @@ fun DetailsPage( } } -/** Flutter DetailsListTop — play-all bar / select bar, sticky. */ +/** 详情顶栏 — 播放全部/选择栏,粘性定位。 */ @Composable fun DetailsListTop( itemsLength: Int, @@ -316,8 +295,7 @@ fun DetailsListTop( modifier: Modifier = Modifier, ) { val dark = LocalDarkTheme.current - val hasBgPhoto = LocalBgPhoto.current.isNotBlank() - val lightOnDark = dark || hasBgPhoto + val lightOnDark = dark val countColor = if (lightOnDark) ColorMs.White else ColorMs.Text333 val iconTint = if (lightOnDark) ColorMs.White else ColorMs.GrayCCC val selectText = if (lightOnDark) ColorMs.White else ColorMs.Text333 @@ -337,13 +315,13 @@ fun DetailsListTop( ) Spacer(Modifier.width(10.dp)) Text( - text = "已选择 $checkedCount 首", + text = tr("selected_count", checkedCount), color = selectText, fontSize = 15.sp, modifier = Modifier.weight(1f), ) Text( - text = "完成", + text = tr("finish"), color = selectText, fontSize = 15.sp, modifier = Modifier @@ -364,10 +342,10 @@ fun DetailsListTop( .padding(start = 16.dp, end = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - PlayAllButton(onClick = onPlayTap, hasShadow = !hasBgPhoto) + PlayAllButton(onClick = onPlayTap) Spacer(Modifier.width(10.dp)) Text( - text = "$itemsLength 首", + text = tr("count_songs", itemsLength), color = countColor, fontSize = 14.sp, maxLines = 1, @@ -377,10 +355,10 @@ fun DetailsListTop( NeumorphicIconButton( icon = LlmpIcons.Function, onClick = onFunctionTap, - size = 30.dp, - iconSize = 18.dp, - radius = 6.dp, - contentPadding = 6.dp, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, hasShadow = false, iconColor = iconTint, modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp), @@ -389,7 +367,7 @@ fun DetailsListTop( } } -/** Album cover block: 240w image + name + category·date. */ +/** 专辑封面块:240 宽图片 + 名称 + 分类·日期。 */ @Composable fun AlbumDetailsCover( albumName: String, @@ -398,30 +376,38 @@ fun AlbumDetailsCover( cover: @Composable () -> Unit, ) { val dark = LocalDarkTheme.current - val hasBgPhoto = LocalBgPhoto.current.isNotBlank() - val titleColor = if (dark || hasBgPhoto) ColorMs.White else ColorMs.Text333 - val subColor = if (hasBgPhoto) ColorMs.GrayDFD else ColorMs.Gray999 + val titleColor = if (dark) ColorMs.White else ColorMs.Text333 + val subColor = ColorMs.Gray999 + val navScope = LocalNavAnimatedVisibilityScope.current + // 返回时封面走 Hero,名称/日期立刻隐掉,避免停在专辑列表上方。 + val metaVisible = navScope == null || + navScope.transition.targetState == EnterExitState.Visible Column(horizontalAlignment = Alignment.CenterHorizontally) { cover() - Spacer(Modifier.height(20.dp)) - Text( - albumName, - color = titleColor, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 20.dp), - ) - Spacer(Modifier.height(5.dp)) - Text( - listOfNotNull(category?.takeIf { it.isNotBlank() }, date?.takeIf { it.isNotBlank() }) - .joinToString("·"), - color = subColor, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { alpha = if (metaVisible) 1f else 0f }, + ) { + Spacer(Modifier.height(20.dp)) + Text( + albumName, + color = titleColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 20.dp), + ) + Spacer(Modifier.height(5.dp)) + Text( + listOfNotNull(category?.takeIf { it.isNotBlank() }, date?.takeIf { it.isNotBlank() }) + .joinToString("·"), + color = subColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt new file mode 100644 index 0000000..4c7ed09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogAddSongSheet.kt @@ -0,0 +1,280 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +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.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset + +/** + * 添加到歌单 — 底部弹出,对齐 Flutter [DialogAddSongSheet]。 + * 标题 / 创建歌单 / 我喜欢 / id>100 的歌单列表(封面 + 名称 + 数量)。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToMenuDialog( + musicList: List, + onDismiss: () -> Unit, + onMenuChanged: (Boolean) -> Unit = {}, + onLoveChanged: (Boolean) -> Unit = {}, +) { + val dark = LocalDarkTheme.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showCreate by remember { mutableStateOf(false) } + var suppressSheetDismiss by remember { mutableStateOf(false) } + val menus = remember { AppContainer.library.getAllMenus().filter { it.id > 100 } } + val notAllLove = musicList.any { music -> + val id = music.musicId + id == null || AppContainer.library.getMusicById(id)?.isLove != true + } + val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary + val titleColor = if (dark) ColorMs.White else ColorMs.Text333 + val itemColor = if (dark) ColorMs.White else ColorMs.Text333 + val iconColor = if (dark) ColorMs.White else ColorMs.Gray666 + val divider = if (dark) ColorMs.Gray737 else ColorMs.GrayCFC + val musicIds = musicList.mapNotNull { it.musicId } + + fun dismissThen(block: () -> Unit) { + scope.launch { + suppressSheetDismiss = true + sheetState.hide() + block() + } + } + + if (!showCreate) { + ModalBottomSheet( + onDismissRequest = { + if (!suppressSheetDismiss) onDismiss() + }, + sheetState = sheetState, + containerColor = bg, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + dragHandle = null, + ) { + Column( + Modifier + .fillMaxWidth() + .height(450.dp) + .navigationBarsPadding(), + ) { + Text( + text = tr("add_to_menu"), + color = titleColor, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + HorizontalDivider(thickness = 0.5.dp, color = divider) + + AddSheetActionRow( + title = tr("create_menu"), + textColor = itemColor, + divider = divider, + icon = SvgAsset("drawable/dialog_ic_new_song_list.svg"), + iconColor = iconColor, + onClick = { + dismissThen { showCreate = true } + }, + ) + AddSheetActionRow( + title = tr("iLove"), + textColor = itemColor, + divider = divider, + icon = if (notAllLove) LlmpIcons.Love else null, + imageVector = if (notAllLove) null else Icons.Filled.Favorite, + iconColor = if (notAllLove) iconColor else Color(0xFFFF4081), + onClick = { + dismissThen { + AppContainer.toggleLoveList(musicList, notAllLove) + PlatformUi.showToast( + I18n.t(if (notAllLove) "add_to_iLove" else "remove_from_iLove"), + ) + onLoveChanged(notAllLove) + onDismiss() + } + }, + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 16.dp, top = 12.dp, end = 16.dp), + ) { + items(menus, key = { it.id }) { menu -> + AddSheetMenuRow( + menu = menu, + titleColor = itemColor, + onClick = { + dismissThen { + val ok = AppContainer.insertMusicToMenu(menu, musicIds) + PlatformUi.showToast( + I18n.t(if (ok) "add_success" else "add_fail"), + ) + onMenuChanged(ok) + onDismiss() + } + }, + ) + Spacer(Modifier.height(10.dp)) + } + } + } + } + } + + if (showCreate) { + TextFieldDialog( + title = tr("create_menu"), + hint = tr("input_menu_name"), + initial = "", + maxLength = 20, + keyboardType = KeyboardType.Text, + sanitize = { it }, + onConfirm = { name -> + val ok = AppContainer.addPhoneMenu(name, musicIds) + PlatformUi.showToast(I18n.t(if (ok) "create_success" else "create_over_max")) + onMenuChanged(ok) + onDismiss() + }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun AddSheetActionRow( + title: String, + textColor: Color, + divider: Color, + onClick: () -> Unit, + icon: SvgAsset? = null, + imageVector: ImageVector? = null, + iconColor: Color = Color.Unspecified, + iconSize: Dp = 20.dp, +) { + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Row( + Modifier.padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when { + icon != null -> NeumorphicIconButton( + icon = icon, + onClick = null, + size = 28.dp, + iconSize = iconSize, + contentPadding = 0.dp, + iconColor = iconColor, + hasShadow = false, + ) + imageVector != null -> NeumorphicIconButton( + imageVector = imageVector, + onClick = null, + size = 28.dp, + iconSize = iconSize, + contentPadding = 4.dp, + iconColor = iconColor, + hasShadow = false, + ) + } + Spacer(Modifier.width(10.dp)) + Text(title, color = textColor, fontSize = 15.sp) + } + HorizontalDivider(thickness = 0.5.dp, color = divider) + } +} + +@Composable +private fun AddSheetMenuRow( + menu: Menu, + titleColor: Color, + onClick: () -> Unit, +) { + val coverMusic = remember(menu.id, menu.music) { + menu.music.firstOrNull()?.let { AppContainer.library.getMusicById(it) } + } + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + if (coverMusic != null) { + MusicCoverImage(music = coverMusic, size = 48.dp, radius = 12.dp) + } else { + CoverPlaceholder(label = menu.name.take(1), size = 48.dp, radius = 12.dp) + } + Column(Modifier.padding(start = 10.dp).weight(1f)) { + Text( + menu.name, + color = titleColor, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + "${menu.music.size} ${tr("total_number_unit")}", + color = ColorMs.Gray999, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogMoreWithMusic.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogMoreWithMusic.kt similarity index 74% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogMoreWithMusic.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogMoreWithMusic.kt index a992a8a..39917ac 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogMoreWithMusic.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogMoreWithMusic.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -27,22 +27,21 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.lyric.PlatformInfo -import top.zhushenwudi.llmp.platform.PlatformServices -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.share.QqShare +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset /** - * Flutter [DialogMoreWithMusic]. - * Player mode ([isPlayer]=true) adds「重新搜索歌词」. + * 歌曲更多操作底部弹窗。 + * 播放器模式(`isPlayer`=true)额外显示「重新搜索歌词」。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,8 +53,7 @@ fun DialogMoreWithMusic( onClosePanel: () -> Unit = {}, onOpenAlbum: (albumId: String) -> Unit = {}, onOpenMoeGirl: (query: String) -> Unit = { query -> - val url = if (query.isBlank()) Const.MOE_GIRL_URL else Const.MOE_GIRL_URL + query - PlatformUi.openWebView(url, "萌娘百科") + PlatformUi.openWebView(Const.buildMoeGirlSearchUrl(query), I18n.t("moe_girl_wiki")) }, onOpenAddToMenu: () -> Unit = {}, onShowSongInfo: () -> Unit = {}, @@ -92,7 +90,7 @@ fun DialogMoreWithMusic( .verticalScroll(rememberScrollState()), ) { Text( - text = music.musicName ?: "未知歌曲", + text = music.musicName ?: tr("unknown_song"), color = titleColor, fontSize = 17.sp, maxLines = 1, @@ -103,20 +101,20 @@ fun DialogMoreWithMusic( MoreSheetItem( icon = SvgAsset("drawable/dialog_ic_add_play_list.svg"), - title = "加入播放列表", + title = tr("add_to_playlist"), iconColor = iconColor, textColor = itemColor, onClick = { dismissThen { addMusicToPlaylistEnd(music) - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) } }, ) SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/dialog_ic_add_song_sheet.svg"), - title = "添加到歌单", + title = tr("add_to_menu"), iconColor = iconColor, textColor = itemColor, onClick = { dismissThen { onOpenAddToMenu() } }, @@ -124,7 +122,7 @@ fun DialogMoreWithMusic( SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/dialog_ic_song_info.svg"), - title = "歌曲信息", + title = tr("music_info"), iconColor = iconColor, textColor = itemColor, onClick = { dismissThen { onShowSongInfo() } }, @@ -132,17 +130,17 @@ fun DialogMoreWithMusic( SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/drawer_drawer_share.svg"), - title = "分享歌曲", + title = tr("share_music"), iconColor = iconColor, textColor = itemColor, - onClick = { dismissThen { shareMusic(music) } }, + onClick = { dismissThen { QqShare.shareMusic(music) } }, ) if (onRemove != null) { SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/dialog_ic_delete_2.svg"), - title = "移除歌曲", + title = tr("remove_music"), iconColor = iconColor, textColor = itemColor, onClick = { dismissThen { onRemove() } }, @@ -152,11 +150,17 @@ fun DialogMoreWithMusic( SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/drawer_drawer_inspect.svg"), - title = "在萌娘百科中搜索", + title = tr("search_in_moe_girl"), iconColor = iconColor, textColor = itemColor, onClick = { - dismissThen { onOpenMoeGirl(music.musicName.orEmpty()) } + val query = music.musicName.orEmpty() + if (PlatformInfo.isIos) { + dismissThen { onOpenMoeGirl(query) } + } else { + onOpenMoeGirl(query) + dismissThen {} + } }, ) } @@ -164,7 +168,7 @@ fun DialogMoreWithMusic( SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/dialog_ic_see_album.svg"), - title = "查看专辑", + title = tr("view_album"), iconColor = iconColor, textColor = itemColor, onClick = { @@ -174,7 +178,7 @@ fun DialogMoreWithMusic( onClosePanel() onOpenAlbum(albumId) } else { - PlatformUi.showToast("暂无专辑信息") + PlatformUi.showToast(I18n.t("no_album_info")) } } }, @@ -184,11 +188,11 @@ fun DialogMoreWithMusic( SheetDivider(divider) MoreSheetItem( icon = SvgAsset("drawable/drawer_drawer_reset.svg"), - title = "重新搜索歌词", + title = tr("search_lyric"), iconColor = iconColor, textColor = itemColor, onClick = { - // AppContainer scope survives dialog dismiss; UI scope would cancel the fetch. + // 使用 AppContainer scope,对话框关闭后请求不会被取消。 AppContainer.requestReloadPlayingLyric(forceRefresh = true) dismissThen {} }, @@ -238,8 +242,8 @@ private fun MoreSheetItem( } /** - * Flutter [PlayerLogic.addNextMusic]. - * @param asNext true: insert after current; false: append to end. + * 将歌曲加入播放队列。 + * @param asNext true 插入当前曲后;false 追加到末尾。 */ fun addMusicToPlayQueue(music: Music, asNext: Boolean = false) { val player = AppContainer.player @@ -268,37 +272,9 @@ fun addMusicToPlayQueue(music: Music, asNext: Boolean = false) { AppContainer.persistPlayQueue() } -/** Flutter addNextMusic(isNext: false). */ +/** 追加到播放列表末尾。 */ fun addMusicToPlaylistEnd(music: Music) = addMusicToPlayQueue(music, asNext = false) -/** Flutter addNextMusic(isNext: true) — list "下一首播放" button. */ +/** 插入为下一首播放(列表「下一首播放」按钮)。 */ fun addMusicPlayNext(music: Music) = addMusicToPlayQueue(music, asNext = true) -@Serializable -private data class SharedMusicPayload( - val id: String, - val name: String, - val neteaseId: String? = null, -) - -/** Flutter AppUtils.shareQQ(music) → system share of shareqq webpage link. */ -private fun shareMusic(music: Music) { - val id = music.musicId ?: return - val name = music.musicName ?: return - val payload = SharedMusicPayload(id = id, name = name, neteaseId = music.neteaseId) - val data = Json.encodeToString(payload) - val params = "?type=1&data=$data" - val encoded = encodeUriComponent(params) - val url = "https://shareqq.zhushenwudi.top/#/$encoded" - PlatformServices.share.shareUrl(url, title = "Ta向你分享了一首歌曲") -} - -private fun encodeUriComponent(value: String): String = buildString(value.length * 2) { - for (ch in value) { - when { - ch.isLetterOrDigit() || ch in "-_.!~*'()" -> append(ch) - else -> append('%') - .append(ch.code.toString(16).uppercase().padStart(2, '0')) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogPlaylist.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogPlaylist.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogPlaylist.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogPlaylist.kt index 5569dd0..90d7ece 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogPlaylist.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogPlaylist.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -26,16 +26,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.player.LoopMode -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset /** - * Flutter [DialogPlaylist] — current play queue bottom sheet. + * 当前播放队列底部弹窗。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,9 +48,9 @@ fun DialogPlaylist(onDismiss: () -> Unit) { val headerIcon = if (dark) ColorMs.GrayCCC else ColorMs.Gray666 val headerText = if (dark) ColorMs.White else ColorMs.Text333 val loopLabel = when (state.loopMode) { - LoopMode.LIST -> "顺序播放" - LoopMode.SINGLE -> "单曲循环" - LoopMode.SHUFFLE -> "随机播放" + LoopMode.LIST -> tr("order_play") + LoopMode.SINGLE -> tr("single_play") + LoopMode.SHUFFLE -> tr("shuffle_play") } val loopIcon = when (state.loopMode) { LoopMode.LIST -> LlmpIcons.Loop @@ -96,7 +97,7 @@ fun DialogPlaylist(onDismiss: () -> Unit) { ) Spacer(Modifier.width(10.dp)) Text( - text = "$loopLabel - ${state.queue.size} 首", + text = tr("loop_songs", loopLabel, state.queue.size), color = headerText, fontSize = 15.sp, modifier = Modifier.weight(1f), @@ -175,7 +176,7 @@ private fun PlaylistRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = music.musicName ?: "未知歌曲", + text = music.musicName ?: tr("unknown_song"), color = nameColor, fontSize = 14.sp, fontWeight = FontWeight.Bold, diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogSongInfo.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogSongInfo.kt similarity index 65% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogSongInfo.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogSongInfo.kt index f87e169..2eb5560 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DialogSongInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DialogSongInfo.kt @@ -1,6 +1,7 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet @@ -21,19 +21,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.platform.rememberCopyTextHandler -/** Flutter [DialogSongInfo]. */ +/** 歌曲信息对话框。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DialogSongInfo( @@ -42,7 +43,7 @@ fun DialogSongInfo( ) { val dark = LocalDarkTheme.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val clipboard = LocalClipboardManager.current + val copyText = rememberCopyTextHandler() val album = remember(music.albumId) { music.albumId?.let { AppContainer.library.getAlbum(it) } } @@ -52,6 +53,11 @@ fun DialogSongInfo( val divider = if (dark) ColorMs.Gray737 else ColorMs.GrayCFC val path = "${music.baseUrl.orEmpty()}${music.musicPath.orEmpty()}" + fun copyToClipboard(text: String) { + copyText(text) + PlatformUi.showToast(I18n.t("copy_success")) + } + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -66,7 +72,7 @@ fun DialogSongInfo( .height(370.dp), ) { Text( - text = "歌曲信息", + text = tr("music_info"), color = titleColor, fontSize = 17.sp, fontWeight = FontWeight.Bold, @@ -75,18 +81,53 @@ fun DialogSongInfo( modifier = Modifier.padding(12.dp), ) HorizontalDivider(thickness = 0.5.dp, color = divider) - SongInfoRow("歌曲: ", music.musicName, itemColor, divider, canCopy = true, clipboard = clipboard) - SongInfoRow("专辑: ", music.albumName, itemColor, divider, canCopy = true, clipboard = clipboard) - SongInfoRow("时长: ", music.time, itemColor, divider, canCopy = false, clipboard = clipboard) - SongInfoRow("位置: ", path.ifBlank { null }, itemColor, divider, canCopy = true, clipboard = clipboard) - SongInfoRow("发行日期: ", album?.date, itemColor, divider, canCopy = false, clipboard = clipboard) SongInfoRow( - "分类: ", + tr("song_label"), + music.musicName, + itemColor, + divider, + canCopy = true, + onCopy = ::copyToClipboard, + ) + SongInfoRow( + tr("album_label"), + music.albumName, + itemColor, + divider, + canCopy = true, + onCopy = ::copyToClipboard, + ) + SongInfoRow( + tr("duration_label"), + music.time, + itemColor, + divider, + canCopy = false, + onCopy = ::copyToClipboard, + ) + SongInfoRow( + tr("position_label"), + path.ifBlank { null }, + itemColor, + divider, + canCopy = true, + onCopy = ::copyToClipboard, + ) + SongInfoRow( + tr("release_date_label"), + album?.date, + itemColor, + divider, + canCopy = false, + onCopy = ::copyToClipboard, + ) + SongInfoRow( + tr("classification_label"), album?.category, itemColor, divider, canCopy = false, - clipboard = clipboard, + onCopy = ::copyToClipboard, showDivider = false, ) } @@ -100,10 +141,10 @@ private fun SongInfoRow( textColor: androidx.compose.ui.graphics.Color, divider: androidx.compose.ui.graphics.Color, canCopy: Boolean, - clipboard: androidx.compose.ui.platform.ClipboardManager, + onCopy: (String) -> Unit, showDivider: Boolean = true, ) { - val display = message?.takeIf { it.isNotBlank() } ?: "未知" + val display = message?.takeIf { it.isNotBlank() } ?: tr("unknown") Column( Modifier .fillMaxWidth() @@ -111,8 +152,7 @@ private fun SongInfoRow( detectTapGestures( onLongPress = { if (canCopy && !message.isNullOrBlank()) { - clipboard.setText(AnnotatedString(message)) - PlatformUi.showToast("复制成功") + onCopy(message) } }, ) diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DrawerPageChrome.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerPageChrome.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DrawerPageChrome.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerPageChrome.kt index c6aa939..fabef66 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DrawerPageChrome.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DrawerPageChrome.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -29,39 +29,40 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.SvgAsset -import top.zhushenwudi.llmp.ui.icons.SvgIcon -import top.zhushenwudi.llmp.ui.icons.TintIcon -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon +import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon +import com.zhushenwudi.lovelivemusicplayer.ui.theme.hDp +import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp -/** Flutter AppHeader — transparent bar, centered 15sp title, iOS-style back. */ +/** 透明顶栏,居中 15.h 标题,iOS 风格返回按钮。 */ @Composable fun AppHeader( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, - /** Force white icons/title (e.g. colored Material AppBar like Daily/SD). */ + /** 强制白色图标/标题(如每日推荐/SD 等彩色顶栏)。 */ lightContent: Boolean? = null, ) { val dark = LocalDarkTheme.current - val bgPhoto = LocalBgPhoto.current - val lightOnDark = lightContent ?: (dark || bgPhoto.isNotBlank()) + val lightOnDark = lightContent ?: dark val fg = if (lightOnDark) ColorMs.White else ColorMs.Text333 Column( modifier = modifier .fillMaxWidth() .statusBarsPadding() - .padding(top = 14.dp), + .padding(top = hDp(14f)), ) { Box( Modifier .fillMaxWidth() - .height(30.dp), + .height(hDp(30f)), contentAlignment = Alignment.Center, ) { Row( @@ -72,7 +73,7 @@ fun AppHeader( imageVector = Icons.AutoMirrored.Filled.ArrowBack, tint = fg, size = 20.dp, - contentDescription = "返回", + contentDescription = tr("back"), modifier = Modifier .padding(start = 16.dp) .clickable( @@ -84,8 +85,7 @@ fun AppHeader( } Text( title, - color = fg, - fontSize = 15.sp, + style = drawerChromeTextStyle(fg), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 240.dp), @@ -94,7 +94,7 @@ fun AppHeader( } } -/** Flutter DrawerFunctionButton row (30dp, 28 icon, 15sp, optional switch). */ +/** 抽屉功能行(30.h 高,28 图标,15.h 文字,可选开关)。 */ @Composable fun DrawerFunctionRow( text: String, @@ -108,15 +108,14 @@ fun DrawerFunctionRow( onClick: (() -> Unit)? = null, ) { val dark = LocalDarkTheme.current - val bgPhoto = LocalBgPhoto.current - val lightOnDark = dark || bgPhoto.isNotBlank() + val lightOnDark = dark val textColor = if (lightOnDark) ColorMs.EDF5FF else Color.Black val resolvedIconTint = iconTint ?: if (dark) ColorMs.White else ColorMs.Text333 val rowInteraction = remember { MutableInteractionSource() } Row( modifier = modifier .fillMaxWidth() - .height(30.dp) + .height(hDp(30f)) .then( if (onClick != null) { Modifier.clickable( @@ -153,8 +152,7 @@ fun DrawerFunctionRow( Spacer(Modifier.width(8.dp)) Text( text, - color = textColor, - fontSize = 15.sp, + style = drawerChromeTextStyle(textColor), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), @@ -176,7 +174,7 @@ fun DrawerFunctionRow( } } -/** Flutter websocket_widget.btnFunc — 220×46 pink action. */ +/** 粉色操作按钮 220×46。 */ @Composable fun PinkFuncButton( title: String, @@ -208,11 +206,11 @@ fun PinkFuncButton( size = 15.dp, ) Spacer(Modifier.width(11.dp)) - Text(title, color = ColorMs.PinkF940A7, fontSize = 15.sp) + Text(title, style = drawerChromeTextStyle(ColorMs.PinkF940A7)) } } -/** Flutter tip row with drawer_secret icon. */ +/** 带 drawer_secret 图标的提示行。 */ @Composable fun LanTipRow(text: String, modifier: Modifier = Modifier) { Row( @@ -228,8 +226,7 @@ fun LanTipRow(text: String, modifier: Modifier = Modifier) { Spacer(Modifier.width(10.dp)) Text( text, - color = ColorMs.Gray999, - fontSize = 12.sp, + style = drawerChromeTipTextStyle(), modifier = Modifier.widthIn(max = 300.dp), ) } @@ -241,8 +238,30 @@ fun PairTipsBlock(modifier: Modifier = Modifier) { modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - LanTipRow("请保持手机和电脑处于同一局域网内") + LanTipRow(tr("keep_same_lan")) Spacer(Modifier.height(4.dp)) - LanTipRow("请保持屏幕常亮并扫描二维码") + LanTipRow(tr("keep_screen_scan_qr")) } } + +/** 原项目 `drawer_function_button` / `AppHeader`:15.h 正文。 */ +@Composable +private fun drawerChromeTextStyle(color: Color): TextStyle { + val size = hSp(15f) + return TextStyle( + color = color, + fontSize = size, + lineHeight = size, + ) +} + +/** 原项目 `TextStyleMs.gray_12`:12.h 提示文案。 */ +@Composable +private fun drawerChromeTipTextStyle(): TextStyle { + val size = hSp(12f) + return TextStyle( + color = ColorMs.Gray999, + fontSize = size, + lineHeight = size, + ) +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DriveModeCarousel.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DriveModeCarousel.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt index 7e2b5dd..d49c234 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/DriveModeCarousel.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/DriveModeCarousel.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -22,18 +22,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import kotlin.math.absoluteValue import kotlinx.coroutines.flow.distinctUntilChanged -import top.zhushenwudi.llmp.domain.Music +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import kotlin.math.absoluteValue -/** Center cover; side pages share this base and scale down via transformer (+15% vs 230). */ +/** 中心封面尺寸;侧页在此基础上缩放(较 230 放大 15%)。 */ private val CoverSize = 264.5.dp /** - * Aligns with Flutter [SwipeImageCarousel]: - * - viewportFraction ~0.56 (Flutter 0.62, slightly tighter) - * - ScaleAndFadeTransformer(scale=0.5, fade=0.3) + Y offset scale*-40 - * - center circular cover 264.5 (230 × 1.15) + * 驾驶模式封面轮播: + * - 视口宽度比例约 0.56 + * - 缩放淡出变换(scale=0.5, fade=0.3)+ Y 偏移 scale*-40 + * - 中心圆形封面 264.5dp */ @Composable fun DriveModeCarousel( @@ -76,7 +76,7 @@ fun DriveModeCarousel( } BoxWithConstraints(modifier.fillMaxSize()) { - // Flutter base is 0.62; slightly tighter so neighbors sit a bit closer. + // 视口宽度比例 0.56,使相邻页更紧凑。 val pageWidth = maxWidth * 0.56f val sidePad = ((maxWidth - pageWidth) / 2f).coerceAtLeast(0.dp) @@ -92,7 +92,7 @@ fun DriveModeCarousel( val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction val abs = pageOffset.absoluteValue.coerceIn(0f, 1f) - // ScaleAndFadeTransformer(scale=0.5, fade=0.3) + // 缩放淡出变换(scale=0.5, fade=0.3) val scale = 0.5f + (1f - abs) * 0.5f val alpha = 0.3f + (1f - abs) * 0.7f Box( diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/GroupLogos.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/GroupLogos.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/GroupLogos.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/GroupLogos.kt index 08ef206..e8c789b 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/GroupLogos.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/GroupLogos.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape @@ -11,20 +11,20 @@ import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImage import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.generated.resources.logo_logo -import top.zhushenwudi.llmp.generated.resources.logo_logo_aqours -import top.zhushenwudi.llmp.generated.resources.logo_logo_bluebird -import top.zhushenwudi.llmp.generated.resources.logo_logo_combine -import top.zhushenwudi.llmp.generated.resources.logo_logo_hasunosora -import top.zhushenwudi.llmp.generated.resources.logo_logo_liella -import top.zhushenwudi.llmp.generated.resources.logo_logo_musical -import top.zhushenwudi.llmp.generated.resources.logo_logo_niji -import top.zhushenwudi.llmp.generated.resources.logo_logo_us -import top.zhushenwudi.llmp.generated.resources.logo_logo_yohane +import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_aqours +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_bluebird +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_combine +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_hasunosora +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_liella +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_musical +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_niji +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_us +import com.zhushenwudi.lovelivemusicplayer.generated.resources.logo_logo_yohane -/** Flutter Const.groupList[].logo / GlobalLogic.getCurrentGroupIcon. */ +/** 团组 Logo 资源映射。 */ fun GroupKey.groupLogoDrawable(): DrawableResource = when (this) { GroupKey.GROUP_ALL -> Res.drawable.logo_logo GroupKey.GROUP_US -> Res.drawable.logo_logo_us @@ -38,7 +38,7 @@ fun GroupKey.groupLogoDrawable(): DrawableResource = when (this) { GroupKey.GROUP_COMBINE -> Res.drawable.logo_logo_combine } -/** Compose resource path for Coil — must stay in sync with [groupLogoDrawable]. */ +/** Coil 用的 Compose 资源路径,须与 `GroupKey.groupLogoDrawable` 同步。 */ fun GroupKey.groupLogoAssetPath(): String = when (this) { GroupKey.GROUP_ALL -> "drawable/logo_logo.png" GroupKey.GROUP_US -> "drawable/logo_logo_us.jpg" @@ -52,12 +52,12 @@ fun GroupKey.groupLogoAssetPath(): String = when (this) { GroupKey.GROUP_COMBINE -> "drawable/logo_logo_combine.jpg" } -/** Flutter: default lovelive logo uses Fit + padding; group JPG logos use Crop. */ +/** 默认 lovelive Logo 使用 Fit + 内边距;团组 JPG 使用 Crop。 */ fun GroupKey.isDefaultGroupLogo(): Boolean = this == GroupKey.GROUP_ALL /** - * Flutter getCurrentGroupIcon — always shows the logo for [group]. - * [key] remounts so Coil/painter never keeps a stale first frame. + * 始终显示 `group` 对应的团组 Logo。 + * `key` 触发重挂载,避免 Coil/painter 保留过期首帧。 */ @OptIn(ExperimentalResourceApi::class) @Composable diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt new file mode 100644 index 0000000..1da3b39 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt @@ -0,0 +1,37 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** 紧凑双行列表项,48dp 前导封面 — 介于 M3 56dp 与旧版 72dp 之间。 */ +internal val TwoLineListItemHeight = 56.dp + +/** 歌曲/艺人/歌单行共用的前导封面尺寸。 */ +internal val ListLeadingCoverSize = 48.dp + +/** 文本块高度与封面一致;均分为上下两带。 */ +internal val ListTextBlockHeight = ListLeadingCoverSize + +/** M3 列表紧密排列;行间无额外 LazyColumn 间距。 */ +internal val ListItemOuterVerticalPadding = 0.dp + +/** 专辑网格单元外内边距。 */ +internal val AlbumGridItemVerticalPadding = 8.dp + +/** 列表行主标题(歌名/艺人名/歌单名)。 */ +internal val ListItemTitleSize = 13.sp +internal val ListItemTitleLineHeight = 17.sp + +/** 列表行副标题(艺人/曲目数)。 */ +internal val ListItemSubtitleSize = 11.sp +internal val ListItemSubtitleLineHeight = 13.sp + +/** + * 曲库列表右侧、顶栏搜索/倒序/多选。 + * Flutter `neumorphicButton` 为 30×30、图标 15、内边距 5; + * Compose SVG 在同样数值下视觉偏小,按钮与图标各放大一档。 + */ +internal val LibraryActionButtonSize = 32.dp +internal val LibraryActionIconSize = 18.dp +internal val LibraryActionIconPadding = 5.dp +internal val LibraryActionButtonRadius = 6.dp diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemTextColumn.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemTextColumn.kt new file mode 100644 index 0000000..783fc39 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemTextColumn.kt @@ -0,0 +1,36 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** 将 `ListTextBlockHeight` 均分为两带;标题/副标题各带居中。 */ +@Composable +internal fun TwoLineListTextColumn( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, +) { + Column(modifier = modifier.height(ListTextBlockHeight)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + title() + } + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + subtitle() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/LlmpDialogs.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpDialogs.kt similarity index 64% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/LlmpDialogs.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpDialogs.kt index 84d7cd7..11d7c75 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/LlmpDialogs.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpDialogs.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,32 +30,58 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType 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.dp import androidx.compose.ui.unit.min import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme +import coil3.compose.AsyncImage +import org.jetbrains.compose.resources.ExperimentalResourceApi +import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs +import com.zhushenwudi.lovelivemusicplayer.db.formatLocalHm +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme -/** Flutter TwoButtonDialog — width min(0.4h, 0.8w), radius 16, footer 44. */ +/** + * 双按钮对话框 — 对齐 Flutter [TwoButtonDialog]。 + * 默认顶部 78 警告图 `main_ic_err`,宽度 min(0.4h, 0.8w),圆角 16,底栏 44。 + */ +@OptIn(ExperimentalResourceApi::class) @Composable fun TwoButtonDialog( title: String, message: String? = null, showMessage: Boolean = message != null, - confirmText: String = "确认", - cancelText: String = "取消", + showImage: Boolean = true, + imagePath: String = "drawable/main_ic_err.png", + confirmText: String = I18n.t("confirm"), + cancelText: String = I18n.t("cancel"), onConfirm: () -> Unit, onDismiss: () -> Unit, ) { val dark = LocalDarkTheme.current LlmpDialogShell(onDismiss = onDismiss) { Spacer(Modifier.height(28.dp)) + if (showImage) { + AsyncImage( + model = Res.getUri(imagePath), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(78.dp), + ) + Spacer(Modifier.height(12.dp)) + } Text( title, color = if (dark) ColorMs.White else ColorMs.Text333, @@ -64,8 +91,8 @@ fun TwoButtonDialog( overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 20.dp), ) + Spacer(Modifier.height(8.dp)) if (showMessage) { - Spacer(Modifier.height(8.dp)) Text( message.orEmpty(), color = ColorMs.Gray999, @@ -74,7 +101,13 @@ fun TwoButtonDialog( modifier = Modifier.padding(horizontal = 20.dp), ) } else { - Spacer(Modifier.height(8.dp)) + // 对齐 Flutter Visibility(maintainSize):隐藏文案仍占一行高度。 + Text( + " ", + color = ColorMs.Gray999, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 20.dp), + ) } Spacer(Modifier.height(8.dp)) DialogFooterButtons( @@ -89,13 +122,15 @@ fun TwoButtonDialog( } } -/** Flutter TextFieldDialog — height 230. */ +/** 文本输入对话框 — 高度 230。 */ @Composable fun TextFieldDialog( title: String, hint: String, initial: String, maxLength: Int = 50, + keyboardType: KeyboardType = KeyboardType.Uri, + sanitize: (String) -> String = { raw -> raw.filter { it.isLetterOrDigit() || it in ".:/_-" } }, onConfirm: (String) -> Unit, onDismiss: () -> Unit, ) { @@ -111,13 +146,10 @@ fun TextFieldDialog( Spacer(Modifier.height(8.dp)) OutlinedTextField( value = text, - onValueChange = { raw -> - val filtered = raw.filter { it.isLetterOrDigit() || it in ".:/_-" } - text = filtered.take(maxLength) - }, + onValueChange = { raw -> text = sanitize(raw).take(maxLength) }, label = { Text(hint, fontSize = 14.sp) }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), modifier = Modifier .fillMaxWidth() .padding(horizontal = 28.dp), @@ -133,10 +165,10 @@ fun TextFieldDialog( } } -/** Flutter AddMinDialog — ±10 / ±30, max 180. */ +/** 分钟增减对话框 — ±10 / ±30,最大 180。对齐 Flutter [AddMinDialog]。 */ @Composable fun AddMinDialog( - title: String = "选择时间", + title: String = I18n.t("select_time"), initialMinutes: Int = 0, onConfirm: (Int) -> Unit, onDismiss: () -> Unit, @@ -159,19 +191,20 @@ fun AddMinDialog( color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 15.sp, ) - Text("结束时间 $endLabel", color = Color.Red, fontSize = 14.sp) + Text(tr("end_time_value", endLabel), color = Color.Red, fontSize = 14.sp) Spacer(Modifier.height(18.dp)) Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - MinChip("-$30", enabled = minutes > 0) { + // 不能写成 "-$30":Kotlin 会把 $ 当字面量,按钮中间出现 $。 + MinChip("-30", enabled = minutes > 0) { minutes = (minutes - 30).coerceAtLeast(0) } - MinChip("-$10", enabled = minutes > 0) { + MinChip("-10", enabled = minutes > 0) { minutes = (minutes - 10).coerceAtLeast(0) } - MinChip("+$10", enabled = minutes < 180) { + MinChip("+10", enabled = minutes < 180) { minutes = (minutes + 10).coerceAtMost(180) } - MinChip("+$30", enabled = minutes < 180) { + MinChip("+30", enabled = minutes < 180) { minutes = (minutes + 30).coerceAtMost(180) } } @@ -186,7 +219,7 @@ fun AddMinDialog( } } -/** Flutter ResetDataDialog. */ +/** 重置数据确认对话框。 */ @Composable fun ResetDataDialog( onClearSongs: () -> Unit, @@ -194,27 +227,27 @@ fun ResetDataDialog( onDismiss: () -> Unit, ) { val dark = LocalDarkTheme.current - LlmpDialogShell(onDismiss = onDismiss) { + LlmpDialogShell(onDismiss = onDismiss, dismissOnOutsideClick = true) { Spacer(Modifier.height(12.dp)) Text( - "请选择要清理的数据", + tr("choose_your_clean_data"), color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 18.sp, textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) - ResetActionButton("清除歌曲数据") { + ResetActionButton(tr("remove_songs_data_kmp")) { onDismiss() onClearSongs() } Spacer(Modifier.height(24.dp)) - ResetActionButton("清除用户数据") { + ResetActionButton(tr("remove_user_data_kmp")) { onDismiss() onClearUserData() } Spacer(Modifier.height(24.dp)) Text( - "歌曲数据:曲库元数据、版本号", + tr("explain_songs_data_kmp"), color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 12.sp, textAlign = TextAlign.Center, @@ -222,7 +255,7 @@ fun ResetDataDialog( ) Spacer(Modifier.height(3.dp)) Text( - "用户数据:喜欢/历史/歌单/缓存/背景图,并退出应用", + tr("explain_user_data_kmp"), color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 12.sp, textAlign = TextAlign.Center, @@ -232,7 +265,7 @@ fun ResetDataDialog( } } -/** Version update AlertDialog style. */ +/** 版本更新 AlertDialog 样式。 */ @Composable fun UpdateVersionDialog( versionName: String, @@ -241,10 +274,11 @@ fun UpdateVersionDialog( onDismiss: () -> Unit, ) { TwoButtonDialog( - title = "发现新版本 v$versionName", - message = changelog.ifBlank { "检测到新版本,是否立即升级?" }, - confirmText = "升级", - cancelText = "取消", + title = tr("new_version", versionName), + message = changelog.ifBlank { I18n.t("upgrade_prompt") }, + showImage = false, + confirmText = I18n.t("update_yes"), + cancelText = I18n.t("cancel"), onConfirm = onUpdate, onDismiss = onDismiss, ) @@ -259,20 +293,22 @@ fun LoadingOverlay(message: String) { dismissOnClickOutside = false, ), ) { - Row( - Modifier - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black.copy(alpha = 0.75f)) - .padding(horizontal = 24.dp, vertical = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - color = ColorMs.PinkF940A7, - modifier = Modifier.height(28.dp).width(28.dp), - strokeWidth = 3.dp, - ) - Spacer(Modifier.width(14.dp)) - Text(message, color = ColorMs.White, fontSize = 14.sp) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 24.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + color = ColorMs.PinkF940A7, + modifier = Modifier.height(28.dp).width(28.dp), + strokeWidth = 3.dp, + ) + Spacer(Modifier.width(14.dp)) + Text(message, color = ColorMs.White, fontSize = 14.sp) + } } } } @@ -280,33 +316,62 @@ fun LoadingOverlay(message: String) { @Composable private fun LlmpDialogShell( onDismiss: () -> Unit, - fixedHeight: androidx.compose.ui.unit.Dp? = null, + fixedHeight: Dp? = null, + dismissOnOutsideClick: Boolean = false, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, ) { val dark = LocalDarkTheme.current + val scrimInteraction = remember { MutableInteractionSource() } + val cardInteraction = remember { MutableInteractionSource() } Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val dialogWidth = min(maxHeight * 0.4f, maxWidth * 0.8f) + // 抽屉用 RTL 开右侧,Dialog 会继承方向,中文标点会被 bidi 甩到句首。 + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .then( + if (dismissOnOutsideClick) { + Modifier.clickable( + indication = null, + interactionSource = scrimInteraction, + onClick = onDismiss, + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + // 记住首次布局宽度,避免键盘弹出时 adjustResize 缩小 maxHeight 导致弹窗变窄。 + val dialogWidth = remember { + min(maxHeight * 0.4f, maxWidth * 0.8f) + } Column( modifier = Modifier .width(dialogWidth) .then(if (fixedHeight != null) Modifier.height(fixedHeight) else Modifier) .clip(RoundedCornerShape(16.dp)) - .background(if (dark) ColorMs.NightPrimary else ColorMs.White), + .background(if (dark) ColorMs.NightPrimary else ColorMs.White) + .clickable( + indication = null, + interactionSource = cardInteraction, + onClick = {}, + ), horizontalAlignment = Alignment.CenterHorizontally, content = content, ) + } } } } @Composable private fun DialogFooterButtons( - cancelText: String = "取消", - confirmText: String = "确认", + cancelText: String = I18n.t("cancel"), + confirmText: String = I18n.t("confirm"), onCancel: () -> Unit, onConfirm: () -> Unit, ) { @@ -390,10 +455,6 @@ private fun ResetActionButton(text: String, onClick: () -> Unit) { } private fun formatEndTime(minutes: Int): String { - // Approximate wall-clock end without kotlinx-datetime: show "+N min" if (minutes <= 0) return "--:--" - val total = minutes - val h = total / 60 - val m = total % 60 - return if (h > 0) "${h}小时${m}分后" else "${m}分后" + return formatLocalHm(currentTimeMs() + minutes * 60_000L) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt new file mode 100644 index 0000000..b526ef8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt @@ -0,0 +1,252 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter.State +import org.jetbrains.compose.resources.ExperimentalResourceApi +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.Artist +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.ui.image.CoverImageHolder +import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage +import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter +import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey + +private const val FALLBACK_LOGO = "drawable/logo_svg_logo.svg" + +/** 解析播放器/列表/驾驶模式的显示 URI(null 则显示 Logo)。 */ +@Composable +fun rememberMusicCoverUri(music: Music?): String? = + remember(music?.musicId, music?.coverPath, music?.existFile, music?.baseUrl) { + music?.let { AppContainer.pathResolver.coverDisplayUri(it) } + } + +/** 调色板/小组件用的本地封面绝对路径(无 file://)。 */ +@Composable +fun rememberMusicCoverLocalPath(music: Music?): String? = + remember(music?.musicId, music?.coverPath, music?.existFile, music?.baseUrl) { + music?.let { AppContainer.pathResolver.absoluteCoverPath(it) } + ?.takeIf { path -> AppContainer.fileSystem.exists(path) } + } + +/** 封面可解析且可加载时显示,否则显示默认 Logo。 */ +@OptIn(ExperimentalResourceApi::class) +@Composable +fun MusicCoverImage( + music: Music, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + radius: Dp = 12.dp, +) { + val uri = rememberMusicCoverUri(music) + CoverAsyncImage( + modelUri = uri, + stableCacheKey = music.musicId?.let { musicCoverCacheKey(it) }, + // 先设置尺寸,再应用调用方修饰符(如 sharedElement 在 size 之后)。 + modifier = Modifier.size(size).then(modifier), + shape = RoundedCornerShape(radius), + fallbackPadding = 6.dp, + ) +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +fun AlbumCoverImage( + album: Album, + modifier: Modifier = Modifier, + radius: Dp = 12.dp, +) { + val uri = remember(album.albumId, album.coverPath, album.existFile) { + AppContainer.pathResolver.coverDisplayUri(album) + } + CoverAsyncImage( + modelUri = uri, + stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) }, + modifier = modifier, + shape = RoundedCornerShape(radius), + fallbackPadding = 6.dp, + ) +} + +/** + * 艺人头像:48×48 圆形封面。 + * 加载 https `{R2}/LLMP-M/artist_webp/{uid}.webp`(经 Coil 网络请求)。 + */ +@OptIn(ExperimentalResourceApi::class) +@Composable +fun ArtistPhotoImage( + artist: Artist, + modifier: Modifier = Modifier, + size: Dp = 48.dp, +) { + val uri = remember(artist.uid, artist.photo) { + artistPhotoDisplayUri(artist) + } + CoverAsyncImage( + modelUri = uri, + stableCacheKey = artistPhotoCacheKey(artist.uid), + modifier = Modifier.size(size).then(modifier), + shape = CircleShape, + fallbackPadding = 6.dp, + ) +} + +/** 优先使用数据库头像;否则从 uid 构建 URL(非纯名称键时)。 */ +private fun artistPhotoDisplayUri(artist: Artist): String? { + if (artist.photo.isNotBlank()) { + return artist.photo + } + if (artist.uid.startsWith("name:")) return null + return "${Const.R2_OSS_URL}LLMP-M/artist_webp/${artist.uid}.webp" +} + +/** WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。 + * `circular` 为 true 时圆形封面,否则圆角矩形。 + */ +@OptIn(ExperimentalResourceApi::class) +@Composable +fun PlayerMusicCover( + music: Music?, + modifier: Modifier = Modifier, + circular: Boolean = false, + radius: Dp = 12.dp, + fallbackPadding: Dp = 8.dp, +) { + val uri = rememberMusicCoverUri(music) + CoverAsyncImage( + modelUri = uri, + stableCacheKey = music?.musicId?.let { musicCoverCacheKey(it) }, + modifier = modifier, + shape = if (circular) CircleShape else RoundedCornerShape(radius), + fallbackPadding = fallbackPadding, + ) +} + +internal fun musicCoverCacheKey(musicId: String): String = "music-cover:$musicId" + +internal fun albumCoverCacheKey(albumId: String): String = "album-cover:$albumId" + +internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid" + +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun CoverAsyncImage( + modelUri: String?, + stableCacheKey: String? = null, + modifier: Modifier, + shape: Shape, + fallbackPadding: Dp, +) { + val fallback = Res.getUri(FALLBACK_LOGO) + val clipped = modifier.clip(shape).fillMaxSize() + if (modelUri.isNullOrBlank()) { + LlmpAsyncImage( + model = fallback, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = clipped.padding(fallbackPadding), + ) + return + } + + // 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致 + // Loading(null) → 空白,即使磁盘/内存仍有封面。 + val cacheKey = remember(modelUri, stableCacheKey) { + stableCacheKey?.takeIf { it.isNotBlank() } + ?: if (modelUri.startsWith("http", ignoreCase = true)) { + modelUri.toImageCacheKey() + } else { + modelUri + } + } + val heldPainter = rememberCoverPainter(cacheKey) + var showLogoFallback by remember(cacheKey) { mutableStateOf(false) } + var hasSuccess by remember(cacheKey) { + mutableStateOf(CoverImageHolder.get(cacheKey) != null) + } + + Box(clipped) { + if (heldPainter != null) { + Image( + painter = heldPainter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else if (showLogoFallback && !hasSuccess) { + LlmpAsyncImage( + model = fallback, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .padding(fallbackPadding), + ) + } + // key(cacheKey):LazyList 复用不得保留上一首歌的 AsyncImagePainter。 + // 首次加载空白可接受。同 key 缓存 → heldPainter / placeholderMemoryCacheKey。 + key(cacheKey) { + LlmpAsyncImage( + model = modelUri, + stableCacheKey = cacheKey, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + transform = { state -> + when (state) { + is State.Loading -> { + // 仅同 key 底图。绝不复用其他 model 的 painter + // (滚动时曾导致错误封面闪现)。 + if (heldPainter != null) State.Loading(heldPainter) else state + } + + is State.Error -> { + if (heldPainter != null) State.Error( + heldPainter, + state.result + ) else state + } + + else -> state + } + }, + onState = { state -> + when (state) { + is State.Success -> { + hasSuccess = true + showLogoFallback = false + } + + is State.Error -> { + if (CoverImageHolder.get(cacheKey) == null && !hasSuccess) { + showLogoFallback = true + } + } + + else -> Unit + } + }, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/NeumorphicButton.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/NeumorphicButton.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt index 75f98c9..d6b2d52 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/NeumorphicButton.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -35,17 +35,17 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.theme.neuShadowColor -import top.zhushenwudi.llmp.ui.icons.SvgAsset -import top.zhushenwudi.llmp.ui.icons.SvgIcon -import top.zhushenwudi.llmp.ui.icons.TintIcon +import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.theme.neuShadowColor +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon +import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon /** - * Flutter neumorphicButton defaults: 32×32, radius 6, padding 5, icon 15, depth 3, - * LightSource.bottomRight, flat shape; press → inset (negative depth). + * 新拟态按钮默认:32×32,圆角 6,内边距 5,图标 15,深度 3, + * 光源右下,平面形状;按下 → 内凹(负深度)。 */ @Composable fun NeumorphicIconButton( @@ -60,7 +60,7 @@ fun NeumorphicIconButton( hasShadow: Boolean = true, contentPadding: Dp = 5.dp, tintIcon: Boolean = true, - /** Share press state with a parent Row / clickable (drawer function rows). */ + /** 与父级 Row / clickable 共享按下状态(抽屉功能行)。 */ interactionSource: MutableInteractionSource? = null, externalPressed: Boolean = false, ) { @@ -89,7 +89,7 @@ fun NeumorphicIconButton( } } -/** Material / vector icon variant (Flutter player header uses IconData). */ +/** Material / 矢量图标变体。 */ @Composable fun NeumorphicIconButton( imageVector: ImageVector, @@ -149,7 +149,7 @@ fun NeumorphicDrawableButton( contentPadding = contentPadding, circular = radius >= size / 2, ) { - // Remount when resource changes — painterResource can keep the first frame. + // 资源变化时重挂载 — painterResource 可能保留首帧。 key(drawable) { androidx.compose.foundation.Image( painter = painterResource(drawable), @@ -163,7 +163,7 @@ fun NeumorphicDrawableButton( } } -/** Flutter HomePageView._getTopHead — circular button whose image follows [group]. */ +/** 圆形团组 Logo 按钮,图片随 `group` 变化。 */ @Composable fun NeumorphicGroupLogoButton( group: GroupKey, @@ -196,8 +196,8 @@ fun NeumorphicGroupLogoButton( } /** - * Soft dual-shadow surface. - * Rest: embossed (light TL / dark BR). Press: inset (shadows inverted). + * 柔和双阴影表面。 + * 静止:浮雕(左上亮/右下暗)。按下:内凹(阴影反转)。 */ @Composable fun NeumorphicSurface( @@ -223,9 +223,9 @@ fun NeumorphicSurface( val source = interactionSource ?: remember { MutableInteractionSource() } val pressed by source.collectIsPressedAsState() val pressable = onClick != null - // Flutter: interactive buttons invert depth while finger is down. + // 交互按钮按下时反转深度。 val isInset = externalPressed || (pressable && pressed) || - (interactionSource != null && pressed && !pressable) + (interactionSource != null && pressed && !pressable) val scale by animateFloatAsState( targetValue = if (isInset) 0.96f else 1f, @@ -256,8 +256,10 @@ fun NeumorphicSurface( CornerRadius(radius.toPx()) } val depth = 1.5.dp.toPx() - val darkOffset = if (isInset) Offset(-depth, -depth) else Offset(depth, depth) - val lightOffset = if (isInset) Offset(depth, depth) else Offset(-depth, -depth) + val darkOffset = + if (isInset) Offset(-depth, -depth) else Offset(depth, depth) + val lightOffset = + if (isInset) Offset(depth, depth) else Offset(-depth, -depth) drawSoftRoundRect( color = shadow.copy(alpha = darkAlpha), topLeft = darkOffset, @@ -292,14 +294,13 @@ fun NeumorphicSurface( .clip(shape) .background(pressedBg) .then( - if (pressable) { - Modifier.clickable( + when (val handler = onClick) { + null -> Modifier + else -> Modifier.clickable( indication = null, interactionSource = source, - onClick = { onClick?.invoke() }, + onClick = handler, ) - } else { - Modifier }, ) .padding(contentPadding), diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/PlayAllButton.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PlayAllButton.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/PlayAllButton.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PlayAllButton.kt index 881ddc3..06f58ab 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/PlayAllButton.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/PlayAllButton.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -18,15 +18,15 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.theme.neuShadowColor +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.theme.neuShadowColor @Composable fun PlayAllButton( onClick: () -> Unit, modifier: Modifier = Modifier, - /** Flutter: no shadow when colorful bgPhoto is set. */ hasShadow: Boolean = true, ) { val dark = LocalDarkTheme.current @@ -61,7 +61,7 @@ fun PlayAllButton( ) { Icon( imageVector = Icons.Rounded.PlayArrow, - contentDescription = "播放全部", + contentDescription = tr("play_all"), tint = Color.White, modifier = Modifier.size(20.dp), ) diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/SongListItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/SongListItem.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt index 0ad11a9..4665046 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/SongListItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt @@ -1,11 +1,9 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -13,8 +11,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -26,11 +27,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons @Composable fun SongListItem( @@ -45,34 +46,33 @@ fun SongListItem( modifier: Modifier = Modifier, ) { val dark = LocalDarkTheme.current - val hasBgPhoto = LocalBgPhoto.current.isNotBlank() val titleColor = when { - hasBgPhoto -> ColorMs.White isPlaying -> ColorMs.PlayingOrange dark -> ColorMs.White else -> ColorMs.Text333 } val artistColor = when { - hasBgPhoto -> ColorMs.GrayD6D isPlaying -> ColorMs.PlayingOrange else -> ColorMs.Gray999 } - // Flutter: dark || bgPhoto → DFDFDF else CCCCCC - val actionColor = if (dark || hasBgPhoto) ColorMs.GrayDFD else ColorMs.GrayCCC + val actionColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .height(TwoLineListItemHeight), verticalAlignment = Alignment.CenterVertically, ) { Box( Modifier .width(5.dp) - .height(48.dp) + .height(TwoLineListItemHeight) .background(if (isPlaying) ColorMs.PlayingOrange else Color.Transparent), ) Row( Modifier .weight(1f) + .fillMaxWidth() .padding(horizontal = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -92,11 +92,11 @@ fun SongListItem( }, ), ) { - MusicCoverImage(music = music, size = 48.dp, radius = 12.dp) + MusicCoverImage(music = music, size = ListLeadingCoverSize, radius = 12.dp) } Spacer(Modifier.width(10.dp)) - Column( - Modifier + TwoLineListTextColumn( + modifier = Modifier .weight(1f) .clickable( indication = null, @@ -105,45 +105,50 @@ fun SongListItem( if (selectMode) onCheckedChange?.invoke(!checked) else onClick() }, ), - ) { - Text( - text = music.musicName ?: "未知歌曲", - color = titleColor, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = music.artist.orEmpty(), - color = artistColor, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + title = { + Text( + text = music.musicName ?: tr("unknown_song"), + color = titleColor, + fontSize = ListItemTitleSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = ListItemTitleLineHeight, + ) + }, + subtitle = { + Text( + text = music.artist.orEmpty(), + color = artistColor, + fontSize = ListItemSubtitleSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = ListItemSubtitleLineHeight, + ) + }, + ) if (!selectMode) { - // Flutter neumorphicButton 30×30, iconSize 15, padding 5, hasShadow false NeumorphicIconButton( icon = LlmpIcons.AddNext, onClick = onPlayNext, - size = 30.dp, - iconSize = 15.dp, - contentPadding = 5.dp, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, iconColor = actionColor, hasShadow = false, - modifier = Modifier.padding(horizontal = 3.dp, vertical = 12.dp), + modifier = Modifier.padding(horizontal = 3.dp), ) NeumorphicIconButton( icon = LlmpIcons.More, onClick = onMore, - size = 30.dp, - iconSize = 15.dp, - contentPadding = 5.dp, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, iconColor = actionColor, hasShadow = false, - modifier = Modifier.padding(horizontal = 3.dp, vertical = 12.dp), + modifier = Modifier.padding(horizontal = 3.dp), ) } } @@ -156,23 +161,21 @@ fun CircularCheckBox( onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - Box( + Icon( + imageVector = if (checked) { + Icons.Filled.CheckCircle + } else { + Icons.Outlined.RadioButtonUnchecked + }, + contentDescription = null, + tint = if (checked) ColorMs.PinkF940A7 else Color(0xFFD6D6D6), modifier = modifier .size(25.dp) - .clip(CircleShape) - .border( - width = 2.dp, - color = if (checked) ColorMs.PinkF940A7 else Color(0xFFD6D6D6), - shape = CircleShape, - ) - .background(if (checked) ColorMs.PinkF940A7 else Color.Transparent) - .clickable { onCheckedChange(!checked) }, - contentAlignment = Alignment.Center, - ) { - if (checked) { - Text("✓", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) - } - } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onCheckedChange(!checked) }, + ) } @Composable diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/TopLibraryTabs.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/TopLibraryTabs.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt index d4f93e1..bfd1d34 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/TopLibraryTabs.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/TopLibraryTabs.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.components +package com.zhushenwudi.lovelivemusicplayer.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,20 +31,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.navigation.LibraryTab -import top.zhushenwudi.llmp.navigation.TopSection -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.theme.katongFontFamily -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab +import com.zhushenwudi.lovelivemusicplayer.navigation.TopSection +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.theme.katongFontFamily +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset @Composable fun TopLibraryTabs( selected: TopSection, onSelect: (TopSection) -> Unit, modifier: Modifier = Modifier, - /** Flutter IgnorePointer when selectMode > 0. */ + /** 选择模式 > 0 时忽略指针事件。 */ interactionEnabled: Boolean = true, ) { val katong = katongFontFamily() @@ -71,7 +72,7 @@ fun TopLibraryTabs( .padding(horizontal = 4.dp), ) { Text( - text = section.title, + text = tr(section.titleKey), fontFamily = katong, fontWeight = FontWeight.Bold, fontSize = if (selectedTab) 20.sp else 16.sp, @@ -83,7 +84,11 @@ fun TopLibraryTabs( .width(if (selectedTab) 16.dp else 0.dp) .height(4.dp) .clip(RoundedCornerShape(2.dp)) - .background(if (selectedTab) ColorMs.PinkF940A7 else ColorMs.PinkF940A7.copy(alpha = 0f)), + .background( + if (selectedTab) ColorMs.PinkF940A7 else ColorMs.PinkF940A7.copy( + alpha = 0f + ) + ), ) } } @@ -92,7 +97,7 @@ fun TopLibraryTabs( private val FilterIcon = SvgAsset("drawable/main_ic_function.svg") -/** Flutter HomeState.selectMode: 0 normal, 1 batch, 2 search. */ +/** 选择模式:0 正常,1 批量,2 搜索。 */ enum class LibrarySelectMode { Normal, Batch, Search } @Composable @@ -123,6 +128,7 @@ fun SongLibraryTopBar( onFinish = onBatchFinish, modifier = modifier, ) + LibrarySelectMode.Search -> SearchTopBar( query = searchQuery, onQueryChange = onSearchQueryChange, @@ -130,6 +136,7 @@ fun SongLibraryTopBar( onCancel = onSearchCancel, modifier = modifier, ) + LibrarySelectMode.Normal -> NormalTopBar( countLabel = countLabel, libraryTab = libraryTab, @@ -177,39 +184,40 @@ private fun NormalTopBar( NeumorphicIconButton( icon = LlmpIcons.Search, onClick = onSearchOpen, - size = 30.dp, - iconSize = 15.dp, - radius = 6.dp, - contentPadding = 5.dp, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, hasShadow = false, iconColor = iconTint, + modifier = Modifier.padding(horizontal = 3.dp), ) } NeumorphicIconButton( icon = if (sortAsc) LlmpIcons.SortAsc else LlmpIcons.SortDesc, onClick = onSort, - size = 30.dp, - iconSize = 15.dp, - radius = 6.dp, - contentPadding = 5.dp, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, hasShadow = false, iconColor = iconTint, + modifier = Modifier.padding(horizontal = 3.dp), ) if (isAlbum) { Spacer(Modifier.width(15.dp)) } else { - Box(Modifier.padding(end = 18.dp)) { - NeumorphicIconButton( - icon = FilterIcon, - onClick = onBatchOpen, - size = 30.dp, - iconSize = 15.dp, - radius = 6.dp, - contentPadding = 5.dp, - hasShadow = false, - iconColor = iconTint, - ) - } + NeumorphicIconButton( + icon = FilterIcon, + onClick = onBatchOpen, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, + hasShadow = false, + iconColor = iconTint, + modifier = Modifier.padding(start = 3.dp, end = 18.dp), + ) } } } @@ -253,7 +261,10 @@ private fun SearchTopBar( value = query, onValueChange = onQueryChange, singleLine = true, - textStyle = TextStyle(color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 15.sp), + textStyle = TextStyle( + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = 15.sp + ), cursorBrush = SolidColor(textColor), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSubmit(query) }), @@ -262,7 +273,7 @@ private fun SearchTopBar( .focusRequester(focus), decorationBox = { inner -> if (query.isEmpty()) { - Text("输入歌曲关键字", color = textColor, fontSize = 15.sp) + Text(tr("input_song_keyword"), color = textColor, fontSize = 15.sp) } inner() }, @@ -273,7 +284,7 @@ private fun SearchTopBar( color = textColor, modifier = Modifier .clickable { - // Flutter: clear + filterItem("") + // 清空搜索并过滤 onQueryChange("") onSubmit("") } @@ -289,7 +300,7 @@ private fun SearchTopBar( .clickable(onClick = onCancel) .padding(horizontal = 10.dp, vertical = 8.dp), ) { - Text("取消", color = textColor, fontSize = 15.sp) + Text(tr("cancel"), color = textColor, fontSize = 15.sp) } } } @@ -314,13 +325,13 @@ private fun BatchTopBar( CircularCheckBox(checked = selectAll, onCheckedChange = onSelectAll) Spacer(Modifier.width(10.dp)) Text( - text = "已选择 $checkedCount 首", + text = tr("selected_count", checkedCount), color = textColor, fontSize = 15.sp, modifier = Modifier.weight(1f), ) Text( - text = "完成", + text = tr("finish"), color = textColor, fontSize = 15.sp, modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/CoilSvg.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/CoilSvg.kt new file mode 100644 index 0000000..7b9bc07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/CoilSvg.kt @@ -0,0 +1,19 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.icons + +import androidx.compose.runtime.Composable +import coil3.PlatformContext +import coil3.compose.setSingletonImageLoaderFactory +import com.zhushenwudi.lovelivemusicplayer.ui.image.createLlmpImageLoader + +/** + * 注册 Coil 单例:SVG 解码 + 1 天网络图片缓存(SWR)。 + * 网络 https(artist_webp 等)需要 classpath 上的 `coil-network-ktor3` — + * Coil 3 无此模块不会拉取网络图片。 + * 须在应用根节点、任何 AsyncImage 之前调用一次。 + */ +@Composable +fun InstallCoilSvg() { + setSingletonImageLoaderFactory { context: PlatformContext -> + createLlmpImageLoader(context) + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/LlmpIcons.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/LlmpIcons.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt index 37c2003..f0228f5 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/LlmpIcons.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.icons +package com.zhushenwudi.lovelivemusicplayer.ui.icons import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon @@ -12,11 +12,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res /** - * Original Flutter SVG assets under composeResources/drawable. - * Rendered with Coil3 + coil-svg — never via painterResource (unsupported on Android). + * SVG 图标资源,位于 composeResources/drawable。 + * 使用 Coil3 + coil-svg 渲染,不用 painterResource(Android 不支持)。 */ object LlmpIcons { val TabMusic = SvgAsset("drawable/tab_tab_music.svg") diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.kt new file mode 100644 index 0000000..bc24133 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.kt @@ -0,0 +1,7 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.image + +import coil3.PlatformContext +import okio.Path + +/** Coil 持久化磁盘缓存目录(进程重启后保留)。 */ +internal expect fun persistentImageCacheDirectory(context: PlatformContext): Path diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt new file mode 100644 index 0000000..95ce5d0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt @@ -0,0 +1,335 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.image + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import coil3.Image +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter.Companion.DefaultTransform +import coil3.compose.AsyncImagePainter.State +import coil3.compose.LocalPlatformContext +import coil3.compose.asPainter +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.CacheStrategy +import coil3.network.CacheStrategy.ReadResult +import coil3.network.CacheStrategy.WriteResult +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse +import coil3.network.ktor3.KtorNetworkFetcherFactory +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.Options +import coil3.request.crossfade +import coil3.svg.SvgDecoder +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog + +private const val IMG_TAG = "LLMP-Img" + +/** 网络写入成功后的磁盘缓存软 TTL。 */ +private const val IMAGE_CACHE_TTL_MS = 24L * 60 * 60 * 1000 + +/** + * Coil 网络缓存策略: + * - `IMAGE_CACHE_TTL_MS` 内:仅使用磁盘缓存。 + * - 过期后:仍立即显示旧图(不空白/不闪烁),后台刷新;新字节写入缓存后再切换 UI。 + */ +@OptIn(ExperimentalCoilApi::class) +internal object DayTtlSwrCacheStrategy : CacheStrategy { + + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ): ReadResult { + val url = networkRequest.url + if (cacheResponse.code !in 200 until 300) { + return ReadResult(networkRequest) + } + if (isExpired(cacheResponse)) { + ImageRevalidator.schedule(url, options.context) + } + // 显示时优先磁盘图,避免过期导致 UI 空白。 + return ReadResult(cacheResponse) + } + + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ): WriteResult { + val url = networkRequest.url + if (networkResponse.code == 304 && cacheResponse != null) { + // 仅刷新元数据/TTL,不重写正文。 + return WriteResult( + networkResponse.copy( + headers = cacheResponse.headers, + body = null, + ), + ) + } + // 仅持久化成功图片;失败请求下次访问重试。 + if (networkResponse.code in 200 until 300) { + return WriteResult(networkResponse) + } + AppLog.w( + IMG_TAG, + "diskWrite SKIP code=${networkResponse.code} url=$url", + ) + return WriteResult.DISABLED + } + + private fun isExpired(cacheResponse: NetworkResponse): Boolean { + val cachedAt = maxOf(cacheResponse.requestMillis, cacheResponse.responseMillis) + // 无时间戳视为新鲜;标为过期会触发后台 + // 重新校验 + UI 重挂载,可能 Error 并闪烁/卡住占位图。 + if (cachedAt <= 0L) return false + return currentTimeMs() - cachedAt >= IMAGE_CACHE_TTL_MS + } +} + +internal object ImageRevalidator { + private val mutex = Mutex() + private val inFlight = mutableSetOf() + + private var imageLoader: ImageLoader? = null + + fun bind(loader: ImageLoader) { + imageLoader = loader + } + + suspend fun schedule(url: String, context: PlatformContext) { + if (!url.isHttpUrl()) return + val cacheKey = url.toImageCacheKey() + mutex.withLock { + if (!inFlight.add(cacheKey)) { + return + } + } + val loader = imageLoader + if (loader == null) { + mutex.withLock { inFlight.remove(cacheKey) } + AppLog.w(IMG_TAG, "revalidate skip-noLoader url=$url") + return + } + loader.enqueue( + ImageRequest.Builder(context) + .data(url) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + // 跳过读取过期磁盘项;强制网络拉取后重写缓存。 + .diskCachePolicy(CachePolicy.WRITE_ONLY) + .memoryCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .crossfade(false) + .listener( + onSuccess = { _, result -> + CoverImageHolder.put(cacheKey, result.image) + complete(cacheKey) + }, + onError = { _, result -> + AppLog.w( + IMG_TAG, + "revalidate ERR ${result.throwable.message} url=$url", + ) + complete(cacheKey) + }, + onCancel = { _ -> + complete(cacheKey) + }, + ) + .build(), + ) + } + + private fun complete(cacheKey: String) { + // 监听器在 CacheStrategy 协程外运行;尽力而为,不挂起。 + inFlight.remove(cacheKey) + // 故意不触发 UI 版本更新:重新校验后重挂载可见封面 + // 曾导致间歇 Error → 占位图锁定。磁盘/内存已更新,供 + // 下次组合使用(如滚回)。 + } +} + + +/** + * 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。 + * AsyncImage dispose 后持有 [androidx.compose.ui.graphics.painter.Painter] 不安全(RememberObserver.onForgotten)。 + * 保留 [androidx.compose.ui.graphics.Image] 可在底图/Loading 时重建 Painter,避免空白帧。 + */ +internal object CoverImageHolder { + private const val CAP = 96 + private val keys = ArrayDeque() + private val map = mutableMapOf() + + fun get(key: String): Image? = map[key] + + fun put(key: String, image: Image) { + if (map[key] === image) return + if (map.put(key, image) == null) { + keys.addLast(key) + } + while (keys.size > CAP) { + val old = keys.removeFirst() + map.remove(old) + } + } +} + +/** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */ +internal fun String.toImageCacheKey(): String = replace("'", "%27") + +@Composable +internal fun rememberCoverPainter(cacheKey: String?): Painter? { + if (cacheKey.isNullOrBlank()) return null + val context = LocalPlatformContext.current + val image = CoverImageHolder.get(cacheKey) ?: return null + return remember(cacheKey, image) { image.asPainter(context) } +} + +@OptIn(ExperimentalCoilApi::class) +fun createLlmpImageLoader(context: PlatformContext): ImageLoader { + val diskCacheDir = persistentImageCacheDirectory(context) + val loader = ImageLoader.Builder(context) + .crossfade(false) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.35) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(diskCacheDir) + .maxSizePercent(0.05) + .build() + } + .components { + add(SvgDecoder.Factory()) + add( + KtorNetworkFetcherFactory( + cacheStrategy = { DayTtlSwrCacheStrategy }, + ), + ) + } + .build() + ImageRevalidator.bind(loader) + return loader +} + +/** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */ +@Composable +fun LlmpAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + stableCacheKey: String? = null, + transform: (State) -> State = DefaultTransform, + onState: ((State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DefaultFilterQuality, + clipToBounds: Boolean = true, +) { + val context = LocalPlatformContext.current + val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() } + val cacheKey = stableCacheKey?.takeIf { it.isNotBlank() } + ?: httpUrl?.toImageCacheKey() + + // 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面 + // 可能 Error 一次并使占位图锁定,行仍在屏幕上。 + val request = remember(model, httpUrl, cacheKey) { + ImageRequest.Builder(context) + .data(model) + .crossfade(false) + .apply { + if (cacheKey != null) { + memoryCacheKey(cacheKey) + diskCacheKey(cacheKey) + placeholderMemoryCacheKey(cacheKey) + listener( + onSuccess = { _, result -> + CoverImageHolder.put(cacheKey, result.image) + }, + onError = { _, result -> + AppLog.w( + IMG_TAG, + "req ERR ${result.throwable.message} url=$model", + ) + }, + ) + } + } + .build() + } + AsyncImage( + model = request, + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + clipToBounds = clipToBounds, + ) +} + +/** 便捷重载,匹配 Coil 的 onLoading/onSuccess/onError 回调。 */ +@Composable +fun LlmpAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + stableCacheKey: String? = null, + onLoading: ((State.Loading) -> Unit)? = null, + onSuccess: ((State.Success) -> Unit)? = null, + onError: ((State.Error) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DefaultFilterQuality, + clipToBounds: Boolean = true, +) { + LlmpAsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + stableCacheKey = stableCacheKey, + onState = { state -> + when (state) { + is State.Loading -> onLoading?.invoke(state) + is State.Success -> onSuccess?.invoke(state) + is State.Error -> onError?.invoke(state) + is State.Empty -> Unit + } + }, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + clipToBounds = clipToBounds, + ) +} + +private fun String.isHttpUrl(): Boolean = + startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.kt new file mode 100644 index 0000000..6728c46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.kt @@ -0,0 +1,7 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.platform + +import androidx.compose.runtime.Composable + +/** 将纯文本复制到系统剪贴板。 */ +@Composable +expect fun rememberCopyTextHandler(): (String) -> Unit diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/FullPlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt similarity index 52% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/FullPlayerScreen.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt index a78f57b..5a2f449 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/FullPlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/FullPlayerScreen.kt @@ -1,1142 +1,1065 @@ -package top.zhushenwudi.llmp.ui.player - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsDraggedAsState -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.MoreHoriz -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -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 kotlin.math.abs -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.lyric.LoadedLyric -import top.zhushenwudi.llmp.lyric.LrcParser -import top.zhushenwudi.llmp.lyric.PlatformInfo -import top.zhushenwudi.llmp.platform.PlatformServices -import top.zhushenwudi.llmp.player.LoopMode -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.components.AddToMenuDialog -import top.zhushenwudi.llmp.ui.components.DialogMoreWithMusic -import top.zhushenwudi.llmp.ui.components.DialogPlaylist -import top.zhushenwudi.llmp.ui.components.DialogSongInfo -import top.zhushenwudi.llmp.ui.components.NeumorphicIconButton -import top.zhushenwudi.llmp.ui.components.NeumorphicSurface -import top.zhushenwudi.llmp.ui.components.PlayerMusicCover -import top.zhushenwudi.llmp.ui.components.rememberMusicCoverLocalPath -import top.zhushenwudi.llmp.ui.components.rememberMusicCoverUri -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import kotlin.math.roundToLong - -/** Flutter [PlayerType]: cover ↔ lyric (tachie deferred). */ -private enum class PlayerContent { - Cover, - Lyric, -} - -@Composable -fun FullPlayerScreen( - onBack: () -> Unit, - onOpenAlbum: (albumId: String) -> Unit = {}, -) { - val state by AppContainer.player.state.collectAsState() - val sharedLyric by AppContainer.playingLyric.collectAsState() - val scope = rememberCoroutineScope() - var lyric by remember { mutableStateOf(LoadedLyric()) } - var lrcType by remember { mutableIntStateOf(AppContainer.lyricService.lrcType) } - var content by remember { mutableStateOf(PlayerContent.Cover) } - var isLove by remember(state.current?.musicId) { - mutableStateOf(state.current?.isLove == true) - } - var showAddToMenu by remember { mutableStateOf(false) } - var showMore by remember { mutableStateOf(false) } - var showSongInfo by remember { mutableStateOf(false) } - var showPlaylist by remember { mutableStateOf(false) } - var menus by remember { mutableStateOf>(emptyList()) } - val listState = rememberLazyListState() - val currentIdx = LrcParser.currentIndex(lyric.main, state.positionMs) - val dark = LocalDarkTheme.current - val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary - // Flutter GlobalLogic.hasSkin (炫彩模式). Re-read when panel opens so settings toggles apply. - val panelOpen = AppContainer.weSlide.isOpened - val hasSkin = remember(panelOpen) { AppContainer.settings.isColorful } - val coverLocal = rememberMusicCoverLocalPath(state.current) - val coverUri = rememberMusicCoverUri(state.current) - // Flutter iconColor — cover palette ARGB(150,…) - val skinColor = rememberMiniPlayerBarColor(coverLocal) - val skinOpaque = remember(skinColor) { skinColor.copy(alpha = 1f) } - // Flutter: hasSkin → white icons + iconColor btn; else theme - val iconTint = if (hasSkin || dark) ColorMs.White else ColorMs.Text333 - val controlBg = when { - hasSkin -> skinColor - dark -> Color.Black.copy(alpha = 0.15f) - else -> Color.White.copy(alpha = 0.5f) - } - val thumbColor = when { - hasSkin -> skinOpaque - dark -> ColorMs.NeuShadowDark - else -> ColorMs.PinkF940A7 - } - val activeTrack = when { - hasSkin -> skinColor - dark -> ColorMs.NeuShadowDark.copy(alpha = 153f / 255f) - else -> ColorMs.SeekTrackLight.copy(alpha = 153f / 255f) - } - val inactiveTrack = when { - hasSkin -> skinOpaque.copy(alpha = 0.3f) - dark -> ColorMs.Dark272727 - else -> ColorMs.SeekTrackLight.copy(alpha = 153f / 255f) - } - val skinText = hasSkin || dark - val lyricPlayingColor = if (skinText) ColorMs.White else Color.Black - val lyricOtherColor = if (hasSkin) { - ColorMs.GrayDFD.copy(alpha = 0.4f) - } else if (dark) { - ColorMs.GrayDFD.copy(alpha = 0.4f) - } else { - ColorMs.Gray999 - } - val timeColor = if (hasSkin || dark) ColorMs.GrayDFD else ColorMs.Gray999 - // Flutter PlayerClosableEvent: reopen panel → force cover mode - LaunchedEffect(panelOpen) { - if (panelOpen) content = PlayerContent.Cover - } - // Lyric list: disable WeSlide nested/panel drag so scrolling isn't stolen. - // Chrome between lyric body and seekbar still drives dismiss via onChromeVerticalDrag. - LaunchedEffect(content) { - AppContainer.weSlide.dragEnabled = content == PlayerContent.Cover - } - DisposableEffect(Unit) { - onDispose { AppContainer.weSlide.dragEnabled = true } - } - - // Track-change fetch: AppContainer.bindLyricLoader → LyricLogic.getLrc - LaunchedEffect(sharedLyric) { - lyric = sharedLyric - } - // Flutter lrcType.stream → reloadLyricToController (DB cache only, no network) - LaunchedEffect(lrcType) { - AppContainer.lyricService.lrcType = lrcType - lyric = AppContainer.applyLyricDisplayType() - } - LaunchedEffect(state.current?.musicId) { - val id = state.current?.musicId - isLove = id?.let { mid -> - AppContainer.library.getAllLove().any { it.musicId == mid } - } == true - } - - LaunchedEffect(currentIdx, lyric, state.isPlaying, state.current?.musicId) { - val line1 = lyric.main.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(0)?.text - val line2 = when { - lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(currentIdx + 1)?.text - else -> lyric.main.getOrNull(currentIdx + 1)?.text - } - val highlight = when { - lyric.main.isEmpty() -> -1 - currentIdx < 0 -> -1 - else -> 1 - } - PlatformServices.desktopLyric.updateLines(line1, line2, highlight) - PlatformServices.homeWidget.update( - songName = state.current?.musicName, - artist = state.current?.artist, - isPlaying = state.isPlaying, - favorite = isLove, - lyricLine1 = line1, - lyricLine2 = line2, - currentLine = highlight, - coverPath = state.current?.let { AppContainer.pathResolver.absoluteCoverPath(it) }, - playText = "播放中,已暂停", - ) - } - - // Flutter PlayerPage: coverBg (hasSkin) under top+bottom chrome - Box(Modifier.fillMaxSize()) { - if (hasSkin) { - PlayerColorfulBackground(coverUri = coverUri, fallback = skinOpaque) - } else { - Box(Modifier.fillMaxSize().background(bg)) - } - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(15.dp)) - - PlayerHeader( - title = state.current?.musicName ?: "未播放", - artist = state.current?.artist.orEmpty(), - dark = dark, - colorful = hasSkin, - controlBg = controlBg, - iconTint = iconTint, - onBack = onBack, - onMore = { - if (state.current != null) showMore = true - }, - ) - - Spacer(Modifier.height(10.dp)) - - // Flutter stackBody height 400.h — cover OR lyric scroll - Box( - Modifier - .fillMaxWidth() - .height(400.dp), - ) { - when (content) { - PlayerContent.Cover -> { - PlayerCoverBody( - music = state.current, - line1 = lyric.main.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(0)?.text.orEmpty(), - line2 = when { - lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty() - else -> lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty() - }, - playingColor = lyricPlayingColor, - otherColor = lyricOtherColor, - onTap = { content = PlayerContent.Lyric }, - ) - } - PlayerContent.Lyric -> { - PlayerLyricScroll( - lyric = lyric, - currentIdx = currentIdx, - listState = listState, - playingColor = lyricPlayingColor, - otherColor = lyricOtherColor, - onTapBack = { content = PlayerContent.Cover }, - onSeek = { positionMs -> - AppContainer.player.seekTo(positionMs) - AppContainer.player.play() - }, - ) - } - } - } - - // Between lyric body and seekbar: allow pull-to-dismiss while lyrics stay scroll-only. - Column( - Modifier - .weight(1f) - .fillMaxWidth() - .then( - if (content == PlayerContent.Lyric) { - Modifier.pointerInput(Unit) { - var lastDrag = 0f - detectVerticalDragGestures( - onDragEnd = { - AppContainer.weSlide.onChromeVerticalDragEnd?.invoke(lastDrag) - lastDrag = 0f - }, - onDragCancel = { - AppContainer.weSlide.onChromeVerticalDragCancel?.invoke() - lastDrag = 0f - }, - onVerticalDrag = { change, dragAmount -> - change.consume() - lastDrag = dragAmount - AppContainer.weSlide.onChromeVerticalDrag?.invoke(dragAmount) - }, - ) - } - } else { - Modifier - }, - ), - ) { - Spacer(Modifier.height(10.dp)) - // Flutter funcButton — differs by cover / lyric - PlayerFuncButtons( - content = content, - isLove = isLove, - lrcType = lrcType, - dark = dark, - colorful = hasSkin, - controlBg = controlBg, - iconTint = iconTint, - onToggleLove = { - state.current?.musicId?.let { id -> - isLove = AppContainer.toggleLove(id) - } - }, - onAddSong = { - if (state.current?.musicId != null) { - menus = AppContainer.library.getAllMenus() - showAddToMenu = true - } - }, - onToggleTranslate = { - // Flutter LyricLogic.toggleTranslate → reloadLyricToController - AppContainer.lyricService.toggleType() - lrcType = AppContainer.lyricService.lrcType - lyric = AppContainer.applyLyricDisplayType() - }, - ) - Spacer(Modifier.weight(1f)) - } - - Column( - Modifier - .fillMaxWidth() - .height(170.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - PlayerSeekBar( - positionMs = state.positionMs, - durationMs = state.durationMs, - thumbColor = thumbColor, - activeTrack = activeTrack, - inactiveTrack = inactiveTrack, - timeColor = timeColor, - onSeek = { AppContainer.player.seekTo(it) }, - ) - Spacer(Modifier.height(20.dp)) - Row( - Modifier - .fillMaxWidth() - .height(80.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - NeumorphicIconButton( - icon = when (state.loopMode) { - LoopMode.LIST -> LlmpIcons.Loop - LoopMode.SINGLE -> LlmpIcons.LoopOne - LoopMode.SHUFFLE -> LlmpIcons.Shuffle - }, - onClick = { - val next = when (state.loopMode) { - LoopMode.LIST -> LoopMode.SINGLE - LoopMode.SINGLE -> LoopMode.SHUFFLE - LoopMode.SHUFFLE -> LoopMode.LIST - } - AppContainer.player.setLoopMode(next) - AppContainer.settings.loopMode = next - }, - size = 32.dp, - iconSize = 15.dp, - contentPadding = 9.dp, - iconColor = iconTint, - background = controlBg, - ) - NeumorphicIconButton( - icon = LlmpIcons.Prev, - onClick = { AppContainer.player.previous() }, - size = 60.dp, - iconSize = 15.dp, - radius = 30.dp, - contentPadding = 20.dp, - iconColor = iconTint, - background = controlBg, - ) - NeumorphicIconButton( - icon = if (state.isPlaying) LlmpIcons.Pause else LlmpIcons.Play, - onClick = { AppContainer.player.toggle() }, - size = 80.dp, - iconSize = 26.dp, - radius = 40.dp, - contentPadding = 25.dp, - iconColor = iconTint, - background = controlBg, - ) - NeumorphicIconButton( - icon = LlmpIcons.Next, - onClick = { AppContainer.player.next() }, - size = 60.dp, - iconSize = 15.dp, - radius = 40.dp, - contentPadding = 20.dp, - iconColor = iconTint, - background = controlBg, - ) - NeumorphicIconButton( - icon = LlmpIcons.Playlist, - onClick = { showPlaylist = true }, - size = 32.dp, - iconSize = 15.dp, - contentPadding = 9.dp, - iconColor = iconTint, - background = controlBg, - ) - } - } - Spacer(Modifier.height(28.dp)) - } - } - - if (showAddToMenu) { - AddToMenuDialog( - menus = menus, - onPick = { menu -> - state.current?.musicId?.let { id -> - val ids = (menu.music + id).distinct() - AppContainer.library.upsertMenu(menu.copy(music = ids)) - } - showAddToMenu = false - }, - onCreate = { name -> - val musicId = state.current?.musicId - val id = AppContainer.library.nextPhoneMenuId() - val menu = Menu( - id = id, - isPhone = true, - music = listOfNotNull(musicId), - date = "", - name = name, - ) - AppContainer.library.upsertMenu(menu) - showAddToMenu = false - }, - onDismiss = { showAddToMenu = false }, - ) - } - - val currentMusic = state.current - if (showMore && currentMusic != null) { - DialogMoreWithMusic( - music = currentMusic, - isPlayer = true, - onDismiss = { showMore = false }, - onClosePanel = onBack, - onOpenAlbum = onOpenAlbum, - onOpenAddToMenu = { - menus = AppContainer.library.getAllMenus() - showAddToMenu = true - }, - onShowSongInfo = { showSongInfo = true }, - ) - } - if (showSongInfo && currentMusic != null) { - DialogSongInfo( - music = currentMusic, - onDismiss = { showSongInfo = false }, - ) - } - if (showPlaylist) { - DialogPlaylist(onDismiss = { showPlaylist = false }) - } -} - -/** - * Flutter PlayerPage.coverBg: - * full-screen cover (or logo) → BackdropFilter blur σ=20 → black@40%. - * Compose [Modifier.blur] leaves transparent edges that reveal the WeSlide panel color; - * use scaled [BlurEffect] + clip + solid base so lyric/cover pages stay opaque. - */ -@OptIn(ExperimentalResourceApi::class) -@Composable -private fun PlayerColorfulBackground( - coverUri: String?, - fallback: Color, -) { - val logoUri = remember { Res.getUri("drawable/logo_logo.png") } - val model = if (!coverUri.isNullOrBlank()) coverUri else logoUri - val blurPx = with(LocalDensity.current) { 20.dp.toPx() } - Box(Modifier.fillMaxSize()) { - // Base: Flutter iconColor when image missing; also fills blur edge gaps. - Box(Modifier.fillMaxSize().background(fallback)) - Box( - Modifier - .fillMaxSize() - .clip(RectangleShape), - ) { - AsyncImage( - model = model, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - // Expand past bounds so blur sampling does not fade to transparent. - scaleX = 1.2f - scaleY = 1.2f - renderEffect = BlurEffect(blurPx, blurPx, TileMode.Clamp) - }, - ) - } - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), - ) - } -} - -@Composable -private fun PlayerHeader( - title: String, - artist: String, - dark: Boolean, - colorful: Boolean, - controlBg: Color, - iconTint: Color, - onBack: () -> Unit, - onMore: () -> Unit, -) { - val headerBtnBg = if (colorful) controlBg else Color.Transparent - val headerIconTint = if (colorful) ColorMs.White else iconTint - Row( - Modifier - .fillMaxWidth() - .height(44.dp) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NeumorphicIconButton( - imageVector = Icons.Filled.KeyboardArrowDown, - onClick = onBack, - size = 32.dp, - iconSize = 20.dp, - radius = 6.dp, - contentPadding = 5.dp, - iconColor = headerIconTint, - background = headerBtnBg, - contentDescription = "收起", - ) - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - style = TextStyle( - color = if (dark || colorful) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - ), - ) - if (artist.isNotBlank()) { - Text( - text = artist, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - style = TextStyle( - color = when { - colorful -> ColorMs.GrayDFD - dark -> ColorMs.GrayDFD - else -> ColorMs.Gray999 - }, - fontSize = 12.sp, - ), - ) - } - } - } - NeumorphicIconButton( - imageVector = Icons.Filled.MoreHoriz, - onClick = onMore, - size = 32.dp, - iconSize = 18.dp, - radius = 6.dp, - contentPadding = 5.dp, - iconColor = headerIconTint, - background = headerBtnBg, - contentDescription = "更多", - ) - } -} - -/** Flutter Cover: 273 cover + PlayerInfo (2 lyric lines). Tap → lyric page. */ -@Composable -private fun PlayerCoverBody( - music: Music?, - line1: String, - line2: String, - playingColor: Color, - otherColor: Color, - onTap: () -> Unit, -) { - Column( - Modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onTap, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - if (PlatformInfo.allowEulaLyric) { - Spacer(Modifier.height(18.dp)) - } - PlayerCoverArt(music = music) - if (PlatformInfo.allowEulaLyric) { - Column( - Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = line1, - color = playingColor, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(10.dp)) - Text( - text = line2, - color = otherColor, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -/** - * Flutter LyricView + [_LyricSelectBar]: - * - auto-follow current line - * - drag to select center line → green time/line/play → seek - * - tap anywhere → back to cover (play button on select bar still seeks) - */ -@Composable -private fun PlayerLyricScroll( - lyric: LoadedLyric, - currentIdx: Int, - listState: LazyListState, - playingColor: Color, - otherColor: Color, - onTapBack: () -> Unit, - onSeek: (positionMs: Long) -> Unit, -) { - if (lyric.main.isEmpty()) { - Box( - Modifier - .fillMaxSize() - .pointerInput(onTapBack) { - detectTapGestures(onTap = { onTapBack() }) - }, - contentAlignment = Alignment.Center, - ) { - Text("暂无歌词", color = otherColor, fontSize = 17.sp) - } - return - } - - val isDragged by listState.interactionSource.collectIsDraggedAsState() - var selecting by remember { mutableStateOf(false) } - var selectedIndex by remember { mutableIntStateOf(-1) } - val selectColor = Color(0xFF4CAF50) - // LazyColumn: [pad-top] + lyrics + [pad-bottom] - val lyricListOffset = 1 - - val centerLyricIndex by remember { - derivedStateOf { - val info = listState.layoutInfo - if (info.visibleItemsInfo.isEmpty()) return@derivedStateOf -1 - val center = (info.viewportStartOffset + info.viewportEndOffset) / 2f - val listIndex = info.visibleItemsInfo.minByOrNull { item -> - abs((item.offset + item.size / 2f) - center) - }?.index ?: return@derivedStateOf -1 - val lyricIdx = listIndex - lyricListOffset - if (lyricIdx in lyric.main.indices) lyricIdx else -1 - } - } - - LaunchedEffect(isDragged) { - if (isDragged) selecting = true - } - - LaunchedEffect(selecting, isDragged, listState.isScrollInProgress) { - if (!selecting) return@LaunchedEffect - snapshotFlow { centerLyricIndex } - .distinctUntilChanged() - .collect { idx -> - if (idx >= 0) selectedIndex = idx - } - } - - // Flutter selectionAutoResumeDuration — idle then resume auto-follow - LaunchedEffect(isDragged, listState.isScrollInProgress, selecting) { - if (!selecting) return@LaunchedEffect - if (isDragged || listState.isScrollInProgress) return@LaunchedEffect - delay(3_000) - if (!isDragged && !listState.isScrollInProgress) { - selecting = false - selectedIndex = -1 - } - } - - // Flutter LyricStyle.anchorPosition / activeAnchorPosition = 0.5 → keep playing line centered. - LaunchedEffect(currentIdx, selecting, lyric.main.size) { - if (selecting) return@LaunchedEffect - if (currentIdx < 0 || lyric.main.isEmpty()) return@LaunchedEffect - val index = (currentIdx + lyricListOffset) - .coerceIn(lyricListOffset, lyric.main.size) - runCatching { listState.animateScrollToItemCentered(index) } - } - - // Reset selection when track / lyric set changes - LaunchedEffect(lyric.raw?.uid, lyric.main.size) { - selecting = false - selectedIndex = -1 - } - - BoxWithConstraints(Modifier.fillMaxSize()) { - // Half-viewport pads so first/last lines can sit on the center anchor. - val edgePad = maxHeight / 2 - Box(Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Replace vertical contentPadding with tappable spacers so empty area also returns to cover. - item(key = "pad-top") { - Spacer( - Modifier - .fillMaxWidth() - .height(edgePad) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onTapBack, - ), - ) - } - itemsIndexed(lyric.main, key = { index, line -> "${line.timeMs}-$index" }) { index, line -> - val translation = lyric.translation.getOrNull(index)?.text - val isSelected = selecting && index == selectedIndex - val active = !selecting && index == currentIdx - val lineColor = when { - isSelected -> selectColor - active -> playingColor - else -> otherColor - } - Column( - Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onTapBack, - ) - .padding(vertical = 10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = line.text, - fontSize = 17.sp, - fontWeight = if (active || isSelected) FontWeight.Bold else FontWeight.Normal, - color = lineColor, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - if (!translation.isNullOrBlank()) { - Spacer(Modifier.height(8.dp)) - Text( - text = translation, - fontSize = 15.sp, - color = lineColor, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - item(key = "pad-bottom") { - Spacer( - Modifier - .fillMaxWidth() - .height(edgePad) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onTapBack, - ), - ) - } - } - - if (selecting && selectedIndex in lyric.main.indices) { - LyricSelectBar( - timeMs = lyric.main[selectedIndex].timeMs, - color = selectColor, - modifier = Modifier.align(Alignment.Center), - onPlay = { - val time = lyric.main[selectedIndex].timeMs - selecting = false - selectedIndex = -1 - onSeek(time) - }, - onTapBack = onTapBack, - ) - } - } - } -} - -/** Scroll so [index] is vertically centered (Flutter lyric anchorPosition 0.5). */ -private suspend fun LazyListState.animateScrollToItemCentered(index: Int) { - val layout = layoutInfo - val viewport = layout.viewportEndOffset - layout.viewportStartOffset - if (viewport <= 0) { - animateScrollToItem(index) - return - } - val visible = layout.visibleItemsInfo.firstOrNull { it.index == index } - if (visible != null) { - val center = layout.viewportStartOffset + viewport / 2 - val itemCenter = visible.offset + visible.size / 2 - val delta = itemCenter - center - if (abs(delta) > 1) animateScrollBy(delta.toFloat()) - return - } - val estimated = layout.visibleItemsInfo - .asSequence() - .filter { it.index > 0 } - .map { it.size } - .average() - .takeIf { !it.isNaN() } - ?.toInt() - ?: (viewport / 10).coerceAtLeast(1) - animateScrollToItem(index, scrollOffset = -(viewport / 2 - estimated / 2)) - val after = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return - val center = layoutInfo.viewportStartOffset + - (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2 - val itemCenter = after.offset + after.size / 2 - val delta = itemCenter - center - if (abs(delta) > 1) animateScrollBy(delta.toFloat()) -} - -/** Flutter [_LyricSelectBar]: green mm:ss + divider + play. */ -@Composable -private fun LyricSelectBar( - timeMs: Long, - color: Color, - onPlay: () -> Unit, - onTapBack: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onTapBack, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatLyricTime(timeMs), - color = color, - fontSize = 13.sp, - modifier = Modifier.padding(start = 15.dp), - ) - Box( - Modifier - .weight(1f) - .padding(start = 15.dp) - .height(1.dp) - .background(color), - ) - IconButton(onClick = onPlay) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "跳转播放", - tint = color, - ) - } - } -} - -private fun formatLyricTime(timeMs: Long): String { - val totalSec = (timeMs / 1000L).coerceAtLeast(0L).toInt() - val m = totalSec / 60 - val s = totalSec % 60 - return "${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" -} - -/** Flutter funcButton row under stackBody. */ -@Composable -private fun PlayerFuncButtons( - content: PlayerContent, - isLove: Boolean, - lrcType: Int, - dark: Boolean, - colorful: Boolean, - controlBg: Color, - iconTint: Color, - onToggleLove: () -> Unit, - onAddSong: () -> Unit, - onToggleTranslate: () -> Unit, -) { - val btnBg = if (colorful) controlBg else Color.Transparent - val btnIcon = if (colorful) ColorMs.White else iconTint - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - when (content) { - PlayerContent.Cover -> { - if (isLove) { - NeumorphicIconButton( - imageVector = Icons.Filled.Favorite, - onClick = onToggleLove, - size = 32.dp, - iconSize = 18.dp, - contentPadding = 7.dp, - iconColor = ColorMs.PinkF940A7, - background = btnBg, - contentDescription = "取消喜欢", - ) - } else { - NeumorphicIconButton( - icon = LlmpIcons.Love, - onClick = onToggleLove, - size = 32.dp, - iconSize = 18.dp, - contentPadding = 7.dp, - iconColor = ColorMs.PinkF940A7, - background = btnBg, - ) - } - NeumorphicIconButton( - imageVector = Icons.Filled.Add, - onClick = onAddSong, - size = 32.dp, - iconSize = 20.dp, - contentPadding = 5.dp, - iconColor = btnIcon, - background = btnBg, - contentDescription = "加入歌单", - ) - } - PlayerContent.Lyric -> { - // Tachie button only with colorful skin in Flutter — skip for now - Spacer(Modifier.size(32.dp)) - if (PlatformInfo.allowEulaLyric) { - // SVG text glyphs render tiny via Coil — use Compose Text instead. - NeumorphicSurface( - onClick = onToggleTranslate, - modifier = Modifier.size(32.dp), - radius = 6.dp, - background = btnBg, - contentPadding = 0.dp, - ) { - Text( - text = when (lrcType % 3) { - 0 -> "原" - 1 -> "译" - else -> "音" - }, - color = btnIcon, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center), - ) - } - } else { - Spacer(Modifier.size(32.dp)) - } - } - } - } -} - -@Composable -private fun PlayerCoverArt(music: Music?) { - val dark = LocalDarkTheme.current - val coverUri = rememberMusicCoverUri(music) - val hasCover = !coverUri.isNullOrBlank() - Box( - Modifier - .size(273.dp) - .clip(RoundedCornerShape(24.dp)) - .background(if (dark) ColorMs.Dark2B333A else ColorMs.E7F2FF), - contentAlignment = Alignment.Center, - ) { - PlayerMusicCover( - music = music, - radius = 24.dp, - fallbackPadding = 36.dp, - modifier = Modifier.fillMaxSize(), - ) - if (dark && hasCover) { - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.2f)), - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PlayerSeekBar( - positionMs: Long, - durationMs: Long, - thumbColor: Color, - activeTrack: Color, - inactiveTrack: Color, - timeColor: Color, - onSeek: (Long) -> Unit, -) { - val duration = durationMs.coerceAtLeast(0L) - val max = duration.toFloat().coerceAtLeast(1f) - var dragging by remember { mutableStateOf(false) } - var dragValue by remember { mutableFloatStateOf(0f) } - val display = if (dragging) dragValue else positionMs.toFloat().coerceIn(0f, max) - val sliderColors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrack, - inactiveTrackColor = inactiveTrack, - ) - val thumbInteraction = remember { MutableInteractionSource() } - - Column(Modifier.fillMaxWidth()) { - Slider( - value = display, - onValueChange = { - dragging = true - dragValue = it - }, - onValueChangeFinished = { - onSeek(display.roundToLong().coerceIn(0L, duration)) - dragging = false - }, - valueRange = 0f..max, - modifier = Modifier.fillMaxWidth().height(36.dp), - colors = sliderColors, - thumb = { - SliderDefaults.Thumb( - interactionSource = thumbInteraction, - colors = sliderColors, - modifier = Modifier.size(16.dp), - ) - }, - track = { sliderState -> - SliderDefaults.Track( - sliderState = sliderState, - modifier = Modifier.height(4.dp), - colors = sliderColors, - thumbTrackGapSize = 0.dp, - trackInsideCornerSize = 2.dp, - drawStopIndicator = null, - ) - }, - ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(formatMs(display.roundToLong()), color = timeColor, fontSize = 12.sp) - Text(formatMs(duration), color = timeColor, fontSize = 12.sp) - } - } -} - -private fun formatMs(ms: Long): String { - val totalSec = (ms.coerceAtLeast(0) / 1000).toInt() - val m = totalSec / 60 - val s = totalSec % 60 - return "${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" -} +package com.zhushenwudi.lovelivemusicplayer.ui.player + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +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 kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric +import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode +import com.zhushenwudi.lovelivemusicplayer.player.PlayerState +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.components.AddToMenuDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogMoreWithMusic +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogPlaylist +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogSongInfo +import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton +import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicSurface +import com.zhushenwudi.lovelivemusicplayer.ui.components.PlayerMusicCover +import com.zhushenwudi.lovelivemusicplayer.ui.components.rememberMusicCoverUri +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import kotlin.math.abs +import kotlin.math.roundToLong + +/** 播放器内容类型:封面 ↔ 歌词(立绘暂未实现)。 */ +private enum class PlayerContent { + Cover, + Lyric, +} + +@Composable +private fun rememberPlayerField(select: (PlayerState) -> T): T { + val initial = remember { select(AppContainer.player.state.value) } + return remember { AppContainer.player.state.map(select).distinctUntilChanged() } + .collectAsState(initial) + .value +} + +@Composable +fun FullPlayerScreen( + onBack: () -> Unit, + onOpenAlbum: (albumId: String) -> Unit = {}, +) { + val current = rememberPlayerField { it.current } + val isPlaying = rememberPlayerField { it.isPlaying } + val loopMode = rememberPlayerField { it.loopMode } + val sharedLyric by AppContainer.playingLyric.collectAsState() + val scope = rememberCoroutineScope() + var lyric by remember { mutableStateOf(LoadedLyric()) } + var lrcType by remember { mutableIntStateOf(AppContainer.lyricService.lrcType) } + var content by remember { mutableStateOf(PlayerContent.Cover) } + var isLove by remember(current?.musicId) { + mutableStateOf(current?.isLove == true) + } + var showAddToMenu by remember { mutableStateOf(false) } + var showMore by remember { mutableStateOf(false) } + var showSongInfo by remember { mutableStateOf(false) } + var showPlaylist by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val dark = LocalDarkTheme.current + val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary + val panelOpen = AppContainer.weSlide.isOpened + val iconTint = if (dark) ColorMs.White else ColorMs.Text333 + val controlBg = if (dark) { + Color.Black.copy(alpha = 0.15f) + } else { + Color.White.copy(alpha = 0.5f) + } + val thumbColor = if (dark) ColorMs.NeuShadowDark else ColorMs.LightPrimary + val activeTrack = if (dark) { + ColorMs.NeuShadowDark.copy(alpha = 153f / 255f) + } else { + ColorMs.SeekTrackLight.copy(alpha = 153f / 255f) + } + val inactiveTrack = if (dark) { + ColorMs.Dark272727 + } else { + ColorMs.SeekTrackLight.copy(alpha = 153f / 255f) + } + val lyricPlayingColor = if (dark) ColorMs.White else Color.Black + val lyricOtherColor = if (dark) { + ColorMs.GrayDFD.copy(alpha = 0.4f) + } else { + ColorMs.Gray999 + } + val timeColor = if (dark) ColorMs.GrayDFD else ColorMs.Gray999 + // 重新打开面板时强制回到封面模式 + LaunchedEffect(panelOpen) { + if (panelOpen) content = PlayerContent.Cover + } + // 歌词列表:禁用 WeSlide 嵌套/面板拖拽,避免滚动被抢占。 + // 歌词区与进度条之间的顶栏仍通过 onChromeVerticalDrag 驱动关闭。 + LaunchedEffect(content) { + AppContainer.weSlide.dragEnabled = content == PlayerContent.Cover + } + DisposableEffect(Unit) { + onDispose { AppContainer.weSlide.dragEnabled = true } + } + + // 切歌时由 AppContainer.bindLyricLoader 加载歌词 + LaunchedEffect(sharedLyric) { + lyric = sharedLyric + } + // 歌词类型变化时重新解析缓存(仅数据库,不请求网络) + LaunchedEffect(lrcType) { + AppContainer.lyricService.lrcType = lrcType + lyric = AppContainer.applyLyricDisplayType() + } + LaunchedEffect(current?.musicId) { + val id = current?.musicId + isLove = id?.let { mid -> + AppContainer.library.getAllLove().any { it.musicId == mid } + } == true + } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(bg)) + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(15.dp)) + + PlayerHeader( + title = current?.musicName ?: tr("not_playing"), + artist = current?.artist.orEmpty(), + dark = dark, + iconTint = iconTint, + onBack = onBack, + onMore = { + if (current != null) showMore = true + }, + ) + + Spacer(Modifier.height(10.dp)) + + // 主体区域高度 400.h — 封面或歌词滚动 + Box( + Modifier + .fillMaxWidth() + .height(400.dp), + ) { + when (content) { + PlayerContent.Cover -> { + PlayerCoverBody( + music = current, + lyric = lyric, + lrcType = lrcType, + playingColor = lyricPlayingColor, + otherColor = lyricOtherColor, + onTap = { content = PlayerContent.Lyric }, + ) + } + + PlayerContent.Lyric -> { + PlayerLyricScroll( + lyric = lyric, + listState = listState, + playingColor = lyricPlayingColor, + otherColor = lyricOtherColor, + onTapBack = { content = PlayerContent.Cover }, + onSeek = { positionMs -> + AppContainer.player.seekTo(positionMs) + AppContainer.player.play() + }, + ) + } + } + } + + // 歌词区与进度条之间:允许下拉关闭,歌词区仅滚动。 + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .then( + if (content == PlayerContent.Lyric) { + Modifier.pointerInput(Unit) { + var lastDrag = 0f + detectVerticalDragGestures( + onDragEnd = { + AppContainer.weSlide.onChromeVerticalDragEnd?.invoke( + lastDrag + ) + lastDrag = 0f + }, + onDragCancel = { + AppContainer.weSlide.onChromeVerticalDragCancel?.invoke() + lastDrag = 0f + }, + onVerticalDrag = { change, dragAmount -> + change.consume() + lastDrag = dragAmount + AppContainer.weSlide.onChromeVerticalDrag?.invoke(dragAmount) + }, + ) + } + } else { + Modifier + }, + ), + ) { + Spacer(Modifier.height(10.dp)) + // 功能按钮行 — 封面/歌词模式不同 + PlayerFuncButtons( + content = content, + isLove = isLove, + lrcType = lrcType, + dark = dark, + iconTint = iconTint, + onToggleLove = { + current?.musicId?.let { id -> + isLove = AppContainer.toggleLove(id) + } + }, + onAddSong = { + if (current?.musicId != null) { + showAddToMenu = true + } + }, + onToggleTranslate = { + // 切换歌词翻译类型并重新解析缓存 + AppContainer.lyricService.toggleType() + lrcType = AppContainer.lyricService.lrcType + lyric = AppContainer.applyLyricDisplayType() + }, + ) + Spacer(Modifier.weight(1f)) + } + + Column( + Modifier + .fillMaxWidth() + .height(170.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PlayerSeekBar( + thumbColor = thumbColor, + activeTrack = activeTrack, + inactiveTrack = inactiveTrack, + timeColor = timeColor, + onSeek = { AppContainer.player.seekTo(it) }, + ) + Spacer(Modifier.height(20.dp)) + Row( + Modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + NeumorphicIconButton( + icon = when (loopMode) { + LoopMode.LIST -> LlmpIcons.Loop + LoopMode.SINGLE -> LlmpIcons.LoopOne + LoopMode.SHUFFLE -> LlmpIcons.Shuffle + }, + onClick = { + val next = when (loopMode) { + LoopMode.LIST -> LoopMode.SINGLE + LoopMode.SINGLE -> LoopMode.SHUFFLE + LoopMode.SHUFFLE -> LoopMode.LIST + } + AppContainer.player.setLoopMode(next) + AppContainer.settings.loopMode = next + }, + size = 32.dp, + iconSize = 15.dp, + contentPadding = 9.dp, + iconColor = iconTint, + background = controlBg, + ) + NeumorphicIconButton( + icon = LlmpIcons.Prev, + onClick = { AppContainer.player.previous() }, + size = 60.dp, + iconSize = 15.dp, + radius = 30.dp, + contentPadding = 20.dp, + iconColor = iconTint, + background = controlBg, + ) + NeumorphicIconButton( + icon = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play, + onClick = { AppContainer.player.toggle() }, + size = 80.dp, + iconSize = 26.dp, + radius = 40.dp, + contentPadding = 25.dp, + iconColor = iconTint, + background = controlBg, + ) + NeumorphicIconButton( + icon = LlmpIcons.Next, + onClick = { AppContainer.player.next() }, + size = 60.dp, + iconSize = 15.dp, + radius = 40.dp, + contentPadding = 20.dp, + iconColor = iconTint, + background = controlBg, + ) + NeumorphicIconButton( + icon = LlmpIcons.Playlist, + onClick = { showPlaylist = true }, + size = 32.dp, + iconSize = 15.dp, + contentPadding = 9.dp, + iconColor = iconTint, + background = controlBg, + ) + } + } + Spacer(Modifier.height(28.dp)) + } + } + + if (showAddToMenu) { + AddToMenuDialog( + musicList = listOfNotNull(current), + onDismiss = { showAddToMenu = false }, + onLoveChanged = { added -> isLove = added }, + ) + } + + val currentMusic = current + if (showMore && currentMusic != null) { + DialogMoreWithMusic( + music = currentMusic, + isPlayer = true, + onDismiss = { showMore = false }, + onClosePanel = onBack, + onOpenAlbum = onOpenAlbum, + onOpenAddToMenu = { showAddToMenu = true }, + onShowSongInfo = { showSongInfo = true }, + ) + } + if (showSongInfo && currentMusic != null) { + DialogSongInfo( + music = currentMusic, + onDismiss = { showSongInfo = false }, + ) + } + if (showPlaylist) { + DialogPlaylist(onDismiss = { showPlaylist = false }) + } +} + +@Composable +private fun PlayerHeader( + title: String, + artist: String, + dark: Boolean, + iconTint: Color, + onBack: () -> Unit, + onMore: () -> Unit, +) { + val headerBtnBg = Color.Transparent + val headerIconTint = iconTint + Row( + Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NeumorphicIconButton( + imageVector = Icons.Filled.KeyboardArrowDown, + onClick = onBack, + size = 32.dp, + iconSize = 20.dp, + radius = 6.dp, + contentPadding = 5.dp, + iconColor = headerIconTint, + background = headerBtnBg, + contentDescription = tr("collapse"), + ) + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = TextStyle( + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ), + ) + if (artist.isNotBlank()) { + Text( + text = artist, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = TextStyle( + color = if (dark) ColorMs.GrayDFD else ColorMs.Gray999, + fontSize = 12.sp, + ), + ) + } + } + } + NeumorphicIconButton( + imageVector = Icons.Filled.MoreHoriz, + onClick = onMore, + size = 32.dp, + iconSize = 18.dp, + radius = 6.dp, + contentPadding = 5.dp, + iconColor = headerIconTint, + background = headerBtnBg, + contentDescription = tr("more"), + ) + } +} + +/** 封面页:273 封面 + 两行歌词预览,点击切换到歌词页。 */ +@Composable +private fun PlayerCoverBody( + music: Music?, + lyric: LoadedLyric, + lrcType: Int, + playingColor: Color, + otherColor: Color, + onTap: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onTap, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (PlatformInfo.allowEulaLyric) { + Spacer(Modifier.height(18.dp)) + } + PlayerCoverArt(music = music) + if (PlatformInfo.allowEulaLyric) { + CoverPreviewLines( + lyric = lyric, + lrcType = lrcType, + playingColor = playingColor, + otherColor = otherColor, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } +} + +@Composable +private fun CoverPreviewLines( + lyric: LoadedLyric, + lrcType: Int, + playingColor: Color, + otherColor: Color, + modifier: Modifier = Modifier, +) { + val positionMs = rememberPlayerField { it.positionMs } + val currentIdx = LrcParser.currentIndex(lyric.main, positionMs) + val line1 = lyric.main.getOrNull(currentIdx)?.text + ?: lyric.main.getOrNull(0)?.text.orEmpty() + val line2 = when { + lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text + ?: lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty() + else -> lyric.main.getOrNull(currentIdx + 1)?.text.orEmpty() + } + Column( + modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = line1, + color = playingColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + Text( + text = line2, + color = otherColor, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** + * 歌词滚动视图 + 选择栏: + * - 自动跟随当前播放行 + * - 拖拽选择中心行 → 绿色时间/行/播放 → 跳转 + * - 点击任意处返回封面(选择栏播放按钮仍可跳转) + */ +@Composable +private fun PlayerLyricScroll( + lyric: LoadedLyric, + listState: LazyListState, + playingColor: Color, + otherColor: Color, + onTapBack: () -> Unit, + onSeek: (positionMs: Long) -> Unit, +) { + if (lyric.main.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .pointerInput(onTapBack) { + detectTapGestures(onTap = { onTapBack() }) + }, + contentAlignment = Alignment.Center, + ) { + Text(tr("no_lyrics"), color = otherColor, fontSize = 17.sp) + } + return + } + + val positionMs = rememberPlayerField { it.positionMs } + val currentIdx = LrcParser.currentIndex(lyric.main, positionMs) + val isDragged by listState.interactionSource.collectIsDraggedAsState() + var selecting by remember { mutableStateOf(false) } + var selectedIndex by remember { mutableIntStateOf(-1) } + val selectColor = Color(0xFF4CAF50) + // LazyColumn:[顶部留白] + 歌词 + [底部留白] + val lyricListOffset = 1 + + val centerLyricIndex by remember { + derivedStateOf { + val info = listState.layoutInfo + if (info.visibleItemsInfo.isEmpty()) return@derivedStateOf -1 + val center = (info.viewportStartOffset + info.viewportEndOffset) / 2f + val listIndex = info.visibleItemsInfo.minByOrNull { item -> + abs((item.offset + item.size / 2f) - center) + }?.index ?: return@derivedStateOf -1 + val lyricIdx = listIndex - lyricListOffset + if (lyricIdx in lyric.main.indices) lyricIdx else -1 + } + } + + LaunchedEffect(isDragged) { + if (isDragged) selecting = true + } + + LaunchedEffect(selecting, isDragged, listState.isScrollInProgress) { + if (!selecting) return@LaunchedEffect + snapshotFlow { centerLyricIndex } + .distinctUntilChanged() + .collect { idx -> + if (idx >= 0) selectedIndex = idx + } + } + + // 选择模式空闲 3 秒后恢复自动跟随 + LaunchedEffect(isDragged, listState.isScrollInProgress, selecting) { + if (!selecting) return@LaunchedEffect + if (isDragged || listState.isScrollInProgress) return@LaunchedEffect + delay(3_000) + if (!isDragged && !listState.isScrollInProgress) { + selecting = false + selectedIndex = -1 + } + } + + // 锚点位置 0.5,保持当前播放行垂直居中。 + LaunchedEffect(currentIdx, selecting, lyric.main.size) { + if (selecting) return@LaunchedEffect + if (currentIdx < 0 || lyric.main.isEmpty()) return@LaunchedEffect + val index = (currentIdx + lyricListOffset) + .coerceIn(lyricListOffset, lyric.main.size) + runCatching { listState.animateScrollToItemCentered(index) } + } + + // 曲目或歌词集变化时重置选择状态 + LaunchedEffect(lyric.raw?.uid, lyric.main.size) { + selecting = false + selectedIndex = -1 + } + + BoxWithConstraints(Modifier.fillMaxSize()) { + // 半视口留白,使首尾行可居中到锚点。 + val edgePad = maxHeight / 2 + Box(Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 用可点击 Spacer 替代垂直 contentPadding,空白区也可返回封面。 + item(key = "pad-top") { + Spacer( + Modifier + .fillMaxWidth() + .height(edgePad) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onTapBack, + ), + ) + } + itemsIndexed( + lyric.main, + key = { index, line -> "${line.timeMs}-$index" }) { index, line -> + val translation = lyric.translation.getOrNull(index)?.text + val isSelected = selecting && index == selectedIndex + val active = !selecting && index == currentIdx + val lineColor = when { + isSelected -> selectColor + active -> playingColor + else -> otherColor + } + Column( + Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onTapBack, + ) + .padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = line.text, + fontSize = 17.sp, + fontWeight = if (active || isSelected) FontWeight.Bold else FontWeight.Normal, + color = lineColor, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (!translation.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = translation, + fontSize = 15.sp, + color = lineColor, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + item(key = "pad-bottom") { + Spacer( + Modifier + .fillMaxWidth() + .height(edgePad) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onTapBack, + ), + ) + } + } + + if (selecting && selectedIndex in lyric.main.indices) { + LyricSelectBar( + timeMs = lyric.main[selectedIndex].timeMs, + color = selectColor, + modifier = Modifier.align(Alignment.Center), + onPlay = { + val time = lyric.main[selectedIndex].timeMs + selecting = false + selectedIndex = -1 + onSeek(time) + }, + onTapBack = onTapBack, + ) + } + } + } +} + +/** 滚动使 `index` 垂直居中(锚点 0.5)。 */ +private suspend fun LazyListState.animateScrollToItemCentered(index: Int) { + val layout = layoutInfo + val viewport = layout.viewportEndOffset - layout.viewportStartOffset + if (viewport <= 0) { + animateScrollToItem(index) + return + } + val visible = layout.visibleItemsInfo.firstOrNull { it.index == index } + if (visible != null) { + val center = layout.viewportStartOffset + viewport / 2 + val itemCenter = visible.offset + visible.size / 2 + val delta = itemCenter - center + if (abs(delta) > 1) animateScrollBy(delta.toFloat()) + return + } + val estimated = layout.visibleItemsInfo + .asSequence() + .filter { it.index > 0 } + .map { it.size } + .average() + .takeIf { !it.isNaN() } + ?.toInt() + ?: (viewport / 10).coerceAtLeast(1) + animateScrollToItem(index, scrollOffset = -(viewport / 2 - estimated / 2)) + val after = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return + val center = layoutInfo.viewportStartOffset + + (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2 + val itemCenter = after.offset + after.size / 2 + val delta = itemCenter - center + if (abs(delta) > 1) animateScrollBy(delta.toFloat()) +} + +/** 歌词选择栏:绿色 mm:ss + 分隔线 + 播放按钮。 */ +@Composable +private fun LyricSelectBar( + timeMs: Long, + color: Color, + onPlay: () -> Unit, + onTapBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onTapBack, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatLyricTime(timeMs), + color = color, + fontSize = 13.sp, + modifier = Modifier.padding(start = 15.dp), + ) + Box( + Modifier + .weight(1f) + .padding(start = 15.dp) + .height(1.dp) + .background(color), + ) + IconButton(onClick = onPlay) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = tr("jump_play"), + tint = color, + ) + } + } +} + +private fun formatLyricTime(timeMs: Long): String { + val totalSec = (timeMs / 1000L).coerceAtLeast(0L).toInt() + val m = totalSec / 60 + val s = totalSec % 60 + return "${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" +} + +/** 主体区域下方的功能按钮行。 */ +@Composable +private fun PlayerFuncButtons( + content: PlayerContent, + isLove: Boolean, + lrcType: Int, + dark: Boolean, + iconTint: Color, + onToggleLove: () -> Unit, + onAddSong: () -> Unit, + onToggleTranslate: () -> Unit, +) { + val btnBg = Color.Transparent + val btnIcon = iconTint + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + when (content) { + PlayerContent.Cover -> { + if (isLove) { + NeumorphicIconButton( + imageVector = Icons.Filled.Favorite, + onClick = onToggleLove, + size = 32.dp, + iconSize = 18.dp, + contentPadding = 7.dp, + iconColor = ColorMs.PinkF940A7, + background = btnBg, + contentDescription = tr("cancel_i_like"), + ) + } else { + NeumorphicIconButton( + icon = LlmpIcons.Love, + onClick = onToggleLove, + size = 32.dp, + iconSize = 18.dp, + contentPadding = 7.dp, + iconColor = ColorMs.PinkF940A7, + background = btnBg, + ) + } + NeumorphicIconButton( + imageVector = Icons.Filled.Add, + onClick = onAddSong, + size = 32.dp, + iconSize = 20.dp, + contentPadding = 5.dp, + iconColor = btnIcon, + background = btnBg, + contentDescription = tr("add_to_menu"), + ) + } + + PlayerContent.Lyric -> { + Spacer(Modifier.size(32.dp)) + if (PlatformInfo.allowEulaLyric) { + // SVG 文字经 Coil 渲染过小,改用 Compose Text。 + NeumorphicSurface( + onClick = onToggleTranslate, + modifier = Modifier.size(32.dp), + radius = 6.dp, + background = btnBg, + contentPadding = 0.dp, + ) { + Text( + text = when (lrcType % 3) { + 0 -> tr("lyric_orig") + 1 -> tr("lyric_trans") + else -> tr("lyric_roma") + }, + color = btnIcon, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center), + ) + } + } else { + Spacer(Modifier.size(32.dp)) + } + } + } + } +} + +@Composable +private fun PlayerCoverArt(music: Music?) { + val dark = LocalDarkTheme.current + val coverUri = rememberMusicCoverUri(music) + val hasCover = !coverUri.isNullOrBlank() + Box( + Modifier + .size(273.dp) + .clip(RoundedCornerShape(24.dp)) + .background(if (dark) ColorMs.Dark2B333A else ColorMs.E7F2FF), + contentAlignment = Alignment.Center, + ) { + PlayerMusicCover( + music = music, + radius = 24.dp, + fallbackPadding = 36.dp, + modifier = Modifier.fillMaxSize(), + ) + if (dark && hasCover) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.2f)), + ) + } + } +} + +@Composable +private fun PlayerSeekBar( + thumbColor: Color, + activeTrack: Color, + inactiveTrack: Color, + timeColor: Color, + onSeek: (Long) -> Unit, +) { + val positionMs = rememberPlayerField { it.positionMs } + val durationMs = rememberPlayerField { it.durationMs } + val duration = durationMs.coerceAtLeast(0L) + val max = duration.toFloat().coerceAtLeast(1f) + var dragging by remember { mutableStateOf(false) } + var dragValue by remember { mutableFloatStateOf(0f) } + val display = if (dragging) dragValue else positionMs.toFloat().coerceIn(0f, max) + val fraction = (display / max).coerceIn(0f, 1f) + + Column(Modifier.fillMaxWidth()) { + BoxWithConstraints( + Modifier + .fillMaxWidth() + .height(48.dp) + .pointerInput(max) { + val inset = 24.dp.toPx() + fun xToValue(x: Float): Float { + val usable = (size.width - inset * 2).coerceAtLeast(1f) + return ((x - inset) / usable).coerceIn(0f, 1f) * max + } + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + dragging = true + dragValue = xToValue(down.position.x) + drag(down.id) { change -> + change.consume() + dragValue = xToValue(change.position.x) + } + onSeek(dragValue.roundToLong().coerceIn(0L, duration)) + dragging = false + } + }, + ) { + val thumbRadius = 10.dp + val overlayPad = 24.dp + val trackHeight = 4.dp + Canvas(Modifier.fillMaxSize()) { + val inset = overlayPad.toPx() + val y = size.height / 2f + val trackW = (size.width - inset * 2f).coerceAtLeast(0f) + val top = y - trackHeight.toPx() / 2f + val radius = CornerRadius(trackHeight.toPx() / 2f) + drawRoundRect( + color = inactiveTrack, + topLeft = Offset(inset, top), + size = Size(trackW, trackHeight.toPx()), + cornerRadius = radius, + ) + if (fraction > 0f && activeTrack != inactiveTrack) { + drawRoundRect( + color = activeTrack, + topLeft = Offset(inset, top), + size = Size(trackW * fraction, trackHeight.toPx()), + cornerRadius = radius, + ) + } + } + Box( + Modifier + .align(Alignment.CenterStart) + .offset(x = overlayPad - thumbRadius + (maxWidth - overlayPad * 2) * fraction) + .size(thumbRadius * 2) + .shadow(1.dp, CircleShape) + .background(thumbColor, CircleShape), + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(formatMs(display.roundToLong()), color = timeColor, fontSize = 12.sp) + Text(formatMs(duration), color = timeColor, fontSize = 12.sp) + } + } +} + +private fun formatMs(ms: Long): String { + val totalSec = (ms.coerceAtLeast(0) / 1000).toInt() + val m = totalSec / 60 + val s = totalSec % 60 + return "${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/MiniPlayerBar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/MiniPlayerBar.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt index b28117e..de830ed 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/MiniPlayerBar.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/player/MiniPlayerBar.kt @@ -1,235 +1,239 @@ -package top.zhushenwudi.llmp.ui.player - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.font.FontWeight -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 dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.components.DialogPlaylist -import top.zhushenwudi.llmp.ui.components.PlayerMusicCover -import top.zhushenwudi.llmp.ui.components.rememberMusicCoverLocalPath -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.SvgAsset -import top.zhushenwudi.llmp.ui.icons.TintIcon - -/** Home list content behind the mini bar — provided by [MainShell]. */ -val LocalMiniPlayerHaze = compositionLocalOf { null } - -@Composable -fun MiniPlayerBar( - onOpenPlayer: () -> Unit, - modifier: Modifier = Modifier, - /** Flutter: miniPlayerTapCover only when selectMode == 0. */ - interactionEnabled: Boolean = true, -) { - val state by AppContainer.player.state.collectAsState() - val dark = LocalDarkTheme.current - val title = state.current?.musicName ?: "暂无歌曲" - val current = state.current - val coverLocal = rememberMusicCoverLocalPath(current) - val barColor = rememberMiniPlayerBarColor(coverLocal) - // Flutter: theme text over alpha-150 palette; pick by blended luminance for contrast. - val shellBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary - val (titleColor, iconColor) = remember(barColor, shellBg) { - miniPlayerContentColors(barColor, shellBg) - } - var dragAccum by remember { mutableFloatStateOf(0f) } - var showPlaylist by remember { mutableStateOf(false) } - val hazeState = LocalMiniPlayerHaze.current - - Box( - modifier = modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 6.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .clip(RoundedCornerShape(34.dp)) - .then( - // Flutter: BackdropFilter σ=30 + Colors.grey@15% over palette decoration - if (hazeState != null) { - Modifier.hazeEffect(state = hazeState) { - blurRadius = 30.dp - backgroundColor = barColor - tints = listOf( - HazeTint(barColor), - HazeTint(Color.Gray.copy(alpha = 0.15f)), - ) - noiseFactor = 0f - } - } else { - Modifier - }, - ), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .then( - if (interactionEnabled) { - Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onOpenPlayer, - ) - } else { - Modifier - }, - ), - ) { - PlayerMusicCover( - music = current, - circular = true, - fallbackPadding = 8.dp, - modifier = Modifier.fillMaxSize(), - ) - } - Spacer(Modifier.width(8.dp)) - // Flutter marqueeMusicName: Center(child: MarqueeText(...)) - Text( - text = title, - color = titleColor, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .weight(1f) - .then( - if (interactionEnabled) { - Modifier - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - // Flutter marquee swipe threshold ~130.w - when { - dragAccum > 130f -> AppContainer.player.previous() - dragAccum < -130f -> AppContainer.player.next() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { change, amount -> - change.consume() - dragAccum += amount - }, - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onOpenPlayer, - ) - } else { - Modifier - }, - ), - ) - Spacer(Modifier.width(8.dp)) - // Flutter: neumorphicButton 30×30, default iconSize 15, padding 5, hasShadow false - MiniBarIconButton( - asset = if (state.isPlaying) LlmpIcons.Pause else LlmpIcons.Play, - tint = iconColor, - contentDescription = if (state.isPlaying) "暂停" else "播放", - onClick = if (interactionEnabled) { - { AppContainer.player.toggle() } - } else { - null - }, - ) - Spacer(Modifier.width(1.dp)) - MiniBarIconButton( - asset = LlmpIcons.Playlist, - tint = iconColor, - contentDescription = "播放列表", - onClick = if (interactionEnabled) { - { showPlaylist = true } - } else { - null - }, - ) - Spacer(Modifier.width(8.dp)) - } - } - } - if (showPlaylist && interactionEnabled) { - DialogPlaylist(onDismiss = { showPlaylist = false }) - } -} - -@Composable -private fun MiniBarIconButton( - asset: SvgAsset, - tint: Color, - contentDescription: String?, - onClick: (() -> Unit)?, -) { - Box( - modifier = Modifier - .size(30.dp) - .then( - if (onClick != null) { - Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - ) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, - ) { - TintIcon( - asset = asset, - tint = tint, - // Flutter default iconSize 15 偏小,略放大到 20,仍落在 30 按钮内 - size = 20.dp, - contentDescription = contentDescription, - ) - } -} +package com.zhushenwudi.lovelivemusicplayer.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +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 dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogPlaylist +import com.zhushenwudi.lovelivemusicplayer.ui.components.PlayerMusicCover +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.icons.TintIcon +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** 迷你栏背后的主页列表内容,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */ +val LocalMiniPlayerHaze = compositionLocalOf { null } + +@Composable +fun MiniPlayerBar( + onOpenPlayer: () -> Unit, + modifier: Modifier = Modifier, + /** 仅选择模式为 0 时可点击封面打开全屏播放器。 */ + interactionEnabled: Boolean = true, +) { + val current by remember { + AppContainer.player.state.map { it.current }.distinctUntilChanged() + }.collectAsState(AppContainer.player.state.value.current) + val isPlaying by remember { + AppContainer.player.state.map { it.isPlaying }.distinctUntilChanged() + }.collectAsState(AppContainer.player.state.value.isPlaying) + val dark = LocalDarkTheme.current + val title = current?.musicName ?: tr("no_songs") + val barColor = if (dark) ColorMs.BottomBarDark.copy(alpha = 0.85f) else ColorMs.LightPrimary + val titleColor = if (dark) ColorMs.White else ColorMs.Text333 + val iconColor = if (dark) ColorMs.GrayCCC else ColorMs.Text333 + var dragAccum by remember { mutableFloatStateOf(0f) } + var showPlaylist by remember { mutableStateOf(false) } + // 展开过程关掉 Haze,避免和 body 缩放叠两层模糊。 + val hazeState = if (AppContainer.weSlide.isOpened) null else LocalMiniPlayerHaze.current + + Box( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 6.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(34.dp)) + .background(barColor) + .then( + if (hazeState != null) { + Modifier.hazeEffect(state = hazeState) { + blurRadius = 30.dp + backgroundColor = barColor + tints = listOf( + HazeTint(barColor), + HazeTint(Color.Gray.copy(alpha = 0.15f)), + ) + noiseFactor = 0f + } + } else { + Modifier + }, + ), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .then( + if (interactionEnabled) { + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onOpenPlayer, + ) + } else { + Modifier + }, + ), + ) { + PlayerMusicCover( + music = current, + circular = true, + fallbackPadding = 8.dp, + modifier = Modifier.fillMaxSize(), + ) + } + Spacer(Modifier.width(8.dp)) + // 居中跑马灯歌名 + Text( + text = title, + color = titleColor, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .weight(1f) + .then( + if (interactionEnabled) { + Modifier + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // 跑马灯滑动阈值约 130.w + when { + dragAccum > 130f -> AppContainer.player.previous() + dragAccum < -130f -> AppContainer.player.next() + } + dragAccum = 0f + }, + onDragCancel = { dragAccum = 0f }, + onHorizontalDrag = { change, amount -> + change.consume() + dragAccum += amount + }, + ) + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onOpenPlayer, + ) + } else { + Modifier + }, + ), + ) + Spacer(Modifier.width(8.dp)) + // 新拟态按钮 30×30,图标 15,内边距 5,无阴影 + MiniBarIconButton( + asset = if (isPlaying) LlmpIcons.Pause else LlmpIcons.Play, + tint = iconColor, + contentDescription = if (isPlaying) tr("pause") else tr("play"), + onClick = if (interactionEnabled) { + { AppContainer.player.toggle() } + } else { + null + }, + ) + Spacer(Modifier.width(1.dp)) + MiniBarIconButton( + asset = LlmpIcons.Playlist, + tint = iconColor, + contentDescription = tr("playlist"), + onClick = if (interactionEnabled) { + { showPlaylist = true } + } else { + null + }, + ) + Spacer(Modifier.width(8.dp)) + } + } + } + if (showPlaylist && interactionEnabled) { + DialogPlaylist(onDismiss = { showPlaylist = false }) + } +} + +@Composable +private fun MiniBarIconButton( + asset: SvgAsset, + tint: Color, + contentDescription: String?, + onClick: (() -> Unit)?, +) { + Box( + modifier = Modifier + .size(30.dp) + .then( + if (onClick != null) { + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + TintIcon( + asset = asset, + tint = tint, + // 默认图标 15 偏小,略放大到 20,仍在 30 按钮内 + size = 20.dp, + contentDescription = contentDescription, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/DrawerFeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/DrawerFeatureScreens.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt index f4af99f..2c71dd1 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/DrawerFeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/DrawerFeatureScreens.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.screens +package com.zhushenwudi.lovelivemusicplayer.ui.screens import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -28,6 +29,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,48 +41,58 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.lyric.LoadedLyric -import top.zhushenwudi.llmp.lyric.LrcParser -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.player.LoopMode -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.components.AddMinDialog -import top.zhushenwudi.llmp.ui.components.AppHeader -import top.zhushenwudi.llmp.ui.components.CoverPlaceholder -import top.zhushenwudi.llmp.ui.components.DrawerFunctionRow -import top.zhushenwudi.llmp.ui.components.DriveModeCarousel -import top.zhushenwudi.llmp.ui.components.LoadingOverlay -import top.zhushenwudi.llmp.ui.components.NeumorphicIconButton -import top.zhushenwudi.llmp.ui.components.PairTipsBlock -import top.zhushenwudi.llmp.ui.components.PinkFuncButton -import top.zhushenwudi.llmp.ui.components.ResetDataDialog -import top.zhushenwudi.llmp.ui.components.TextFieldDialog -import top.zhushenwudi.llmp.ui.components.TwoButtonDialog -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.SvgAsset -import top.zhushenwudi.llmp.ui.icons.SvgIcon -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.lyric.LoadedLyric +import com.zhushenwudi.lovelivemusicplayer.lyric.LrcParser +import com.zhushenwudi.lovelivemusicplayer.platform.DetailBackHandler +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode +import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.components.AddMinDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.AppHeader +import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder +import com.zhushenwudi.lovelivemusicplayer.ui.components.DrawerFunctionRow +import com.zhushenwudi.lovelivemusicplayer.ui.components.DriveModeCarousel +import com.zhushenwudi.lovelivemusicplayer.ui.components.LoadingOverlay +import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton +import com.zhushenwudi.lovelivemusicplayer.ui.components.PairTipsBlock +import com.zhushenwudi.lovelivemusicplayer.ui.components.PinkFuncButton +import com.zhushenwudi.lovelivemusicplayer.ui.components.ResetDataDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.TextFieldDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoButtonDialog +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgAsset +import com.zhushenwudi.lovelivemusicplayer.ui.icons.SvgIcon +import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalDesignScale +import com.zhushenwudi.lovelivemusicplayer.ui.theme.hSp private val dailyJson = Json { ignoreUnknownKeys = true; isLenient = true } @@ -124,31 +136,27 @@ fun SettingsScreen( onOpenSd: () -> Unit = {}, onOpenLogs: () -> Unit = {}, onOpenPermissions: () -> Unit = {}, - onBgPhotoChange: (String) -> Unit = {}, ) { val settings = AppContainer.settings val scope = rememberCoroutineScope() - var colorful by remember { mutableStateOf(settings.isColorful) } var enableHttp by remember { mutableStateOf(settings.enableHttp) } var httpUrl by remember { mutableStateOf(settings.httpUrl) } var desktopLyric by remember { mutableStateOf(settings.enableDesktopLyric) } var aiPic by remember { mutableStateOf(settings.aiPicture) } - var enableBg by remember { mutableStateOf(settings.enableBackground) } var showHttpDialog by remember { mutableStateOf(false) } var showTimerDialog by remember { mutableStateOf(false) } var showClearDialog by remember { mutableStateOf(false) } var loadingMsg by remember { mutableStateOf(null) } val sleepLeft by AppContainer.sleepTimer.remainingMs.collectAsState() - val usbRoots = remember { AppContainer.fileSystem.listUsbRoots() } + val usbRevision by AppContainer.usbRevision.collectAsState() + val usbRoots = remember(usbRevision) { AppContainer.fileSystem.listUsbRoots() } val dark = LocalDarkTheme.current - val bgPhoto = LocalBgPhoto.current - val iconTint = if (bgPhoto.isNotBlank()) ColorMs.GrayCCC else null val scaffoldBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val timerMinutes = ((sleepLeft + 59999) / 60000).toInt() Scaffold( containerColor = scaffoldBg, - topBar = { AppHeader(title = "系统设置", onBack = onBack) }, + topBar = { AppHeader(title = tr("system_settings"), onBack = onBack) }, ) { padding -> Column( Modifier @@ -159,8 +167,7 @@ fun SettingsScreen( Column(Modifier.padding(horizontal = 16.dp)) { DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_system_theme.svg"), - iconTint = iconTint, - text = "跟随系统", + text = tr("theme_with_system"), hasSwitch = true, checked = followSystem, onCheckedChange = onFollowSystemChange, @@ -169,30 +176,16 @@ fun SettingsScreen( if (!followSystem) { DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_day_night.svg"), - iconTint = iconTint, - text = "深色模式", + text = tr("dark_mode"), hasSwitch = true, checked = darkTheme, onCheckedChange = onDarkThemeChange, ) Spacer(Modifier.height(8.dp)) } - DrawerFunctionRow( - icon = SvgAsset("drawable/drawer_drawer_colorful.svg"), - iconTint = iconTint, - text = "炫彩模式", - hasSwitch = true, - checked = colorful, - onCheckedChange = { - colorful = it - settings.isColorful = it - }, - ) - Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_ai_pic.svg"), - iconTint = iconTint, - text = "开屏图片", + text = tr("splash_photo"), hasSwitch = true, checked = aiPic, onCheckedChange = { @@ -203,63 +196,18 @@ fun SettingsScreen( Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_desktop_lyric.svg"), - iconTint = iconTint, - text = "桌面歌词", + text = tr("desktop_lyric"), hasSwitch = true, checked = desktopLyric, onCheckedChange = { - desktopLyric = it - settings.enableDesktopLyric = it - top.zhushenwudi.llmp.platform.PlatformServices.desktopLyric.enabled = it - if (it) { - PlatformUi.requestOverlayPermission() - } else { - top.zhushenwudi.llmp.platform.PlatformServices.desktopLyric.hide() - } - }, - ) - Spacer(Modifier.height(8.dp)) - DrawerFunctionRow( - icon = SvgAsset("drawable/drawer_drawer_background.svg"), - iconTint = iconTint, - text = "启用背景图片", - hasSwitch = true, - checked = enableBg, - onCheckedChange = { - enableBg = it - settings.enableBackground = it - if (it) { - onBgPhotoChange(settings.backgroundPhoto) - } else { - onBgPhotoChange("") - } - }, - ) - Spacer(Modifier.height(8.dp)) - DrawerFunctionRow( - iconTint = iconTint, - text = "选择背景图片", - onClick = { - if (!enableBg) { - PlatformUi.showToast("请先启用背景图片") - } else { - PlatformUi.pickImage { path -> - if (path.isNullOrBlank()) { - PlatformUi.showToast("未选择图片") - } else { - settings.backgroundPhoto = path - onBgPhotoChange(path) - PlatformUi.showToast("背景图片已更新") - } - } - } + val ok = AppContainer.setDesktopLyricEnabled(it) + desktopLyric = if (ok) it else false }, ) Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_http.svg"), - iconTint = iconTint, - text = "使用HTTP曲库", + text = tr("use_http_music"), hasSwitch = true, checked = enableHttp, switchEnabled = httpUrl.isNotBlank(), @@ -270,8 +218,7 @@ fun SettingsScreen( ) Spacer(Modifier.height(8.dp)) DrawerFunctionRow( - iconTint = iconTint, - text = httpUrl.ifBlank { "输入HTTP地址" }, + text = httpUrl.ifBlank { tr("input_http_url") }, onClick = { showHttpDialog = true }, ) } @@ -284,41 +231,36 @@ fun SettingsScreen( if (usbRoots.size > 1) { DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_sd.svg"), - iconTint = iconTint, - text = "存储设置", + text = tr("storage_settings"), onClick = onOpenSd, ) Spacer(Modifier.height(8.dp)) } DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_timer.svg"), - iconTint = iconTint, text = if (sleepLeft > 0) { - "定时关闭(剩余 ${sleepLeft / 60000} 分)" + tr("timed_remain_n", sleepLeft / 60000) } else { - "定时关闭" + tr("timed_to_stop") }, onClick = { showTimerDialog = true }, ) Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_reset.svg"), - iconTint = iconTint, - text = "清理数据", + text = tr("clear_database"), onClick = { showClearDialog = true }, ) Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_inspect.svg"), - iconTint = iconTint, - text = "查看日志", + text = tr("view_log"), onClick = onOpenLogs, ) Spacer(Modifier.height(8.dp)) DrawerFunctionRow( icon = SvgAsset("drawable/drawer_drawer_secret.svg"), - iconTint = iconTint, - text = "隐私协议", + text = tr("privacy_agreement"), onClick = onOpenPermissions, ) } @@ -328,10 +270,10 @@ fun SettingsScreen( if (showHttpDialog) { TextFieldDialog( - title = "输入HTTP地址", - hint = "支持 http 字符 a-zA-Z0-9.:/_-", + title = tr("input_http_url"), + hint = tr("http_url_example"), initial = httpUrl, - maxLength = 50, + maxLength = 200, onConfirm = { raw -> val normalized = when { raw.isBlank() -> "" @@ -350,15 +292,15 @@ fun SettingsScreen( } if (showTimerDialog) { AddMinDialog( - title = "选择时间", + title = tr("select_time"), initialMinutes = timerMinutes, onConfirm = { minutes -> if (minutes <= 0) { AppContainer.sleepTimer.cancel() - PlatformUi.showToast("已取消定时关闭") + PlatformUi.showToast(I18n.t("timer_cancelled")) } else { AppContainer.sleepTimer.schedule(minutes) - PlatformUi.showToast("将在 $minutes 分钟后停止播放") + PlatformUi.showToast(I18n.t("will_stop_in", minutes)) } }, onDismiss = { showTimerDialog = false }, @@ -368,28 +310,25 @@ fun SettingsScreen( ResetDataDialog( onClearSongs = { scope.launch { - loadingMsg = "重置中…" + loadingMsg = I18n.t("resetting") AppContainer.library.clearMusic() AppContainer.library.clearAlbums() AppContainer.library.clearArtists() settings.dataVersion = "" loadingMsg = null - PlatformUi.showToast("清理成功") + PlatformUi.showToast(I18n.t("clean_success")) } }, onClearUserData = { scope.launch { - loadingMsg = "即将退出…" + loadingMsg = I18n.t("exiting") AppContainer.library.clearMenus() AppContainer.library.clearLove() AppContainer.library.clearHistory() AppContainer.library.clearPlayList() - settings.backgroundPhoto = "" - settings.enableBackground = false settings.dataVersion = "" - onBgPhotoChange("") loadingMsg = null - PlatformUi.showToast("用户数据已清理") + PlatformUi.showToast(I18n.t("user_data_cleared")) PlatformUi.exitApp() } }, @@ -435,7 +374,7 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) { val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary Scaffold( containerColor = bg, - topBar = { AppHeader(title = "扫码配对", onBack = onBack) }, + topBar = { AppHeader(title = tr("scan_pair"), onBack = onBack) }, ) { padding -> Column( Modifier @@ -451,7 +390,7 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) { .clip(RoundedCornerShape(12.dp)) .background(if (dark) ColorMs.Dark1E2328 else ColorMs.E7F2FF) .clickable { - top.zhushenwudi.llmp.platform.PlatformUi.openQrScanner { result -> + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openQrScanner { result -> val parsed = parseHost(result) if (parsed.isNotBlank()) { host = parsed @@ -469,19 +408,19 @@ fun ScanScreen(onBack: () -> Unit, onConnectedHost: (String) -> Unit) { tint = ColorMs.PinkF940A7, ) Spacer(Modifier.height(12.dp)) - Text("点击打开相机扫码", color = ColorMs.Gray999, fontSize = 13.sp) + Text(tr("tap_to_scan"), color = ColorMs.Gray999, fontSize = hSp(13f)) } } Spacer(Modifier.height(24.dp)) OutlinedTextField( value = host, onValueChange = { host = it.trim() }, - label = { Text("或手动输入 PC IP") }, + label = { Text(tr("or_input_pc_ip")) }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) PinkFuncButton( - title = "确认配对", + title = tr("confirm_pair"), icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), onClick = { AppContainer.settings.lastPcHost = host @@ -498,75 +437,102 @@ fun TransferScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { val dark = LocalDarkTheme.current val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val connected = state.connected - Scaffold( - containerColor = bg, - topBar = { AppHeader(title = "歌曲快传", onBack = onBack) }, - ) { padding -> - Column( - Modifier - .padding(padding) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (connected) { - Spacer(Modifier.height(35.dp)) - TransferCoverBlock(state.current) - Spacer(Modifier.height(20.dp)) - Column( - Modifier.height(60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - if (state.current.isNotBlank()) { - Text( - state.current, - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text(state.status, color = ColorMs.Gray999, fontSize = 12.sp) - } - } - Spacer(Modifier.height(40.dp)) - TransferProgressRing( - progress = state.progress, - total = state.total, - downloading = state.total > 0, - ) - Spacer(Modifier.height(24.dp)) - PinkFuncButton( - title = "断开连接", - icon = SvgAsset("drawable/sync_icon_phone.svg"), - onClick = { AppContainer.musicTransfer.stop() }, - ) - } else { - Spacer(Modifier.height(15.dp)) - SvgIcon( - asset = SvgAsset("drawable/sync_icon_data_sync.svg"), - size = 300.dp, - contentDescription = null, - ) - Spacer(Modifier.height(65.dp)) - PairTipsBlock() - Spacer(Modifier.height(85.dp)) - PinkFuncButton( - title = "设备配对", - icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), - onClick = onOpenScan, - ) - state.error?.let { - Spacer(Modifier.height(16.dp)) - Text(it, color = ColorMs.PinkF940A7, fontSize = 12.sp) + var showLeaveConfirm by remember { mutableStateOf(false) } + KeepScreenOn() + var wasCompleted by remember { mutableStateOf(state.completed) } + LaunchedEffect(state.completed) { + if (state.completed && !wasCompleted) onBack() + wasCompleted = state.completed + } + fun requestBack() { + if (connected) showLeaveConfirm = true else onBack() + } + DetailBackHandler(enabled = connected) { requestBack() } + Column( + Modifier + .fillMaxSize() + .background(bg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppHeader(title = tr("music_trans"), onBack = { requestBack() }) + if (connected) { + Spacer(Modifier.height(35.dp)) + TransferCoverBlock(coverUrl = state.coverUrl) + Spacer(Modifier.height(20.dp)) + Column( + Modifier.height(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (state.current.isNotBlank()) { + Text( + state.current, + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = hSp(15f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + state.artist, + color = ColorMs.Gray999, + fontSize = hSp(12f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } + Spacer(Modifier.height(40.dp)) + TransferProgressRing( + progress = state.progress, + total = state.total, + downloading = state.total > 0, + ) + } else { + Spacer(Modifier.height(15.dp)) + SvgIcon( + asset = SvgAsset("drawable/sync_icon_data_sync.svg"), + size = 300.dp, + contentDescription = null, + ) + Spacer(Modifier.height(65.dp)) + PairTipsBlock() + Spacer(Modifier.height(85.dp)) + PinkFuncButton( + title = tr("device_pair"), + icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), + onClick = onOpenScan, + ) + state.error?.let { + Spacer(Modifier.height(16.dp)) + Text(it, color = ColorMs.PinkF940A7, fontSize = hSp(12f)) + } } } + if (showLeaveConfirm) { + TwoButtonDialog( + title = tr("terminate_trans"), + showMessage = false, + onConfirm = { + showLeaveConfirm = false + AppContainer.musicTransfer.stop() + onBack() + }, + onDismiss = { showLeaveConfirm = false }, + ) + } } @Composable -private fun TransferCoverBlock(current: String) { +private fun KeepScreenOn() { + DisposableEffect(Unit) { + PlatformUi.setKeepScreenOn(true) + onDispose { PlatformUi.setKeepScreenOn(false) } + } +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun TransferCoverBlock(coverUrl: String) { val dark = LocalDarkTheme.current Box( Modifier @@ -574,16 +540,24 @@ private fun TransferCoverBlock(current: String) { .background(if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary), contentAlignment = Alignment.Center, ) { - if (current.isBlank()) { - @OptIn(ExperimentalResourceApi::class) + if (coverUrl.isBlank()) { AsyncImage( model = Res.getUri("drawable/logo_logo.png"), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(220.dp), + modifier = Modifier.fillMaxSize(), ) } else { - CoverPlaceholder(label = current.take(1), size = 220.dp, radius = 12.dp) + // 对齐 Flutter TransformerPageView viewportFraction=0.8 + LlmpAsyncImage( + model = coverUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(240.dp) + .clip(RoundedCornerShape(12.dp)), + stableCacheKey = coverUrl, + ) } } } @@ -620,27 +594,27 @@ private fun TransferProgressRing(progress: Int, total: Int, downloading: Boolean Text( "$progress", color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, + fontSize = hSp(15f), ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(15.dp)) Box( Modifier .width(70.dp) .height(1.dp) .background(ColorMs.SeekTrackLight), ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(15.dp)) Text( "$total", color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, + fontSize = hSp(15f), ) } } else { Text( - "数据解析中", + tr("data_parsing_short"), color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, + fontSize = hSp(15f), ) } } @@ -654,9 +628,10 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { val dark = LocalDarkTheme.current val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary val connected = state.connected + KeepScreenOn() Scaffold( containerColor = bg, - topBar = { AppHeader(title = "数据同步", onBack = onBack) }, + topBar = { AppHeader(title = tr("data_sync"), onBack = onBack) }, ) { padding -> Column( Modifier @@ -681,13 +656,13 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { Spacer(Modifier.height(60.dp)) if (connected) { PinkFuncButton( - title = "手机 ≫ 电脑", + title = tr("phone2pc"), icon = SvgAsset("drawable/sync_icon_phone.svg"), onClick = { AppContainer.dataSync.pushPhoneToPc(cover) }, ) Spacer(Modifier.height(28.dp)) PinkFuncButton( - title = "电脑 ≫ 手机", + title = tr("pc2phone"), icon = SvgAsset("drawable/sync_icon_computer.svg"), onClick = { AppContainer.dataSync.requestPcToPhone() }, ) @@ -705,30 +680,30 @@ fun DataSyncScreen(onBack: () -> Unit, onOpenScan: () -> Unit) { ) Spacer(Modifier.width(10.dp)) Text( - "完全覆盖数据", + tr("cover_full_data"), color = if (dark) ColorMs.PinkF940A7 else ColorMs.Text333, - fontSize = 15.sp, + fontSize = hSp(15f), ) } Spacer(Modifier.height(20.dp)) - Text(state.status, color = ColorMs.Gray999, fontSize = 12.sp) + Text(state.status, color = ColorMs.Gray999, fontSize = hSp(12f)) } else { PinkFuncButton( - title = "设备配对", + title = tr("device_pair"), icon = SvgAsset("drawable/sync_icon_scan_qr.svg"), onClick = onOpenScan, ) state.error?.let { Spacer(Modifier.height(16.dp)) - Text(it, color = ColorMs.PinkF940A7, fontSize = 12.sp) + Text(it, color = ColorMs.PinkF940A7, fontSize = hSp(12f)) } } } } if (showCoverConfirm) { TwoButtonDialog( - title = "警告: 请谨慎选择", - message = "是否确定完全覆盖数据", + title = tr("warning_choose"), + message = tr("confirm_full_trans"), onConfirm = { cover = true }, onDismiss = { showCoverConfirm = false }, ) @@ -774,7 +749,7 @@ fun DriveModeScreen(onBack: () -> Unit) { } } - // Flutter: Scaffold(bg=outer) + AppBar/body(bg=contentBg) + bottom bar(outer) + // 外层背景 + 内容区背景 + 底部栏外层背景 Column( Modifier .fillMaxSize() @@ -787,7 +762,7 @@ fun DriveModeScreen(onBack: () -> Unit) { .background(contentBg) .statusBarsPadding(), ) { - // Flutter MyAppbar height 40, same contentBg + // 顶栏占位高度 40,与内容区同色 Spacer(Modifier.height(40.dp)) Column( Modifier @@ -795,130 +770,130 @@ fun DriveModeScreen(onBack: () -> Unit) { .fillMaxWidth() .padding(top = 10.dp), ) { - // Flutter SwipeImageCarousel recreates PageView when playlist changes (UniqueKey). - key( - state.queue.size, - state.queue.firstOrNull()?.musicId, - state.queue.lastOrNull()?.musicId, - ) { - DriveModeCarousel( - queue = state.queue, - currentIndex = state.index, - onPageSettled = { page -> - if (page != state.index) { - AppContainer.player.playAtIndex(page) - } - }, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) - } - Column( - Modifier - .fillMaxWidth() - .height(280.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - music?.musicName ?: "暂无歌曲", - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .height(34.dp) - .padding(horizontal = 24.dp), - ) - Spacer(Modifier.height(5.dp)) - Text( - line, - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 20.sp, - maxLines = 2, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .padding(horizontal = 24.dp), - ) - Spacer(Modifier.height(24.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + // 播放列表变化时用 key 重建轮播。 + key( + state.queue.size, + state.queue.firstOrNull()?.musicId, + state.queue.lastOrNull()?.musicId, ) { - val btnBg = contentBg - NeumorphicIconButton( - icon = if (isLove) { - SvgAsset("drawable/drive_car_favoriate.svg") - } else { - SvgAsset("drawable/drive_car_dis_favorite.svg") - }, - onClick = { - music?.musicId?.let { id -> - AppContainer.toggleLove(id) + DriveModeCarousel( + queue = state.queue, + currentIndex = state.index, + onPageSettled = { page -> + if (page != state.index) { + AppContainer.player.playAtIndex(page) } }, - size = 62.dp, - iconSize = 42.dp, - radius = 31.dp, - background = btnBg, - iconColor = if (isLove) { - ColorMs.PinkF940A7 - } else if (dark) { - ColorMs.White - } else { - Color.Black - }, - hasShadow = true, - contentPadding = 10.dp, - ) - Spacer(Modifier.width(38.dp)) - // Flutter: 90×90 + outer Padding(8); no stroke ring around glyph. - NeumorphicIconButton( - icon = if (state.isPlaying) { - SvgAsset("drawable/drive_car_play_button.svg") - } else { - SvgAsset("drawable/drive_car_pause_button.svg") - }, - onClick = { AppContainer.player.toggle() }, - size = 90.dp, - iconSize = 42.dp, - radius = 45.dp, - background = btnBg, - iconColor = if (dark) ColorMs.White else Color.Black, - hasShadow = true, - contentPadding = 24.dp, - ) - Spacer(Modifier.width(36.dp)) - NeumorphicIconButton( - icon = when (state.loopMode) { - LoopMode.LIST -> LlmpIcons.Loop - LoopMode.SINGLE -> LlmpIcons.LoopOne - LoopMode.SHUFFLE -> LlmpIcons.Shuffle - }, - onClick = { - val next = when (state.loopMode) { - LoopMode.LIST -> LoopMode.SINGLE - LoopMode.SINGLE -> LoopMode.SHUFFLE - LoopMode.SHUFFLE -> LoopMode.LIST - } - AppContainer.settings.loopMode = next - AppContainer.player.setLoopMode(next) - }, - size = 60.dp, - iconSize = 30.dp, - radius = 30.dp, - background = btnBg, - iconColor = if (dark) ColorMs.White else Color.Black, - hasShadow = true, - contentPadding = 15.dp, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), ) } - } + Column( + Modifier + .fillMaxWidth() + .height(280.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + music?.musicName ?: tr("no_songs"), + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = hSp(24f), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .height(34.dp) + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(5.dp)) + Text( + line, + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = hSp(20f), + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(24.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + val btnBg = contentBg + NeumorphicIconButton( + icon = if (isLove) { + SvgAsset("drawable/drive_car_favoriate.svg") + } else { + SvgAsset("drawable/drive_car_dis_favorite.svg") + }, + onClick = { + music?.musicId?.let { id -> + AppContainer.toggleLove(id) + } + }, + size = 62.dp, + iconSize = 42.dp, + radius = 31.dp, + background = btnBg, + iconColor = if (isLove) { + ColorMs.PinkF940A7 + } else if (dark) { + ColorMs.White + } else { + Color.Black + }, + hasShadow = true, + contentPadding = 10.dp, + ) + Spacer(Modifier.width(38.dp)) + // 播放按钮 90×90,外内边距 8,图标无描边环。 + NeumorphicIconButton( + icon = if (state.isPlaying) { + SvgAsset("drawable/drive_car_play_button.svg") + } else { + SvgAsset("drawable/drive_car_pause_button.svg") + }, + onClick = { AppContainer.player.toggle() }, + size = 90.dp, + iconSize = 42.dp, + radius = 45.dp, + background = btnBg, + iconColor = if (dark) ColorMs.White else Color.Black, + hasShadow = true, + contentPadding = 24.dp, + ) + Spacer(Modifier.width(36.dp)) + NeumorphicIconButton( + icon = when (state.loopMode) { + LoopMode.LIST -> LlmpIcons.Loop + LoopMode.SINGLE -> LlmpIcons.LoopOne + LoopMode.SHUFFLE -> LlmpIcons.Shuffle + }, + onClick = { + val next = when (state.loopMode) { + LoopMode.LIST -> LoopMode.SINGLE + LoopMode.SINGLE -> LoopMode.SHUFFLE + LoopMode.SHUFFLE -> LoopMode.LIST + } + AppContainer.settings.loopMode = next + AppContainer.player.setLoopMode(next) + }, + size = 60.dp, + iconSize = 30.dp, + radius = 30.dp, + background = btnBg, + iconColor = if (dark) ColorMs.White else Color.Black, + hasShadow = true, + contentPadding = 15.dp, + ) + } + } } } Column( @@ -935,14 +910,14 @@ fun DriveModeScreen(onBack: () -> Unit) { .background(outerBg), ) { DriveBottomItem( - text = "退出", + text = tr("exit"), icon = SvgAsset("drawable/drive_car_exit.svg"), color = ColorMs.DriveExit, onClick = onBack, modifier = Modifier.weight(1f), ) DriveBottomItem( - text = "我喜欢", + text = tr("iLove"), icon = SvgAsset("drawable/drive_car_favorite_bottom.svg"), color = ColorMs.DriveFavorite, onClick = { @@ -957,7 +932,7 @@ fun DriveModeScreen(onBack: () -> Unit) { modifier = Modifier.weight(1f), ) DriveBottomItem( - text = "历史", + text = tr("history_short"), icon = SvgAsset("drawable/drive_car_playlist_bottom.svg"), color = ColorMs.DriveHistory, onClick = { @@ -983,7 +958,7 @@ private fun DriveBottomItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - // Flutter renderBottomItem: Expanded + Column(mainAxisAlignment: center) + // 底部项:等宽列,内容垂直居中 Column( modifier = modifier .fillMaxHeight() @@ -999,14 +974,14 @@ private fun DriveBottomItem( icon = icon, onClick = onClick, size = 30.dp, - // Flutter padding:0 的 30×30 按钮,图标铺满(默认 iconSize:15 过小) + // 30×30 按钮,内边距 0,图标铺满 iconSize = 30.dp, iconColor = color, hasShadow = false, contentPadding = 0.dp, ) Spacer(Modifier.height(6.dp)) - Text(text, color = color, fontSize = 17.sp) + Text(text, color = color, fontSize = hSp(17f)) } } @@ -1032,7 +1007,7 @@ fun DailyScreen(onBack: () -> Unit) { .fillMaxWidth() .background(appBarBg), ) { - AppHeader(title = "每日资讯", onBack = onBack, lightContent = true) + AppHeader(title = tr("daily_news"), onBack = onBack, lightContent = true) Spacer(Modifier.height(8.dp)) } }, @@ -1044,9 +1019,9 @@ fun DailyScreen(onBack: () -> Unit) { ) { Spacer(Modifier.height(6.dp)) Text( - "提示:除特别注明外,此页面或章节的时间均以北京时间(UTC+8)为准。", + tr("daily_timezone_hint"), color = Color.Black, - fontSize = 12.sp, + fontSize = hSp(12f), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -1054,27 +1029,27 @@ fun DailyScreen(onBack: () -> Unit) { ) error?.let { Text( - "无法加载:$it", + tr("cannot_load") + it, color = ColorMs.PinkF940A7, modifier = Modifier.padding(16.dp), ) } DailySection( - title = "近期动态", + title = tr("recent_trends"), headerColor = ColorMs.DailyRecentHeader, items = push?.recent.orEmpty(), marginBottom = 6.dp, ) DailySection( - title = "放送预告", + title = tr("live_notice"), headerColor = ColorMs.DailyBangumiHeader, items = push?.bangumi.orEmpty(), marginBottom = 6.dp, ) val today = push?.today - val dayLabel = today?.day ?: "今天" + val dayLabel = today?.day ?: tr("today") DailySection( - title = "往年今日", + title = tr("today_in_previous_years"), headerColor = ColorMs.DailyTodayHeader, items = today?.content.orEmpty(), subtitle = dayLabel, @@ -1115,16 +1090,16 @@ private fun DailySection( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(title, color = ColorMs.B2246A, fontSize = 18.sp) + Text(title, color = ColorMs.B2246A, fontSize = hSp(18f)) if (subtitle != null) { - Text(subtitle, color = ColorMs.B2246A, fontSize = 10.sp) + Text(subtitle, color = ColorMs.B2246A, fontSize = hSp(10f)) } } items.forEach { item -> Row( Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -1134,9 +1109,16 @@ private fun DailySection( .background(parseHexColor(item.color)), ) Spacer(Modifier.width(12.dp)) + val scale = LocalDesignScale.current + val itemSize = (14f / scale).sp Text( item.content, color = ColorMs.DailyItemText, + fontSize = itemSize, + lineHeight = (15f / scale).sp, + letterSpacing = 0.sp, + style = TextStyle.Default, + overflow = TextOverflow.Clip, modifier = Modifier.weight(1f), ) } @@ -1148,15 +1130,53 @@ private fun DailySection( } } +private const val PrivacyLinkTag = "privacy_link" + +private const val PrivacyUmengUrl = "https://www.umeng.com/page/policy" +private const val PrivacyShareUrl = "https://policy.zztfly.com/sdk/share/privacy" +private const val Privacy360Url = "https://jiagu.360.cn/#/global/help/322" +private const val PrivacyGithubUrl = + "https://github.com/zhushenwudi/LoveLiveMusicPlayerMobile" + @Composable fun PermissionsScreen(onBack: () -> Unit) { val dark = LocalDarkTheme.current val appBarBg = if (dark) ColorMs.NightPrimary else ColorMs.Blue28B3F7 + val bodyColor = if (dark) ColorMs.White else ColorMs.Text333 + val linkColor = ColorMs.Blue28B3F7 + val bodySize = hSp(12f) + val bodyStyle = TextStyle(color = bodyColor, fontSize = bodySize, lineHeight = bodySize * 1.4f) + val linkStyle = SpanStyle(color = linkColor, fontSize = bodySize) + val lang by I18n.lang.collectAsState() + val annotated = remember(bodyColor, bodySize, lang) { + buildAnnotatedString { + append(I18n.t("privacy_detail1")) + pushStringAnnotation(PrivacyLinkTag, PrivacyUmengUrl) + withStyle(linkStyle) { append(I18n.t("privacy_umeng")) } + pop() + append(I18n.t("privacy_detail2")) + pushStringAnnotation(PrivacyLinkTag, PrivacyShareUrl) + withStyle(linkStyle) { append(I18n.t("privacy_share")) } + pop() + append(I18n.t("privacy_detail3")) + pushStringAnnotation(PrivacyLinkTag, Privacy360Url) + withStyle(linkStyle) { append(I18n.t("privacy_360")) } + pop() + append(I18n.t("privacy_detail4")) + } + } + val githubLink = remember(bodySize, lang) { + buildAnnotatedString { + pushStringAnnotation(PrivacyLinkTag, PrivacyGithubUrl) + withStyle(linkStyle) { append(I18n.t("github_url")) } + pop() + } + } Scaffold( containerColor = if (dark) ColorMs.NightPrimary else ColorMs.White, topBar = { Column(Modifier.fillMaxWidth().background(appBarBg)) { - AppHeader(title = "隐私协议", onBack = onBack, lightContent = true) + AppHeader(title = tr("privacy_agreement"), onBack = onBack, lightContent = true) Spacer(Modifier.height(8.dp)) } }, @@ -1165,50 +1185,54 @@ fun PermissionsScreen(onBack: () -> Unit) { Modifier .padding(padding) .verticalScroll(rememberScrollState()) - .padding(horizontal = 12.dp), + .padding(horizontal = 12.dp) + .navigationBarsPadding(), ) { - val bodyColor = if (dark) ColorMs.White else ColorMs.Text333 - Text( - "本应用会在必要范围内使用网络、存储、通知、相机与悬浮窗等权限,用于曲库同步、歌曲播放、扫码配对与桌面歌词等功能。统计分析可能使用友盟等第三方 SDK。", - color = bodyColor, - fontSize = 12.sp, + ClickableText( + text = annotated, + style = bodyStyle, + onClick = { offset -> + annotated.getStringAnnotations(PrivacyLinkTag, offset, offset) + .firstOrNull() + ?.let { PlatformUi.openWebView(it.item, I18n.t("privacy_policy")) } + }, ) Spacer(Modifier.height(12.dp)) - Text( - "https://github.com/zhushenwudi/LoveLiveMusicPlayer", - color = ColorMs.Blue28B3F7, - fontSize = 12.sp, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .clickable { - top.zhushenwudi.llmp.platform.PlatformUi.openWebView( - "https://github.com/zhushenwudi/LoveLiveMusicPlayer", - "GitHub", - ) - }, - ) - Spacer(Modifier.height(16.dp)) - PinkFuncButton( - title = "申请悬浮窗权限", - icon = SvgAsset("drawable/drawer_drawer_desktop_lyric.svg"), - onClick = { top.zhushenwudi.llmp.platform.PlatformUi.requestOverlayPermission() }, + ClickableText( + text = githubLink, + style = bodyStyle.copy(textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + githubLink.getStringAnnotations(PrivacyLinkTag, offset, offset) + .firstOrNull() + ?.let { PlatformUi.openWebView(it.item, "GitHub") } + }, ) + Spacer(Modifier.height(12.dp)) } } } @Composable fun SdScreen(onBack: () -> Unit) { - val roots = remember { AppContainer.fileSystem.listUsbRoots() } - var selected by remember { mutableStateOf(AppContainer.settings.sdPath) } + val usbRevision by AppContainer.usbRevision.collectAsState() + var devices by remember { mutableStateOf(AppContainer.listStorageDevices()) } + LaunchedEffect(usbRevision) { + if (usbRevision == 0L) { + devices = AppContainer.listStorageDevices() + return@LaunchedEffect + } + delay(1_000) + devices = AppContainer.listStorageDevices() + } val dark = LocalDarkTheme.current val appBarBg = if (dark) ColorMs.NightPrimary else ColorMs.Blue28B3F7 + val amber = Color(0xFFFFC107) Scaffold( containerColor = if (dark) ColorMs.NightPrimary else ColorMs.White, topBar = { Column(Modifier.fillMaxWidth().background(appBarBg)) { - AppHeader(title = "存储设置", onBack = onBack, lightContent = true) - Spacer(Modifier.height(8.dp)) + AppHeader(title = tr("storage_settings"), onBack = onBack, lightContent = true) } }, ) { padding -> @@ -1220,50 +1244,47 @@ fun SdScreen(onBack: () -> Unit) { Box( Modifier .fillMaxWidth() - .height(50.dp), + .height(50.dp) + .background(appBarBg), contentAlignment = Alignment.Center, ) { Text( - "请选择音乐文件存储位置", + tr("change_storage_warning"), color = ColorMs.White, - fontSize = 15.sp, + fontSize = hSp(15f), fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .background(appBarBg) - .padding(vertical = 12.dp), textAlign = TextAlign.Center, ) } - if (roots.isEmpty()) { - Text( - "未检测到可移动存储(USB)。", - color = if (dark) ColorMs.White else ColorMs.Text333, - modifier = Modifier.padding(30.dp), - ) - } else { - Column(Modifier.padding(horizontal = 30.dp, vertical = 10.dp)) { - roots.chunked(2).forEach { row -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(20.dp), - ) { - row.forEach { path -> - val chosen = selected == path - Column( - Modifier - .weight(1f) - .clickable { - selected = path - AppContainer.settings.sdPath = path - }, - horizontalAlignment = Alignment.CenterHorizontally, + Column(Modifier.padding(horizontal = 30.dp, vertical = 10.dp)) { + devices.chunked(2).forEach { row -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + row.forEach { device -> + Column( + Modifier + .weight(1f) + .clickable { + AppContainer.selectStorageDevice(device) + devices = AppContainer.listStorageDevices() + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.shadow( + elevation = if (device.chosen) 12.dp else 0.dp, + shape = RoundedCornerShape(6.dp), + ambientColor = amber, + spotColor = amber, + ), ) { NeumorphicIconButton( icon = SvgAsset("drawable/drawer_drawer_sd.svg"), onClick = { - selected = path - AppContainer.settings.sdPath = path + AppContainer.selectStorageDevice(device) + devices = AppContainer.listStorageDevices() }, size = 80.dp, iconSize = 40.dp, @@ -1271,23 +1292,27 @@ fun SdScreen(onBack: () -> Unit) { background = appBarBg, contentPadding = 20.dp, ) - Spacer(Modifier.height(8.dp)) - Text( - path.substringAfterLast('/').ifBlank { path }, - color = if (chosen) Color(0xFFFFC107) else { - if (dark) ColorMs.White else ColorMs.Text333 - }, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 2, - textAlign = TextAlign.Center, - ) } + Spacer(Modifier.height(8.dp)) + Text( + device.name, + color = if (device.chosen) { + amber + } else if (dark) { + ColorMs.White + } else { + ColorMs.Text333 + }, + fontSize = hSp(15f), + fontWeight = FontWeight.Bold, + maxLines = 2, + textAlign = TextAlign.Center, + ) } - if (row.size == 1) Spacer(Modifier.weight(1f)) } - Spacer(Modifier.height(10.dp)) + if (row.size == 1) Spacer(Modifier.weight(1f)) } + Spacer(Modifier.height(10.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/FeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/FeatureScreens.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt index a1c9c73..b0b9fba 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/FeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.screens +package com.zhushenwudi.lovelivemusicplayer.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -43,45 +42,54 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Artist -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.navigation.HeroCircleClip -import top.zhushenwudi.llmp.navigation.HeroCoverSlot -import top.zhushenwudi.llmp.navigation.HeroKeys -import top.zhushenwudi.llmp.navigation.HeroRoundedClip -import top.zhushenwudi.llmp.navigation.LibraryTab -import top.zhushenwudi.llmp.navigation.LocalOpenLibraryDetail -import top.zhushenwudi.llmp.navigation.hidesMenu -import top.zhushenwudi.llmp.navigation.llmpDetailSharedElement -import top.zhushenwudi.llmp.platform.FeatureModuleIndex -import top.zhushenwudi.llmp.platform.PlatformUi -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.HomeUiState -import top.zhushenwudi.llmp.ui.components.AddToMenuDialog -import top.zhushenwudi.llmp.ui.components.AlbumCoverImage -import top.zhushenwudi.llmp.ui.components.AlbumDetailsCover -import top.zhushenwudi.llmp.ui.components.AlbumGridItem -import top.zhushenwudi.llmp.ui.components.ArtistListItem -import top.zhushenwudi.llmp.ui.components.ArtistPhotoImage -import top.zhushenwudi.llmp.ui.components.CoverPlaceholder -import top.zhushenwudi.llmp.ui.components.DetailsPage -import top.zhushenwudi.llmp.ui.components.DialogMoreWithMusic -import top.zhushenwudi.llmp.ui.components.DialogSongInfo -import top.zhushenwudi.llmp.ui.components.MusicCoverImage -import top.zhushenwudi.llmp.ui.components.SongListItem -import top.zhushenwudi.llmp.ui.components.addMusicPlayNext -import top.zhushenwudi.llmp.ui.icons.LlmpIcons -import top.zhushenwudi.llmp.ui.icons.TintIcon - -/** Flutter RefresherWidget bottom 70.h — clear collapsed WeSlide mini player. */ -/** Mini player clearance + extra gap (70 + 6). */ -private val HomeListBottomInset = 76.dp +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.Artist +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroCircleClip +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroCoverSlot +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroKeys +import com.zhushenwudi.lovelivemusicplayer.navigation.HeroRoundedClip +import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab +import com.zhushenwudi.lovelivemusicplayer.navigation.LocalOpenLibraryDetail +import com.zhushenwudi.lovelivemusicplayer.navigation.hidesMenu +import com.zhushenwudi.lovelivemusicplayer.navigation.llmpDetailSharedElement +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.HomeUiState +import com.zhushenwudi.lovelivemusicplayer.ui.components.AddToMenuDialog +import com.zhushenwudi.lovelivemusicplayer.ui.components.AlbumCoverImage +import com.zhushenwudi.lovelivemusicplayer.ui.components.AlbumDetailsCover +import com.zhushenwudi.lovelivemusicplayer.ui.components.AlbumGridItem +import com.zhushenwudi.lovelivemusicplayer.ui.components.ArtistListItem +import com.zhushenwudi.lovelivemusicplayer.ui.components.ArtistPhotoImage +import com.zhushenwudi.lovelivemusicplayer.ui.components.CoverPlaceholder +import com.zhushenwudi.lovelivemusicplayer.ui.components.DetailsPage +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogMoreWithMusic +import com.zhushenwudi.lovelivemusicplayer.ui.components.DialogSongInfo +import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemSubtitleLineHeight +import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemSubtitleSize +import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemTitleLineHeight +import com.zhushenwudi.lovelivemusicplayer.ui.components.ListItemTitleSize +import com.zhushenwudi.lovelivemusicplayer.ui.components.ListLeadingCoverSize +import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionButtonRadius +import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionButtonSize +import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconPadding +import com.zhushenwudi.lovelivemusicplayer.ui.components.LibraryActionIconSize +import com.zhushenwudi.lovelivemusicplayer.ui.components.MusicCoverImage +import com.zhushenwudi.lovelivemusicplayer.ui.components.NeumorphicIconButton +import com.zhushenwudi.lovelivemusicplayer.ui.components.SongListItem +import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoLineListItemHeight +import com.zhushenwudi.lovelivemusicplayer.ui.components.TwoLineListTextColumn +import com.zhushenwudi.lovelivemusicplayer.ui.components.addMusicPlayNext +import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset @Composable fun LibraryTabContent( @@ -113,16 +121,19 @@ fun LibraryTabContent( onOpenAlbum = onOpenAlbum, listState = HomeUiState.songListState, ) + LibraryTab.ALBUMS -> AlbumList( albums = albums, onOpen = onOpenAlbum, gridState = HomeUiState.albumGridState, ) + LibraryTab.ARTISTS -> ArtistList( artists = artists, onOpen = onOpenArtist, listState = HomeUiState.artistListState, ) + LibraryTab.LOVE -> SongList( music = loved, playingId = playingId, @@ -133,11 +144,14 @@ fun LibraryTabContent( onOpenAlbum = onOpenAlbum, listState = HomeUiState.loveListState, ) + LibraryTab.MENUS -> MenuList( menus = menus, onOpen = onOpenMenu, + onShare = { com.zhushenwudi.lovelivemusicplayer.share.QqShare.shareMenu(it) }, listState = HomeUiState.menuListState, ) + LibraryTab.RECENT -> SongList( music = recent, playingId = playingId, @@ -166,18 +180,18 @@ fun SongList( var moreMusic by remember { mutableStateOf(null) } var songInfoMusic by remember { mutableStateOf(null) } var addToMenuMusic by remember { mutableStateOf(null) } - var menus by remember { mutableStateOf>(emptyList()) } if (music.isEmpty()) { EmptyPlaceholder() return } + val listBottomInset = LocalHomeListBottomInset.current LazyColumn( state = resolvedListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = HomeListBottomInset), + contentPadding = PaddingValues(bottom = listBottomInset), ) { - items(music, key = { it.musicId ?: it.hashCode().toString() }) { item -> + items(music) { item -> val id = item.musicId.orEmpty() SongListItem( music = item, @@ -185,7 +199,7 @@ fun SongList( onClick = { onPlay(item) }, onPlayNext = { addMusicPlayNext(item) - PlatformUi.showToast("添加成功") + PlatformUi.showToast(I18n.t("add_success")) }, onMore = { moreMusic = item }, selectMode = selectMode, @@ -202,10 +216,7 @@ fun SongList( music = target, onDismiss = { moreMusic = null }, onOpenAlbum = onOpenAlbum, - onOpenAddToMenu = { - menus = AppContainer.library.getAllMenus() - addToMenuMusic = target - }, + onOpenAddToMenu = { addToMenuMusic = target }, onShowSongInfo = { songInfoMusic = target }, ) } @@ -217,28 +228,7 @@ fun SongList( } addToMenuMusic?.let { target -> AddToMenuDialog( - menus = menus, - onPick = { menu -> - target.musicId?.let { mid -> - val ids = (menu.music + mid).distinct() - AppContainer.library.upsertMenu(menu.copy(music = ids)) - } - addToMenuMusic = null - PlatformUi.showToast("添加成功") - }, - onCreate = { name -> - val id = AppContainer.library.nextPhoneMenuId() - val menu = Menu( - id = id, - isPhone = true, - music = listOfNotNull(target.musicId), - date = "", - name = name, - ) - AppContainer.library.upsertMenu(menu) - addToMenuMusic = null - PlatformUi.showToast("添加成功") - }, + musicList = listOf(target), onDismiss = { addToMenuMusic = null }, ) } @@ -255,6 +245,7 @@ private fun AlbumList( EmptyPlaceholder() return } + val listBottomInset = LocalHomeListBottomInset.current LazyVerticalGrid( columns = GridCells.Fixed(3), state = resolvedGridState, @@ -262,11 +253,11 @@ private fun AlbumList( contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - bottom = HomeListBottomInset, + bottom = listBottomInset, ), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - items(albums, key = { it.albumId.orEmpty() }) { album -> + items(albums) { album -> AlbumGridItem( album = album, onClick = { album.albumId?.let(onOpen) }, @@ -283,14 +274,15 @@ private fun ArtistList( ) { val resolvedListState = listState ?: rememberLazyListState() if (artists.isEmpty()) { - // Flutter: SongLibraryTop hidden → empty state top 35.h + // 曲库顶栏隐藏时,空状态顶部留白 35.h EmptyPlaceholder(topOffset = 35.dp) return } + val listBottomInset = LocalHomeListBottomInset.current LazyColumn( state = resolvedListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = HomeListBottomInset), + contentPadding = PaddingValues(bottom = listBottomInset), ) { items(artists, key = { it.uid }) { artist -> ArtistListItem(artist = artist, onClick = { onOpen(artist.uid) }) @@ -302,6 +294,7 @@ private fun ArtistList( private fun MenuList( menus: List, onOpen: (Long) -> Unit, + onShare: (Menu) -> Unit = {}, listState: LazyListState? = null, ) { val resolvedListState = listState ?: rememberLazyListState() @@ -310,10 +303,11 @@ private fun MenuList( return } val dark = LocalDarkTheme.current + val listBottomInset = LocalHomeListBottomInset.current LazyColumn( state = resolvedListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = HomeListBottomInset), + contentPadding = PaddingValues(bottom = listBottomInset), ) { items(menus, key = { it.id }) { menu -> val coverMusic = remember(menu.id, menu.music) { @@ -323,20 +317,21 @@ private fun MenuList( Row( Modifier .fillMaxWidth() + .height(TwoLineListItemHeight) .clickable { onOpen(menu.id) } - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { HeroCoverSlot( key = HeroKeys.menu(menu.id), visible = !openDetail.hidesMenu(menu.id), clipShape = HeroRoundedClip, - slotModifier = Modifier.size(48.dp), + slotModifier = Modifier.size(ListLeadingCoverSize), ) { heroMod -> if (coverMusic != null) { MusicCoverImage( music = coverMusic, - size = 48.dp, + size = ListLeadingCoverSize, radius = 12.dp, modifier = heroMod, ) @@ -349,29 +344,39 @@ private fun MenuList( ) } } - Column( - Modifier + TwoLineListTextColumn( + modifier = Modifier .weight(1f) .padding(start = 10.dp), - ) { - Text( - menu.name, - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) - Text( - "${menu.music.size} 首 · ${if (menu.isPhone) "手机" else "PC"}", - color = ColorMs.Gray999, - fontSize = 12.sp, - ) - } - TintIcon( - asset = LlmpIcons.More, - tint = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC, - size = 30.dp, - contentDescription = "更多", + title = { + Text( + menu.name, + color = if (dark) ColorMs.White else ColorMs.Text333, + fontSize = ListItemTitleSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + lineHeight = ListItemTitleLineHeight, + ) + }, + subtitle = { + Text( + if (menu.isPhone) tr("menu_meta_phone", menu.music.size) else tr("menu_meta_pc", menu.music.size), + color = ColorMs.Gray999, + fontSize = ListItemSubtitleSize, + lineHeight = ListItemSubtitleLineHeight, + ) + }, + ) + NeumorphicIconButton( + icon = LlmpIcons.More, + onClick = { onShare(menu) }, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, + iconColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC, + hasShadow = false, + modifier = Modifier.padding(horizontal = 3.dp), ) } } @@ -379,9 +384,9 @@ private fun MenuList( } /** - * Flutter PageViewComponent._buildNullWidget: - * centered ic_null 80×80 + 10 gap + "暂无数据" (#BFBFBF, 18sp). - * Artists / menus add topOffset 35 to align when SongLibraryTop is hidden. + * 空状态占位组件: + * 居中 ic_null 80×80 + 10 间距 +「暂无数据」(#BFBFBF, 18sp)。 + * 艺人/歌单页在曲库顶栏隐藏时增加 topOffset 35 对齐。 */ @OptIn(ExperimentalResourceApi::class) @Composable @@ -404,7 +409,7 @@ fun EmptyPlaceholder( ) Spacer(Modifier.height(10.dp)) Text( - "暂无数据", + tr("no_data"), color = ColorMs.BFBFBF, fontSize = 18.sp, ) @@ -416,21 +421,21 @@ fun EmptyPlaceholder( @Composable fun MoegirlScreen(onBack: () -> Unit) { val query = AppContainer.player.state.collectAsState().value.current?.musicName.orEmpty() - val url = if (query.isBlank()) Const.MOE_GIRL_URL else Const.MOE_GIRL_URL + query + val url = Const.buildMoeGirlSearchUrl(query) LaunchedEffect(url) { - top.zhushenwudi.llmp.platform.PlatformUi.openWebView(url, "萌娘百科") + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openWebView(url, I18n.t("moe_girl_wiki")) onBack() } Scaffold( topBar = { TopAppBar( - title = { Text("萌娘百科") }, - navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, + title = { Text(tr("moe_girl_wiki")) }, + navigationIcon = { TextButton(onClick = onBack) { Text(tr("back")) } }, ) }, ) { padding -> Column(Modifier.padding(padding).padding(16.dp)) { - Text("正在打开:$url") + Text(tr("opening") + url) } } } @@ -439,40 +444,21 @@ fun MoegirlScreen(onBack: () -> Unit) { @Composable fun TachieScreen(onBack: () -> Unit) { LaunchedEffect(Unit) { - top.zhushenwudi.llmp.platform.PlatformUi.openWebView( + com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi.openWebView( "file:///android_asset/tachie/index.html", - "立绘", + I18n.t("tachie"), ) onBack() } Scaffold( topBar = { TopAppBar( - title = { Text("立绘") }, - navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, + title = { Text(tr("tachie")) }, + navigationIcon = { TextButton(onClick = onBack) { Text(tr("back")) } }, ) }, ) { padding -> - Text("正在打开立绘…", Modifier.padding(padding).padding(16.dp)) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LogsScreen(onBack: () -> Unit) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("模块索引") }, - navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, - ) - }, - ) { padding -> - LazyColumn(Modifier.padding(padding)) { - items(FeatureModuleIndex.modules) { name -> - Text("· $name", Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) - } - } + Text(tr("opening_tachie"), Modifier.padding(padding).padding(16.dp)) } } @@ -481,7 +467,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U val album = remember(albumId) { AppContainer.library.getAlbum(albumId) } val songs = remember(albumId) { AppContainer.library.getMusicByAlbum(albumId) } DetailsPage( - title = album?.albumName ?: "专辑", + title = album?.albumName ?: tr("album"), songs = songs, onBack = onBack, onOpenPlayer = onOpenPlayer, @@ -494,7 +480,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U if (album != null) { AlbumCoverImage( album = album, - // Flutter showImg(path, 240, null) → height falls back to width. + // 图片高度未指定时回退为宽度(240)。 modifier = Modifier .size(240.dp) .llmpDetailSharedElement( @@ -505,7 +491,7 @@ fun AlbumDetailScreen(albumId: String, onBack: () -> Unit, onOpenPlayer: () -> U ) } else { CoverPlaceholder( - label = "专", + label = tr("album_abbr"), size = 240.dp, radius = 12.dp, modifier = Modifier.llmpDetailSharedElement( @@ -530,7 +516,7 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () -> ) } DetailsPage( - title = artist?.name ?: "歌手", + title = artist?.name ?: tr("singer"), songs = songs, onBack = onBack, onOpenPlayer = onOpenPlayer, @@ -546,7 +532,7 @@ fun ArtistDetailScreen(artistId: String, onBack: () -> Unit, onOpenPlayer: () -> ) } else { CoverPlaceholder( - label = "歌", + label = tr("music_abbr"), size = 240.dp, radius = 120.dp, modifier = Modifier.llmpDetailSharedElement( @@ -573,7 +559,7 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit) } val coverMusic = songs.firstOrNull() DetailsPage( - title = menu?.name ?: "歌单", + title = menu?.name ?: tr("songMenu"), songs = songs, onBack = onBack, onOpenPlayer = onOpenPlayer, @@ -599,7 +585,7 @@ fun MenuDetailScreen(menuId: Long, onBack: () -> Unit, onOpenPlayer: () -> Unit) ) } else { CoverPlaceholder( - label = menu?.name?.take(1).orEmpty().ifEmpty { "单" }, + label = menu?.name?.take(1).orEmpty().ifEmpty { tr("menu_abbr") }, size = 240.dp, radius = 12.dp, modifier = Modifier.llmpDetailSharedElement( diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/LogsScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/LogsScreen.kt new file mode 100644 index 0000000..605f32e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/LogsScreen.kt @@ -0,0 +1,603 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.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.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.IosShare +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.zhushenwudi.lovelivemusicplayer.db.formatLocalHms +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.platform.AppLogEntry +import com.zhushenwudi.lovelivemusicplayer.platform.AppLogStore +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformUi +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +import com.zhushenwudi.lovelivemusicplayer.ui.platform.rememberCopyTextHandler + +private val ColorError = Color(0xFFEF5350) +private val ColorWarning = Color(0xFFEF6C00) +private val ColorInfo = Color(0xFF42A5F5) +private val ColorDebug = Color(0xFF9E9E9E) + +private data class LogsTheme( + val bg: Color, + val card: Color, + val text: Color, + val muted: Color, + val chipSelected: Color, +) + +@Composable +private fun rememberLogsTheme(): LogsTheme { + val dark = LocalDarkTheme.current + return if (dark) { + LogsTheme( + bg = ColorMs.NightPrimary, + card = ColorMs.Dark1E2328, + text = ColorMs.White, + muted = ColorMs.GrayDFD, + chipSelected = Color(0xFF3949AB), + ) + } else { + LogsTheme( + bg = ColorMs.LightPrimary, + card = ColorMs.White, + text = ColorMs.Text333, + muted = ColorMs.Gray666, + chipSelected = ColorMs.EBF3FE, + ) + } +} + +private fun levelColor(key: String): Color = when (key) { + "error" -> ColorError + "warning" -> ColorWarning + "info" -> ColorInfo + else -> ColorDebug +} + +@Composable +private fun levelTitle(key: String): String = when (key) { + "error" -> tr("logs_error") + "warning" -> tr("logs_warning") + "info" -> tr("logs_info") + "debug" -> tr("logs_debug") + else -> tr("logs_verbose") +} + +/** 对齐原项目 `TalkerScreen(talker: Log4f.getLogger())`。 */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsScreen(onBack: () -> Unit) { + val version by AppLogStore.version.collectAsState() + val allLogs = remember(version) { AppLogStore.snapshot() } + var query by remember { mutableStateOf("") } + var enabledKeys by remember { mutableStateOf(setOf()) } + var reversed by remember { mutableStateOf(true) } + var expanded by remember { mutableStateOf(true) } + var showActions by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } + var showMonitor by remember { mutableStateOf(false) } + val copyText = rememberCopyTextHandler() + val theme = rememberLogsTheme() + + val filtered = remember(allLogs, query, enabledKeys, reversed) { + val q = query.trim() + val list = allLogs.filter { entry -> + (enabledKeys.isEmpty() || entry.key in enabledKeys) && + (q.isEmpty() || + entry.message.contains(q, ignoreCase = true) || + entry.tag.contains(q, ignoreCase = true) || + entry.key.contains(q, ignoreCase = true)) + } + if (reversed) list.asReversed() else list + } + val counts = remember(allLogs) { + allLogs.groupingBy { it.key }.eachCount() + } + val uniqKeys = remember(counts) { + listOf("error", "warning", "info", "debug").filter { it in counts } + } + val hasErrors = allLogs.any { it.key == "error" } + + if (showMonitor) { + TalkerMonitorPage( + logs = allLogs, + theme = theme, + onBack = { showMonitor = false }, + onPickKey = { key -> + enabledKeys = setOf(key) + showMonitor = false + }, + ) + return + } + + Column( + Modifier + .fillMaxSize() + .background(theme.bg) + .statusBarsPadding(), + ) { + Row( + Modifier + .fillMaxWidth() + .height(60.dp) + .padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, tr("back"), tint = theme.text) + } + Text(tr("view_log"), color = theme.text, fontSize = 20.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Box { + IconButton(onClick = { showMonitor = true }) { + Icon(Icons.Filled.MonitorHeart, tr("logs_monitor"), tint = theme.text) + } + if (hasErrors) { + Box( + Modifier + .align(Alignment.TopEnd) + .padding(top = 10.dp, end = 10.dp) + .size(7.dp) + .clip(CircleShape) + .background(Color.Red), + ) + } + } + IconButton(onClick = { showSettings = true }) { + Icon(Icons.Filled.Settings, tr("logs_settings"), tint = theme.text) + } + IconButton(onClick = { showActions = true }) { + Icon(Icons.Filled.Menu, tr("logs_actions"), tint = theme.text) + } + } + if (uniqKeys.isNotEmpty()) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + uniqKeys.forEach { key -> + val selected = key in enabledKeys + FilterChip( + count = counts[key] ?: 0, + title = levelTitle(key), + selected = selected, + theme = theme, + onClick = { + enabledKeys = if (selected) enabledKeys - key else enabledKeys + key + }, + ) + } + } + Spacer(Modifier.height(8.dp)) + } + SearchField(query = query, onQueryChange = { query = it }, theme = theme) + Spacer(Modifier.height(8.dp)) + LazyColumn(Modifier.fillMaxSize()) { + items(filtered, key = { "${it.timeMs}-${it.tag}-${it.message.hashCode()}" }) { entry -> + LogCard( + entry = entry, + expanded = expanded, + theme = theme, + onCopy = { + copyText(entry.asLine()) + PlatformUi.showToast(I18n.t("logs_copied_one")) + }, + ) + } + } + } + + if (showActions) { + TalkerActionsSheet( + expanded = expanded, + theme = theme, + onDismiss = { showActions = false }, + onReverse = { reversed = !reversed }, + onCopyAll = { + copyText(allLogs.joinToString("\n") { it.asLine() }) + PlatformUi.showToast(I18n.t("logs_copied_all")) + }, + onCopyFiltered = { + copyText(filtered.joinToString("\n") { it.asLine() }) + PlatformUi.showToast(I18n.t("logs_copied_filter")) + }, + onToggleExpand = { expanded = !expanded }, + onClean = { AppLogStore.clear() }, + onShare = { + copyText(allLogs.joinToString("\n") { it.asLine() }) + PlatformUi.showToast(I18n.t("logs_copied_all")) + }, + ) + } + if (showSettings) { + TalkerSettingsSheet( + expanded = expanded, + reversed = reversed, + theme = theme, + onExpandedChange = { expanded = it }, + onReversedChange = { reversed = it }, + onDismiss = { showSettings = false }, + ) + } +} + +@Composable +private fun FilterChip( + count: Int, + title: String, + selected: Boolean, + theme: LogsTheme, + onClick: () -> Unit, +) { + Row( + Modifier + .clip(RoundedCornerShape(10.dp)) + .border(1.dp, theme.text, RoundedCornerShape(10.dp)) + .background(if (selected) theme.chipSelected else theme.card) + .clickable(onClick = onClick) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("$count", color = theme.text, fontSize = 12.sp) + Spacer(Modifier.width(4.dp)) + Text(title, color = theme.text, fontSize = 12.sp) + } +} + +@Composable +private fun SearchField(query: String, onQueryChange: (String) -> Unit, theme: LogsTheme) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(48.dp) + .border(1.dp, theme.text, RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Search, null, tint = theme.text, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = TextStyle(color = theme.text, fontSize = 14.sp), + cursorBrush = SolidColor(theme.text), + modifier = Modifier.weight(1f), + decorationBox = { inner -> + if (query.isEmpty()) { + Text(tr("logs_search"), color = theme.muted, fontSize = 14.sp) + } + inner() + }, + ) + } +} + +@Composable +private fun LogCard( + entry: AppLogEntry, + expanded: Boolean, + theme: LogsTheme, + onCopy: () -> Unit, +) { + val color = levelColor(entry.key) + var localExpanded by remember(expanded) { mutableStateOf(expanded) } + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .border(1.dp, color, RoundedCornerShape(10.dp)) + .background(theme.card) + .clickable { localExpanded = !localExpanded } + .padding(8.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text( + "${levelTitle(entry.key)} | ${formatLocalHms(entry.timeMs)}", + color = color, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + Text( + "[${entry.tag}] ${entry.message}", + color = color, + fontSize = 12.sp, + maxLines = if (localExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onCopy, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.ContentCopy, tr("logs_copy"), tint = color, modifier = Modifier.size(18.dp)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TalkerActionsSheet( + expanded: Boolean, + theme: LogsTheme, + onDismiss: () -> Unit, + onReverse: () -> Unit, + onCopyAll: () -> Unit, + onCopyFiltered: () -> Unit, + onToggleExpand: () -> Unit, + onClean: () -> Unit, + onShare: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = Color.Transparent, + dragHandle = null, + ) { + Column(Modifier.fillMaxWidth()) { + Text( + tr("logs_actions_title"), + color = theme.text, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + Column( + Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(theme.bg), + ) { + ActionTile(tr("logs_reverse"), Icons.Filled.SwapVert, theme) { onDismiss(); onReverse() } + ActionTile(tr("logs_copy_all"), Icons.Filled.ContentCopy, theme) { onDismiss(); onCopyAll() } + ActionTile(tr("logs_copy_filtered"), Icons.Filled.ContentCopy, theme) { + onDismiss(); onCopyFiltered() + } + ActionTile( + if (expanded) tr("logs_collapse") else tr("logs_expand"), + if (expanded) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + theme, + ) { onDismiss(); onToggleExpand() } + ActionTile(tr("logs_clear"), Icons.Filled.DeleteOutline, theme) { onDismiss(); onClean() } + ActionTile(tr("logs_share_file"), Icons.Filled.IosShare, theme, showDivider = false) { + onDismiss(); onShare() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TalkerSettingsSheet( + expanded: Boolean, + reversed: Boolean, + theme: LogsTheme, + onExpandedChange: (Boolean) -> Unit, + onReversedChange: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = theme.bg, + dragHandle = null, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Text( + tr("logs_settings_title"), + color = theme.text, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(16.dp)) + SettingRow(tr("logs_expand"), expanded, theme, onExpandedChange) + SettingRow(tr("logs_reverse"), reversed, theme, onReversedChange) + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun SettingRow(title: String, checked: Boolean, theme: LogsTheme, onChange: (Boolean) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable { onChange(!checked) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, color = theme.text, fontSize = 16.sp, modifier = Modifier.weight(1f)) + Text(if (checked) tr("logs_on") else tr("logs_off"), color = if (checked) ColorInfo else theme.muted) + } +} + +@Composable +private fun ActionTile( + title: String, + icon: ImageVector, + theme: LogsTheme, + showDivider: Boolean = true, + onClick: () -> Unit, +) { + Column { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = theme.text) + Spacer(Modifier.width(16.dp)) + Text(title, color = theme.text, fontSize = 20.sp, fontWeight = FontWeight.Medium) + } + if (showDivider) { + HorizontalDivider(color = theme.text.copy(alpha = 0.2f), thickness = 1.dp) + } + } +} + +@Composable +private fun TalkerMonitorPage( + logs: List, + theme: LogsTheme, + onBack: () -> Unit, + onPickKey: (String) -> Unit, +) { + val errors = logs.filter { it.key == "error" } + val warnings = logs.filter { it.key == "warning" } + val infos = logs.filter { it.key == "info" } + val debugs = logs.filter { it.key == "debug" || it.key == "verbose" } + Column( + Modifier + .fillMaxSize() + .background(theme.bg) + .statusBarsPadding(), + ) { + Row( + Modifier.fillMaxWidth().height(56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, tr("back"), tint = theme.text) + } + Text(tr("logs_monitor_title"), color = theme.text, fontSize = 20.sp) + } + if (errors.isNotEmpty()) { + MonitorCard( + title = tr("logs_error"), + subtitle = tr("logs_unhandled_errors", errors.size), + color = ColorError, + icon = Icons.Filled.ErrorOutline, + theme = theme, + onClick = { onPickKey("error") }, + ) + } + if (warnings.isNotEmpty()) { + MonitorCard( + title = tr("logs_warning"), + subtitle = tr("logs_warning_count", warnings.size), + color = ColorWarning, + icon = Icons.Filled.WarningAmber, + theme = theme, + onClick = { onPickKey("warning") }, + ) + } + if (infos.isNotEmpty()) { + MonitorCard( + title = tr("logs_info"), + subtitle = tr("logs_info_count", infos.size), + color = ColorInfo, + icon = Icons.Filled.Info, + theme = theme, + onClick = { onPickKey("info") }, + ) + } + if (debugs.isNotEmpty()) { + MonitorCard( + title = tr("logs_verbose_debug"), + subtitle = tr("logs_debug_count", debugs.size), + color = ColorDebug, + icon = Icons.Filled.Visibility, + theme = theme, + onClick = { onPickKey("debug") }, + ) + } + } +} + +@Composable +private fun MonitorCard( + title: String, + subtitle: String, + color: Color, + icon: ImageVector, + theme: LogsTheme, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .clip(RoundedCornerShape(12.dp)) + .background(theme.card) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = color, modifier = Modifier.size(28.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text(title, color = color, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text(subtitle, color = theme.text, fontSize = 13.sp) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/SplashScreen.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/SplashScreen.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt index 71b7cc8..a3cd6cf 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/screens/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/SplashScreen.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.screens +package com.zhushenwudi.lovelivemusicplayer.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,18 +22,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import kotlinx.coroutines.delay import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.AppContainer +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import com.zhushenwudi.lovelivemusicplayer.i18n.tr +import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage /** - * Splash flow: - * 1) Show default logo for 1s - * 2) Switch to a random **local cache** photo if any (no network wait) - * 3) Countdown / skip → home; network sync continues in background after leave + * 启动页流程: + * 1) 默认 Logo 显示 1 秒 + * 2) 若有本地缓存则随机切换(不等待网络) + * 3) 倒计时/跳过 → 主页;离开后后台继续网络同步 */ @OptIn(ExperimentalResourceApi::class) @Composable @@ -45,20 +46,20 @@ fun SplashScreen(onFinished: () -> Unit) { fun goHome() { if (finished) return finished = true - // Config + missing downloads must not block splash + // 配置与缺失下载不得阻塞启动页 AppContainer.splashPhoto.syncInBackground() onFinished() } LaunchedEffect(Unit) { - // 1s logo first + // 先显示 1 秒 Logo delay(1_000) count = 2 photoPath = runCatching { AppContainer.splashPhoto.pickCachedDisplayPath() }.getOrNull() - // Remaining countdown (2s → 0) then home + // 剩余倒计时(2 秒 → 0)后进入主页 while (count > 0) { delay(1_000) count -= 1 @@ -72,7 +73,7 @@ fun SplashScreen(onFinished: () -> Unit) { .background(Color.White), ) { val model = photoPath ?: Res.getUri("drawable/launch_background.png") - AsyncImage( + LlmpAsyncImage( model = model, contentDescription = null, contentScale = if (photoPath != null) ContentScale.Crop else ContentScale.Fit, @@ -93,7 +94,7 @@ fun SplashScreen(onFinished: () -> Unit) { .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "跳过 ${count}s", + text = tr("skip_n", count), color = Color.White, fontSize = 14.sp, ) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.kt new file mode 100644 index 0000000..6bea5ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.kt @@ -0,0 +1,15 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.scroll + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable + +/** + * iOS 状态栏点击 → 列表/网格滚到顶部(系统 `UIScrollView.scrollsToTop`)。 + * Android:此处为空操作;由 [com.zhushenwudi.lovelivemusicplayer.ui.scroll.StatusBarScrollToTopTap] 覆盖状态栏点击区域。 + */ +@Composable +expect fun ScrollToTopHandler(listState: LazyListState) + +@Composable +expect fun ScrollToTopHandler(gridState: LazyGridState) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/StatusBarScrollToTopTap.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/StatusBarScrollToTopTap.kt new file mode 100644 index 0000000..886b97b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/StatusBarScrollToTopTap.kt @@ -0,0 +1,73 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.scroll + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import com.zhushenwudi.lovelivemusicplayer.navigation.LibraryTab +import com.zhushenwudi.lovelivemusicplayer.ui.HomeUiState + +/** + * 状态栏内边距上的透明点击区域(Android / Compose 接收的点击)。 + * iOS 系统状态栏点击由 [com.zhushenwudi.lovelivemusicplayer.ui.scroll.ScrollToTopHandler] 处理。 + */ +@Composable +fun StatusBarScrollToTopTap( + height: Dp, + enabled: Boolean, + onScrollToTop: () -> Unit, + modifier: Modifier = Modifier, +) { + if (height <= 0.dp || !enabled) return + Box( + modifier + .fillMaxWidth() + .height(height) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onScrollToTop, + ), + ) +} + +/** 将当前曲库标签的列表/网格滚到首项。 */ +suspend fun HomeUiState.scrollCurrentTabToTop() { + when (LibraryTab.fromPage(pageIndex)) { + LibraryTab.SONGS -> songListState.animateScrollToItem(0) + LibraryTab.ALBUMS -> albumGridState.animateScrollToItem(0) + LibraryTab.ARTISTS -> artistListState.animateScrollToItem(0) + LibraryTab.LOVE -> loveListState.animateScrollToItem(0) + LibraryTab.MENUS -> menuListState.animateScrollToItem(0) + LibraryTab.RECENT -> recentListState.animateScrollToItem(0) + } +} + +/** 仅一个活跃处理器,使 iOS 只有一个 scrollsToTop UIScrollView。 */ +@Composable +fun LibraryScrollToTopHandler(tab: LibraryTab) { + when (tab) { + LibraryTab.SONGS -> ScrollToTopHandler(HomeUiState.songListState) + LibraryTab.ALBUMS -> ScrollToTopHandler(HomeUiState.albumGridState) + LibraryTab.ARTISTS -> ScrollToTopHandler(HomeUiState.artistListState) + LibraryTab.LOVE -> ScrollToTopHandler(HomeUiState.loveListState) + LibraryTab.MENUS -> ScrollToTopHandler(HomeUiState.menuListState) + LibraryTab.RECENT -> ScrollToTopHandler(HomeUiState.recentListState) + } +} + +@Composable +fun rememberStatusBarScrollToTopAction(): () -> Unit { + val scope = rememberCoroutineScope() + return remember(scope) { + { scope.launch { HomeUiState.scrollCurrentTabToTop() } } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt new file mode 100644 index 0000000..87874f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/DesignDensity.kt @@ -0,0 +1,105 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.theme + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.zhushenwudi.lovelivemusicplayer.Const + +/** + * 设计稿缩放(`Const.UI_WIDTH`×`Const.UI_HEIGHT`)。 + * 布局与字体按屏幕高度比例缩放密度。 + */ +val LocalDesignScale = staticCompositionLocalOf { 1f } + +/** [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideDesignDensity] 缩放前的设备密度(供底部栏使用)。 */ +val LocalUnscaledDensity = staticCompositionLocalOf { null } + +/** + * 按设计稿比例缩放 [androidx.compose.ui.platform.LocalDensity],使 dp/sp 与设计尺寸一致。 + * 在应用根节点调用一次;底部栏需保持设备密度时用 [com.zhushenwudi.lovelivemusicplayer.ui.theme.ProvideUnscaledDensity] 包裹。 + */ +@Composable +fun ProvideDesignDensity(content: @Composable () -> Unit) { + val base = LocalDensity.current + val containerPx = LocalWindowInfo.current.containerSize + val heightDp = containerPx.height / base.density + // 按高度比例缩放;不低于设计尺寸;平板上限 1.35。忽略系统字体缩放(对齐 Flutter textScaler = 1.0)。 + val scale = remember(heightDp) { + (heightDp / Const.UI_HEIGHT).coerceIn(1f, 1.35f) + } + val scaled = remember(base, scale) { + Density( + density = base.density * scale, + fontScale = 1f, + ) + } + CompositionLocalProvider( + LocalDensity provides scaled, + LocalUnscaledDensity provides base, + LocalDesignScale provides scale, + content = content, + ) +} + +/** 恢复设备密度,保持底部栏物理尺寸不变。 */ +@Composable +fun ProvideUnscaledDensity(content: @Composable () -> Unit) { + val unscaled = LocalUnscaledDensity.current ?: LocalDensity.current + CompositionLocalProvider(LocalDensity provides unscaled, content = content) +} + +/** + * 将设计稿/未缩放的 [androidx.compose.ui.unit.Dp] 转换为当前(已缩放)密度空间, + * 使迷你播放器、底部栏等未缩放组件占位正确。 + */ +@Composable +fun Dp.asUnscaledInScaledContext(): Dp { + val scale = LocalDesignScale.current + if (scale == 1f) return this + return (value / scale).dp +} + +/** 状态栏顶部内边距(dp,使用真实设备密度,忽略设计缩放)。 */ +@Composable +fun unscaledStatusBarsTop(): Dp { + val density = LocalUnscaledDensity.current ?: LocalDensity.current + return with(density) { WindowInsets.statusBars.getTop(this).toDp() } +} + +/** 导航栏底部内边距(dp,使用真实设备密度,忽略设计缩放)。 */ +@Composable +fun unscaledNavigationBarsBottom(): Dp { + val density = LocalUnscaledDensity.current ?: LocalDensity.current + return with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } +} + +/** + * Flutter ScreenUtil `.h` 等效字号:按屏幕高度缩放,且不受系统字体缩放影响。 + * 用于抽屉/设置等原项目 `15.h` / `12.h` 文案。 + */ +@Composable +fun hSp(value: Float): TextUnit { + val scale = LocalDesignScale.current + val unscaled = LocalUnscaledDensity.current ?: LocalDensity.current + val targetPx = with(unscaled) { (value * scale).dp.toPx() } + return with(LocalDensity.current) { targetPx.toSp() } +} + +/** Flutter ScreenUtil `.h` 等效尺寸。 */ +@Composable +fun hDp(value: Float): Dp { + val scale = LocalDesignScale.current + val unscaled = LocalUnscaledDensity.current ?: LocalDensity.current + val targetPx = with(unscaled) { (value * scale).dp.toPx() } + return with(LocalDensity.current) { targetPx.toDp() } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/HomeListChrome.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/HomeListChrome.kt new file mode 100644 index 0000000..efb834b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/theme/HomeListChrome.kt @@ -0,0 +1,25 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +/** 主页/曲库列表公共尺寸,与 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] WeSlide 及列表内边距对齐。 */ +object HomeListChrome { + /** [com.zhushenwudi.lovelivemusicplayer.ui.player.MiniPlayerBar]:6.dp 顶内边距 + 60.dp 栏高。 */ + val MiniPlayerHeight = 66.dp + + /** WeSlide 收起时迷你播放器与底部栏之间的间距。 */ + val MiniPlayerFooterGap = 8.dp + + /** [com.zhushenwudi.lovelivemusicplayer.ui.components.LlmpBottomBar] 分页行,使用设备密度,与底部导航内边距配对。 */ + val BottomBarContentHeight = 56.dp + + /** + * LazyColumn 底部 contentPadding,为 WeSlide body 上的迷你播放器留出空间(70 + 6)。 + * 不含底部栏高度;body 已在底部栏槽位上方结束。 + */ + val ListBottomInset = 70.dp + 6.dp +} + +/** 曲库标签列表底部内边距,由 [com.zhushenwudi.lovelivemusicplayer.ui.MainShell] 提供。 */ +val LocalHomeListBottomInset = staticCompositionLocalOf { HomeListChrome.ListBottomInset } diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlide.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlide.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlide.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlide.kt index b8513ec..1bd47f2 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlide.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlide.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.weslide +package com.zhushenwudi.lovelivemusicplayer.ui.weslide import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -43,21 +43,25 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlin.math.abs +import kotlin.math.roundToInt + +/** 收起时把面板/顶栏移出命中区域,避免挡住迷你栏,且不触发重新测量。 */ +private val HiddenPanelOffset = IntOffset(0, 100_000) /** - * Compose Multiplatform port of Flutter `we_slide`, tuned to LoveLive home usage: - * body + panelHeader(mini) + panel(full player) + footer(bottom bar), - * with parallax / transformScale / hideFooter / panelHeader fade / isUpSlide / blur. + * 主页滑动面板:body + panelHeader(迷你播放器)+ panel(全屏播放器)+ footer(底部栏), + * 支持视差、缩放、隐藏底栏/顶栏、上滑模式、模糊等。 * - * Performance: - * - progress is FloatState, updated synchronously on drag (no per-frame coroutine) - * - progress is read in offset/graphicsLayer/layout so body/panel skip drag recomposition - * - full player stays mounted after first open (alpha/layout only) to avoid mid-gesture hitch - * - body height stays fixed during the sheet animation + * 性能优化: + * - progress 使用 FloatState,拖拽时同步更新(无逐帧协程) + * - progress 仅在 offset/graphicsLayer 中读取,避免逐帧测量全屏播放器 + * - 首页首帧后再预挂载全屏播放器,展开时只改 alpha/位移 + * - 模糊半径不随进度插值(半径变化会每帧重做高斯模糊) + * - 面板动画期间 body 高度固定 */ @Composable fun WeSlide( @@ -77,18 +81,17 @@ fun WeSlide( transformScaleEnd: Float = 0.85f, overlayColor: Color = Color.Black, backgroundColor: Color = Color.Transparent, - /** Flutter we_slide [blur] — frosted glass over body while panel opens. */ + /** 面板打开时对 body 施加毛玻璃模糊。 */ blur: Boolean = false, /** - * Blur radius at full open. Compose [BlurEffect] reads weaker than Flutter - * ImageFilter σ=5 for the same numeric value — use a larger Dp here. + * 完全打开时的模糊半径。Compose [androidx.compose.ui.graphics.BlurEffect] 同等数值下效果较弱,需使用较大 Dp。 */ blurSigma: Dp = 5.dp, - /** Frost tint over the blurred body (Flutter blurColor). */ + /** 模糊 body 上的霜化色调。 */ blurColor: Color = Color.Black, /** - * Opacity of [blurColor] at full open. - * Flutter hardcodes 0.1; raise this when text under the frost stays readable. + * 完全打开时 `blurColor` 的不透明度。 + * 默认 0.1;霜化下文字仍可读时可调高。 */ blurColorOpacity: Float = 0.1f, hideFooter: Boolean = true, @@ -108,8 +111,12 @@ fun WeSlide( val progressAnim = remember { Animatable(if (controller.isOpened) 1f else 0f) } val footerAnim = remember { Animatable(if (footerController.isOpened) 1f else 0f) } var animJob by remember { mutableStateOf(null) } - // Mount full player once, then only fade — avoids hitch when crossing 1/9 during drag. + // 首帧后再预挂载,避免拖累首页;之后保持挂载,展开不再重新组合/测量。 var panelMounted by remember { mutableStateOf(controller.isOpened) } + LaunchedEffect(Unit) { + yield() + panelMounted = true + } fun headerAlpha(p: Float): Float = when { panelHeader == null -> 0f @@ -139,7 +146,7 @@ fun WeSlide( fun panelDragBlocked(): Boolean = controller.interactionLocked || - (!controller.dragEnabled && (controller.isOpened || progress > 0.5f)) + (!controller.dragEnabled && (controller.isOpened || progress > 0.5f)) fun applyDragDelta(dragAmountY: Float, panelMaxPx: Float, ignoreDragEnabled: Boolean = false) { if (!ignoreDragEnabled && panelDragBlocked()) return @@ -173,8 +180,6 @@ fun WeSlide( if (controller.isOpened) { footerController.hide() panelMounted = true - } else { - footerController.show() } val target = if (controller.isOpened) 1f else 0f if (abs(progress - target) < 0.001f) { @@ -237,8 +242,9 @@ fun WeSlide( } } - // Fixed body height — avoid list relayout every frame while footer hides. - val bodyHeight = if (footer != null) { + // 底栏完全收起后让 body 铺满(专辑/歌手/歌单详情),避免底部多出一块空白。 + // 用 isOpened 而不是动画进度,避免收起过程中每帧改列表高度。 + val bodyHeight = if (footer != null && footerController.isOpened) { (maxH - footerHeight).coerceAtLeast(0.dp) } else { maxH @@ -283,7 +289,10 @@ fun WeSlide( return Velocity.Zero } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { if (panelDragBlocked()) return Velocity.Zero if (progress >= 0.999f && available.y <= 0f) { return Velocity.Zero @@ -299,7 +308,7 @@ fun WeSlide( } } - // 1) Body — progress only in offset/graphicsLayer (skip body recompose on drag) + // 1) Body — progress 仅在 offset/graphicsLayer 中读取(拖拽时跳过 body 重组) Box( modifier = Modifier .align(Alignment.TopStart) @@ -321,10 +330,9 @@ fun WeSlide( scaleY = scale } transformOrigin = TransformOrigin(0.5f, 1f) - // Flutter BackdropFilter(sigma = blurSigma * progress) over body - renderEffect = if (blur && progress > 0.001f) { - val radius = blurSigmaPx * progress - BlurEffect(radius, radius, TileMode.Clamp) + // 半径插值会每帧重做高斯模糊;展开过程用 overlay 过渡,接近全开再套固定模糊。 + renderEffect = if (blur && progress >= 0.99f) { + BlurEffect(blurSigmaPx, blurSigmaPx, TileMode.Clamp) } else { null } @@ -334,7 +342,7 @@ fun WeSlide( body() } - // 1b) Frost tint over blurred body + // 1b) 模糊 body 上的霜化色调 if (blur && blurColorOpacity > 0f && blurColor != Color.Transparent) { Box( Modifier @@ -356,7 +364,7 @@ fun WeSlide( ) } - // 2) Overlay — zero-size when collapsed so it doesn't steal touches + // 2) 遮罩 — 收起时零尺寸,不拦截触摸 if (overlay && overlayOpacity > 0f) { Box( Modifier @@ -376,7 +384,7 @@ fun WeSlide( ) } - // 3) Dismiss tap target — height measured in layout phase + // 3) 关闭点击区域 — 高度在 layout 阶段测量 if (isDismissible) { Box( Modifier @@ -401,7 +409,7 @@ fun WeSlide( ) } - // 4) Panel + header + // 4) 面板 + 顶栏 Box( modifier = Modifier .align(Alignment.BottomCenter) @@ -414,7 +422,12 @@ fun WeSlide( ) } .nestedScroll(panelNestedScroll) - .pointerInput(isUpSlide, panelMaxPx, controller.dragEnabled, controller.interactionLocked) { + .pointerInput( + isUpSlide, + panelMaxPx, + controller.dragEnabled, + controller.interactionLocked + ) { if (controller.interactionLocked || !controller.dragEnabled) return@pointerInput var lastDrag = 0f detectVerticalDragGestures( @@ -440,19 +453,15 @@ fun WeSlide( }, ) { if (panelMounted) { - // Collapse hit-test when invisible so mini header stays clickable. + // 始终按全尺寸测量;不可见时只位移+改 alpha,避免展开过程每帧测量播放器。 Box( Modifier .fillMaxSize() - .layout { measurable, constraints -> - val show = panelContentAlpha(progress) > 0.01f - if (!show) { - layout(0, 0) {} + .offset { + if (panelContentAlpha(progress) > 0.01f) { + IntOffset.Zero } else { - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } + HiddenPanelOffset } } .graphicsLayer { @@ -471,26 +480,22 @@ fun WeSlide( Box( Modifier .fillMaxWidth() - .layout { measurable, constraints -> - val show = headerAlpha(progress) > 0.01f - if (!show) { - layout(0, 0) {} + .align(Alignment.TopCenter) + .offset { + if (headerAlpha(progress) > 0.01f) { + IntOffset.Zero } else { - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } + HiddenPanelOffset } } - .graphicsLayer { alpha = headerAlpha(progress) } - .align(Alignment.TopCenter), + .graphicsLayer { alpha = headerAlpha(progress) }, ) { panelHeader() } } } - // 5) Footer + // 5) 底部栏 — fillMaxSize 使未缩放子项获得有界高度。 if (footer != null) { Box( modifier = Modifier @@ -504,7 +509,9 @@ fun WeSlide( ) }, ) { - footer() + Box(Modifier.fillMaxSize()) { + footer() + } } } } diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlideController.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlideController.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlideController.kt rename to composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlideController.kt index ede1f4e..669cd9e 100644 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/weslide/WeSlideController.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/weslide/WeSlideController.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.ui.weslide +package com.zhushenwudi.lovelivemusicplayer.ui.weslide import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -6,27 +6,25 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -/** - * Mirrors Flutter `we_slide` [WeSlideController]. - */ +/** WeSlide 面板开关与手势控制器。 */ class WeSlideController(initial: Boolean = false) { var isOpened by mutableStateOf(initial) private set /** - * When false, the open panel ignores vertical drag / nested-scroll dismiss - * (used while full-player lyric list is showing). + * 为 false 时,已打开面板忽略垂直拖拽/嵌套滚动关闭 + * (全屏播放器歌词列表显示时使用)。 */ var dragEnabled by mutableStateOf(true) /** - * Hard lock (batch edit): blocks all WeSlide drag / dismiss regardless of [dragEnabled]. + * 硬锁定(批量编辑):无论 `dragEnabled` 如何,禁止所有 WeSlide 拖拽与关闭。 */ var interactionLocked by mutableStateOf(false) /** - * Lyric-page chrome can drive panel drag without enabling list nested-scroll dismiss. - * Wired by [WeSlide] while composed. + * 歌词页顶栏可驱动面板拖拽,但不启用列表嵌套滚动关闭。 + * 由 [com.zhushenwudi.lovelivemusicplayer.ui.weslide.WeSlide] 组合时注入。 */ var onChromeVerticalDrag: ((dragAmountY: Float) -> Unit)? = null internal set diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/widget/WidgetCoverCache.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/widget/WidgetCoverCache.kt new file mode 100644 index 0000000..0d907ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/widget/WidgetCoverCache.kt @@ -0,0 +1,88 @@ +package com.zhushenwudi.lovelivemusicplayer.widget + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.util.PathResolver + +/** + * 小组件封面本地缓存:HTTP 封面只下载一次,按 [Music.musicId] 落盘。 + * 避免歌词进度同步时反复调用 [PathResolver.coverDisplayUri]。 + */ +class WidgetCoverCache( + private val fileSystem: FileSystem, + private val pathResolver: PathResolver, + private val ossApi: OssApi, + private val scope: CoroutineScope, + private val onCoverReady: () -> Unit, +) { + private val mutex = Mutex() + private var memoryMusicId: String? = null + private var memoryPath: String? = null + private val inFlight = mutableSetOf() + + private val cacheDir: String + get() = fileSystem.filesRoot.trimEnd('/', '\\') + "/widget_covers/" + + /** 返回可用于小组件的本地绝对路径;HTTP 未缓存完成时返回 null。 */ + fun resolveForWidget(music: Music): String? { + val musicId = music.musicId ?: return null + + memoryMusicId?.let { id -> + if (id == musicId && memoryPath?.let(fileSystem::exists) == true) { + return memoryPath + } + } + + pathResolver.absoluteCoverPath(music) + ?.takeIf(fileSystem::exists) + ?.let { path -> + remember(musicId, path) + return path + } + + val cached = cacheFilePath(musicId) + if (fileSystem.exists(cached)) { + remember(musicId, cached) + return cached + } + + scheduleDownload(music) + return null + } + + private fun scheduleDownload(music: Music) { + val musicId = music.musicId ?: return + scope.launch { + mutex.withLock { + if (musicId in inFlight) return@launch + inFlight += musicId + } + try { + val url = pathResolver.coverDisplayUri(music) + ?.takeIf { it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true) } + ?: return@launch + val bytes = runCatching { ossApi.downloadBytes(url) }.getOrNull() ?: return@launch + fileSystem.ensureDir(cacheDir) + val dest = cacheFilePath(musicId) + if (fileSystem.writeBytes(dest, bytes)) { + remember(musicId, dest) + onCoverReady() + } + } finally { + mutex.withLock { inFlight.remove(musicId) } + } + } + } + + private fun remember(musicId: String, path: String) { + memoryMusicId = musicId + memoryPath = path + } + + private fun cacheFilePath(musicId: String): String = cacheDir + musicId + ".cover" +} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/App.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/App.kt deleted file mode 100644 index 5d973e1..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/App.kt +++ /dev/null @@ -1,194 +0,0 @@ -package top.zhushenwudi.llmp - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.Modifier -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import top.zhushenwudi.llmp.navigation.Routes -import top.zhushenwudi.llmp.navigation.llmpSlideFadeEnter -import top.zhushenwudi.llmp.navigation.llmpSlideFadeExit -import top.zhushenwudi.llmp.navigation.llmpSlideFadePopEnter -import top.zhushenwudi.llmp.navigation.llmpSlideFadePopExit -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LlmpTheme -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.MainShell -import top.zhushenwudi.llmp.ui.icons.InstallCoilSvg -import top.zhushenwudi.llmp.ui.screens.DailyScreen -import top.zhushenwudi.llmp.ui.screens.DataSyncScreen -import top.zhushenwudi.llmp.ui.screens.DriveModeScreen -import top.zhushenwudi.llmp.ui.screens.LogsScreen -import top.zhushenwudi.llmp.ui.screens.MoegirlScreen -import top.zhushenwudi.llmp.ui.screens.PermissionsScreen -import top.zhushenwudi.llmp.ui.screens.ScanScreen -import top.zhushenwudi.llmp.ui.screens.SdScreen -import top.zhushenwudi.llmp.ui.screens.SettingsScreen -import top.zhushenwudi.llmp.ui.screens.SplashScreen -import top.zhushenwudi.llmp.ui.screens.TachieScreen -import top.zhushenwudi.llmp.ui.screens.TransferScreen -import top.zhushenwudi.llmp.ui.theme.LocalBgPhoto - -@Composable -fun App() { - val settings = AppContainer.settings - var followSystem by remember { mutableStateOf(settings.followSystemTheme) } - var darkPref by remember { mutableStateOf(settings.isDark) } - var bgPhoto by remember { - mutableStateOf( - if (settings.enableBackground) settings.backgroundPhoto else "", - ) - } - val systemDark = isSystemInDarkTheme() - val darkTheme = if (followSystem) systemDark else darkPref - // Flutter AppPage: hasAIPic ? /splash : / - val startDestination = if (settings.aiPicture) Routes.SPLASH else Routes.MAIN - - LlmpTheme(darkTheme = darkTheme) { - InstallCoilSvg() - val navController = rememberNavController() - val dark = LocalDarkTheme.current - val navBg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary - CompositionLocalProvider(LocalBgPhoto provides bgPhoto) { - Box( - Modifier - .fillMaxSize() - .background(navBg), - ) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier.fillMaxSize().background(navBg), - enterTransition = { llmpSlideFadeEnter() }, - exitTransition = { llmpSlideFadeExit() }, - popEnterTransition = { llmpSlideFadePopEnter() }, - popExitTransition = { llmpSlideFadePopExit() }, - ) { - composable( - Routes.SPLASH, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, - ) { - SplashScreen( - onFinished = { - navController.navigate(Routes.MAIN) { - popUpTo(Routes.SPLASH) { inclusive = true } - launchSingleTop = true - } - }, - ) - } - composable(Routes.MAIN) { - MainShell( - onOpenSettings = { navController.navigate(Routes.SETTINGS) }, - onOpenRoute = { route -> navController.navigate(route) }, - bgPhoto = bgPhoto, - onBgPhotoChange = { bgPhoto = it }, - ) - } - composable( - Routes.PLAYER, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, - ) { - LaunchedEffect(Unit) { - val popped = navController.popBackStack(Routes.MAIN, inclusive = false) - if (!popped) { - navController.navigate(Routes.MAIN) { - launchSingleTop = true - } - } - AppContainer.weSlide.show() - } - } - composable(Routes.SETTINGS) { - SettingsScreen( - darkTheme = darkPref, - followSystem = followSystem, - onFollowSystemChange = { - followSystem = it - settings.followSystemTheme = it - }, - onDarkThemeChange = { - darkPref = it - settings.isDark = it - followSystem = false - settings.followSystemTheme = false - }, - onBack = { navController.popBackStack() }, - onOpenSd = { navController.navigate(Routes.SD) }, - onOpenLogs = { navController.navigate(Routes.LOGS) }, - onOpenPermissions = { navController.navigate(Routes.PERMISSIONS) }, - onBgPhotoChange = { path -> - bgPhoto = path - settings.backgroundPhoto = path - }, - ) - } - composable(Routes.SCAN) { - val previous = navController.previousBackStackEntry?.destination?.route - ScanScreen( - onBack = { navController.popBackStack() }, - onConnectedHost = { host -> - when (previous) { - Routes.DATA_SYNC -> AppContainer.dataSync.connect(host) - else -> AppContainer.musicTransfer.connect(host) - } - navController.popBackStack() - }, - ) - } - composable(Routes.TRANSFER) { - TransferScreen( - onBack = { navController.popBackStack() }, - onOpenScan = { navController.navigate(Routes.SCAN) }, - ) - } - composable(Routes.DATA_SYNC) { - DataSyncScreen( - onBack = { navController.popBackStack() }, - onOpenScan = { navController.navigate(Routes.SCAN) }, - ) - } - composable(Routes.DRIVE_MODE) { - DriveModeScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.SD) { - SdScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.PERMISSIONS) { - PermissionsScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.MOEGIRL) { - MoegirlScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.TACHIE) { - TachieScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.DAILY) { - DailyScreen(onBack = { navController.popBackStack() }) - } - composable(Routes.LOGS) { - LogsScreen(onBack = { navController.popBackStack() }) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/AppContainer.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/AppContainer.kt deleted file mode 100644 index 25c4599..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/AppContainer.kt +++ /dev/null @@ -1,359 +0,0 @@ -package top.zhushenwudi.llmp - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import top.zhushenwudi.llmp.data.DataImportService -import top.zhushenwudi.llmp.db.LibraryRepository -import top.zhushenwudi.llmp.db.LlmpDatabase -import top.zhushenwudi.llmp.db.MusicRepository -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.lyric.LoadedLyric -import top.zhushenwudi.llmp.lyric.LrcParser -import top.zhushenwudi.llmp.lyric.LyricService -import top.zhushenwudi.llmp.network.OssApi -import top.zhushenwudi.llmp.network.WsClient -import top.zhushenwudi.llmp.network.createHttpClient -import top.zhushenwudi.llmp.platform.CarPlayCatalog -import top.zhushenwudi.llmp.platform.FileSystem -import top.zhushenwudi.llmp.platform.PlatformServices -import top.zhushenwudi.llmp.platform.createFileSystem -import top.zhushenwudi.llmp.player.PlayerController -import top.zhushenwudi.llmp.player.SleepTimer -import top.zhushenwudi.llmp.settings.AppSettings -import top.zhushenwudi.llmp.splash.SplashPhotoService -import top.zhushenwudi.llmp.transfer.DataSyncService -import top.zhushenwudi.llmp.transfer.MusicTransferService -import top.zhushenwudi.llmp.update.UpdateChecker -import top.zhushenwudi.llmp.ui.weslide.WeSlideController -import top.zhushenwudi.llmp.util.PathResolver - -object AppContainer { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var lyricJob: Job? = null - private var desktopLyricJob: Job? = null - private var playQueueJob: Job? = null - private var historyJob: Job? = null - private val _playingLyric = MutableStateFlow(LoadedLyric()) - private val _historyRevision = MutableStateFlow(0L) - private val _loveRevision = MutableStateFlow(0L) - - /** Flutter LyricLogic.fullLrc — refreshed on each track change. */ - val playingLyric: StateFlow = _playingLyric.asStateFlow() - - /** Bumps when History is written (Flutter refreshMusicTimestamp → recentList). */ - val historyRevision: StateFlow = _historyRevision.asStateFlow() - - /** Bumps when Love changes (Flutter toggleLove → findAllLoveListByGroup). */ - val loveRevision: StateFlow = _loveRevision.asStateFlow() - - /** Home WeSlide panel controller (Flutter GlobalLogic.mobileWeSlideController). */ - val weSlide = WeSlideController() - val weSlideFooter = WeSlideController(initial = true) - - /** Flutter GlobalLogic.currentGroup — survives MainShell leave/recompose. */ - var currentGroup: GroupKey = GroupKey.GROUP_ALL - - lateinit var settings: AppSettings - private set - lateinit var database: LlmpDatabase - private set - lateinit var musicRepository: MusicRepository - private set - lateinit var library: LibraryRepository - private set - lateinit var player: PlayerController - private set - lateinit var fileSystem: FileSystem - private set - lateinit var pathResolver: PathResolver - private set - lateinit var ossApi: OssApi - private set - lateinit var dataImport: DataImportService - private set - lateinit var lyricService: LyricService - private set - lateinit var musicTransfer: MusicTransferService - private set - lateinit var dataSync: DataSyncService - private set - lateinit var updateChecker: UpdateChecker - private set - lateinit var sleepTimer: SleepTimer - private set - lateinit var splashPhoto: SplashPhotoService - private set - - val platform get() = PlatformServices - val carPlayCatalog: CarPlayCatalog get() = PlatformServices.carPlayCatalog - - val isInitialized: Boolean - get() = ::settings.isInitialized - - fun init( - settings: AppSettings, - database: LlmpDatabase, - player: PlayerController, - fileSystem: FileSystem = createFileSystem(), - ) { - this.settings = settings - this.database = database - this.musicRepository = MusicRepository(database) - this.library = LibraryRepository(database) - this.player = player - this.fileSystem = fileSystem - this.pathResolver = PathResolver(fileSystem, settings) - // Keep Music.musicPath relative; resolve file/http URI only for the engine. - player.resolvePlaybackUri = { pathResolver.playbackUri(it) } - // Android notification artwork (file:// / https); iOS Now Playing prefers absolute path. - player.resolveCoverUri = { music -> - pathResolver.absoluteCoverPath(music) - ?.takeIf { fileSystem.exists(it) } - ?: pathResolver.coverDisplayUri(music) - } - - val http = createHttpClient() - this.ossApi = OssApi(http) - this.dataImport = DataImportService(ossApi, library, settings, pathResolver) - this.lyricService = LyricService(ossApi, library) - bindLyricLoader() - bindDesktopLyric() - this.musicTransfer = MusicTransferService( - WsClient(http), - ossApi, - library, - fileSystem, - pathResolver, - scope, - ) - this.dataSync = DataSyncService(WsClient(http), library, scope) - this.updateChecker = UpdateChecker(ossApi) - this.sleepTimer = SleepTimer(scope) { - player.pause() - } - this.splashPhoto = SplashPhotoService(fileSystem, ossApi, scope) - - // Bind CarPlay catalog play → player; refresh from library snapshot. - PlatformServices.carPlayCatalog.onPlay = { musicId, queue -> - val items = queue.mapNotNull { library.getMusicById(it.musicId) } - val idx = items.indexOfFirst { it.musicId == musicId }.coerceAtLeast(0) - if (items.isNotEmpty()) { - player.setQueue(items, idx) - player.play() - persistPlayQueue() - } - } - refreshCarPlayCatalog() - PlatformServices.desktopLyric.enabled = settings.enableDesktopLyric - PlatformServices.carPlay.bind(PlatformServices.carPlayCatalog) - - // Flutter DBLogic.findAllPlayListMusics(needPlay: false) - restoreSavedPlayQueue(needPlay = false) - bindPlayQueuePersistence() - bindHistoryOnTrackChange() - } - - /** Flutter updatePlayingList / persistencePlayList (non-empty only). */ - fun persistPlayQueue( - queue: List = player.state.value.queue, - playingId: String? = player.state.value.current?.musicId, - ) { - if (!isInitialized) return - if (queue.isEmpty()) return - library.savePlayList(queue, playingId) - } - - /** Flutter PlayerLogic.removeMusic + DB update. */ - fun removeFromPlayQueue(index: Int) { - player.removeAt(index) - val queue = player.state.value.queue - if (queue.isEmpty()) library.clearPlayList() else persistPlayQueue() - } - - /** Flutter PlayerLogic.removeAllMusics. */ - fun clearPlayQueue() { - player.clearQueue() - library.clearPlayList() - _playingLyric.value = LoadedLyric() - } - - /** - * Flutter findAllPlayListMusics — restore queue into player without auto-play by default. - */ - fun restoreSavedPlayQueue(needPlay: Boolean = false) { - if (!isInitialized) return - val rows = library.loadPlayListRows() - if (rows.isEmpty()) return - val ids = rows.map { it.musicId } - val playingId = rows.firstOrNull { it.isPlaying != 0L }?.musicId - val musics = library.getMusicByIdsPreservingOrder(ids, settings.enableHttp) - if (musics.isEmpty()) return - val index = playingId - ?.let { pid -> musics.indexOfFirst { it.musicId == pid } } - ?.takeIf { it >= 0 } - ?: 0 - player.setQueue(musics, index) - player.setLoopMode(settings.loopMode) - if (needPlay) player.play() - } - - /** Persist on queue / current-track change (Flutter currentIndexStream + mutation sites). */ - private fun bindPlayQueuePersistence() { - playQueueJob?.cancel() - playQueueJob = scope.launch { - player.state - .map { st -> st.queue.map { it.musicId } to st.current?.musicId } - .distinctUntilChanged() - .collect { - persistPlayQueue() - } - } - } - - /** - * Flutter [DBLogic.refreshMusicTimestamp]: upsert History + notify recent UI. - * Call on track change and when replaying the same song (id unchanged). - */ - fun recordPlayingHistory(musicId: String?) { - if (!isInitialized || musicId.isNullOrBlank()) return - library.addHistory(musicId) - _historyRevision.value = _historyRevision.value + 1 - } - - /** - * Flutter [PlayerLogic.toggleLove]: write Love + refresh loveList observers. - * @return new loved state - */ - fun toggleLove(musicId: String?): Boolean { - if (!isInitialized || musicId.isNullOrBlank()) return false - val loved = library.toggleLove(musicId) - _loveRevision.value = _loveRevision.value + 1 - refreshCarPlayCatalog() - return loved - } - - /** - * Flutter `currentIndexStream` → refreshMusicTimestamp: - * every actual track change updates History so「最近播放」follows play order. - */ - private fun bindHistoryOnTrackChange() { - historyJob?.cancel() - historyJob = scope.launch { - player.state - .map { it.current?.musicId } - .distinctUntilChanged() - .collect { musicId -> - recordPlayingHistory(musicId) - } - } - } - - fun refreshCarPlayCatalog() { - if (!isInitialized) return - PlatformServices.carPlayCatalog.setLibrary( - allMusic = library.getAllMusic(), - albums = library.getAllAlbums(), - love = library.getLovedMusic(), - menuList = library.getAllMenus(), - ) - PlatformServices.carPlay.refreshCatalog() - } - - /** Flutter `LyricLogic.getLrc(false)` on playingMusic change. */ - private fun bindLyricLoader() { - lyricJob?.cancel() - lyricJob = scope.launch { - player.state - .map { it.current?.musicId } - .distinctUntilChanged() - .collect { musicId -> - if (musicId.isNullOrBlank()) { - _playingLyric.value = LoadedLyric() - return@collect - } - val music = library.getMusicById(musicId) - ?: player.state.value.current - ?: return@collect - _playingLyric.value = runCatching { - lyricService.getLrc(music, forceRefresh = false) - }.getOrDefault(LoadedLyric()) - } - } - } - - /** - * Flutter [LyricLogic.postDesktopAndWidgetLyric]: keep floating lyric in sync - * from player position + loaded LRC, even when FullPlayerScreen is not composed. - */ - private fun bindDesktopLyric() { - desktopLyricJob?.cancel() - desktopLyricJob = scope.launch { - combine( - player.state.map { it.positionMs to it.current?.musicId }.distinctUntilChanged(), - _playingLyric, - ) { (positionMs, _), lyric -> - positionMs to lyric - }.collect { (positionMs, lyric) -> - pushDesktopLyric(positionMs, lyric) - } - } - } - - private fun pushDesktopLyric(positionMs: Long, lyric: LoadedLyric) { - val lrcType = lyricService.lrcType - val currentIdx = LrcParser.currentIndex(lyric.main, positionMs) - val line1 = lyric.main.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(0)?.text - val line2 = when { - lrcType > 0 -> lyric.translation.getOrNull(currentIdx)?.text - ?: lyric.main.getOrNull(currentIdx + 1)?.text - else -> lyric.main.getOrNull(currentIdx + 1)?.text - } - val highlight = when { - lyric.main.isEmpty() -> -1 - currentIdx < 0 -> -1 - else -> 1 - } - val displayLine1 = if (highlight < 0) (line1 ?: "暂无歌词") else line1 - PlatformServices.desktopLyric.updateLines(displayLine1, line2, highlight) - } - - /** Flutter getLrc(forceRefresh) — network + DB cache. */ - suspend fun reloadPlayingLyric(forceRefresh: Boolean = false): LoadedLyric { - val musicId = player.state.value.current?.musicId ?: return LoadedLyric().also { - _playingLyric.value = it - } - val music = library.getMusicById(musicId) ?: player.state.value.current - ?: return LoadedLyric().also { _playingLyric.value = it } - val loaded = runCatching { - lyricService.getLrc(music, forceRefresh = forceRefresh) - }.getOrDefault(LoadedLyric()) - _playingLyric.value = loaded - return loaded - } - - /** - * Fire-and-forget reload on app [scope]. - * Safe to call from dialogs that dismiss immediately (Compose UI scopes get cancelled). - */ - fun requestReloadPlayingLyric(forceRefresh: Boolean = false) { - scope.launch { reloadPlayingLyric(forceRefresh = forceRefresh) } - } - - /** Flutter reloadLyricToController — re-parse cached fullLrc only. */ - fun applyLyricDisplayType(): LoadedLyric { - val loaded = lyricService.toLoaded() - _playingLyric.value = loaded - return loaded - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AppDrawer.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AppDrawer.kt deleted file mode 100644 index bab01be..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/AppDrawer.kt +++ /dev/null @@ -1,290 +0,0 @@ -package top.zhushenwudi.llmp.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min -import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage -import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.generated.resources.Res -import top.zhushenwudi.llmp.navigation.Routes -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.theme.neuShadowColor -import top.zhushenwudi.llmp.ui.icons.SvgAsset - -data class DrawerMenuItem( - val title: String, - val route: String?, - val action: DrawerAction = DrawerAction.Navigate, - val icon: SvgAsset, -) - -enum class DrawerAction { - Navigate, - UpdateData, - CheckUpdate, - Share, -} - -private data class GroupLogo( - val key: GroupKey, - val asset: SvgAsset, -) - -@OptIn(ExperimentalResourceApi::class) -@Composable -fun AppDrawer( - onClose: () -> Unit, - onOpenRoute: (String) -> Unit, - onOpenSettings: () -> Unit, - onUpdateData: () -> Unit = {}, - onCheckUpdate: () -> Unit = {}, - onShare: () -> Unit = {}, - currentGroup: GroupKey = GroupKey.GROUP_ALL, - onGroupSelect: (GroupKey) -> Unit = {}, - version: String = "0.2.0", - modifier: Modifier = Modifier, -) { - val dark = LocalDarkTheme.current - val bg = if (dark) ColorMs.NightPrimary else ColorMs.White - val groups = listOf( - GroupLogo(GroupKey.GROUP_ALL, SvgAsset("drawable/drawer_logo_lovelive.svg")) to - GroupLogo(GroupKey.GROUP_US, SvgAsset("drawable/drawer_logo_us.svg")), - GroupLogo(GroupKey.GROUP_AQOURS, SvgAsset("drawable/drawer_logo_aqours.svg")) to - GroupLogo(GroupKey.GROUP_NIJIGASAKI, SvgAsset("drawable/drawer_logo_nijigasaki.svg")), - GroupLogo(GroupKey.GROUP_LIELLA, SvgAsset("drawable/drawer_logo_liella.svg")) to - GroupLogo(GroupKey.GROUP_HASUNOSORA, SvgAsset("drawable/drawer_logo_hasunosora.svg")), - GroupLogo( - GroupKey.GROUP_YOHANE, - SvgAsset( - if (dark) "drawable/drawer_logo_yohane_night.svg" - else "drawable/drawer_logo_yohane_day.svg", - ), - ) to GroupLogo(GroupKey.GROUP_MUSICAL, SvgAsset("drawable/drawer_logo_musical.svg")), - GroupLogo(GroupKey.GROUP_BLUEBIRD, SvgAsset("drawable/drawer_logo_bluebird.svg")) to - GroupLogo(GroupKey.GROUP_COMBINE, SvgAsset("drawable/drawer_logo_allstars.svg")), - ) - val menus = listOf( - DrawerMenuItem("每日资讯", Routes.DAILY, icon = SvgAsset("drawable/drawer_drawer_calendar.svg")), - DrawerMenuItem("歌曲快传", Routes.TRANSFER, icon = SvgAsset("drawable/drawer_drawer_quick_trans.svg")), - DrawerMenuItem("数据同步", Routes.DATA_SYNC, icon = SvgAsset("drawable/drawer_drawer_data_sync.svg")), - DrawerMenuItem("数据更新", null, DrawerAction.UpdateData, SvgAsset("drawable/drawer_drawer_data_download.svg")), - DrawerMenuItem("驾驶模式", Routes.DRIVE_MODE, icon = SvgAsset("drawable/drawer_drawer_car.svg")), - DrawerMenuItem("版本升级", null, DrawerAction.CheckUpdate, SvgAsset("drawable/drawer_drawer_update.svg")), - DrawerMenuItem("系统设置", Routes.SETTINGS, DrawerAction.Navigate, SvgAsset("drawable/drawer_drawer_setting.svg")), - DrawerMenuItem("应用分享", null, DrawerAction.Share, SvgAsset("drawable/drawer_drawer_share.svg")), - ) - // Flutter Const.groupList.getLogo(currentGroup) - val topLogoSize = if (currentGroup.isDefaultGroupLogo()) 165.dp else 130.dp - - BoxWithConstraints(modifier = modifier.fillMaxHeight()) { - // Flutter: min(0.35 * screenHeight, screenWidth) - val drawerWidth = min(maxHeight * 0.35f, maxWidth) - - Column( - modifier = Modifier - .fillMaxHeight() - .width(drawerWidth) - .background(bg) - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier - .height(130.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - // Flutter neumorphicButton(..., radius: diameter) → circular clip - GroupLogoImage( - group = currentGroup, - modifier = Modifier.size(topLogoSize), - ) - } - Spacer(Modifier.height(5.dp)) - Text( - text = "LoveLiveMusicPlayer", - color = if (dark) ColorMs.EDF5FF else ColorMs.Text333, - fontSize = 17.3.sp, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(10.dp)) - groups.forEach { (left, right) -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - key(left.asset.path) { - GroupButton( - group = left, - selected = currentGroup == left.key, - onClick = { onGroupSelect(left.key) }, - ) - } - key(right.asset.path) { - GroupButton( - group = right, - selected = currentGroup == right.key, - onClick = { onGroupSelect(right.key) }, - ) - } - } - Spacer(Modifier.height(12.dp)) - } - Column( - Modifier - .weight(1f) - .width(250.dp) - .padding(horizontal = 8.dp) - .shadow( - elevation = 6.dp, - shape = RoundedCornerShape(8.dp), - ambientColor = neuShadowColor(dark), - spotColor = neuShadowColor(dark), - ) - .clip(RoundedCornerShape(8.dp)) - .background(bg) - .padding(horizontal = 10.dp, vertical = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - menus.forEach { item -> - DrawerFunctionRow( - item = item, - onClick = { - onClose() - when (item.action) { - DrawerAction.UpdateData -> onUpdateData() - DrawerAction.CheckUpdate -> onCheckUpdate() - DrawerAction.Share -> onShare() - DrawerAction.Navigate -> { - when (item.route) { - Routes.SETTINGS -> onOpenSettings() - null -> Unit - else -> onOpenRoute(item.route) - } - } - } - }, - ) - } - } - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .height(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "Ver.$version", - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 12.sp, - ) - } - } - } -} - -@OptIn(ExperimentalResourceApi::class) -@Composable -private fun GroupButton( - group: GroupLogo, - selected: Boolean, - onClick: () -> Unit, -) { - // Flutter 抽屉团组原始视觉(neumorphic 前 / 正确尺寸): - // 按钮约 118×50,内部 SvgPicture(width:128, height:60, fit: BoxFit.fitWidth) - // 即图标按宽度铺满按钮,而不是 neumorphic 默认 iconSize:15 的小方块。 - NeumorphicSurface( - onClick = onClick, - modifier = Modifier.size(width = 120.dp, height = 50.dp), - radius = 8.dp, - background = if (selected) { - Color.Black.copy(alpha = 0.06f) - } else { - Color.Transparent - }, - hasShadow = true, - contentPadding = 0.dp, - ) { - key(group.asset.path) { - AsyncImage( - model = Res.getUri(group.asset.path), - contentDescription = group.key.displayName, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxSize(), - ) - } - } -} - -@Composable -private fun DrawerFunctionRow( - item: DrawerMenuItem, - onClick: () -> Unit, -) { - val dark = LocalDarkTheme.current - val interaction = remember { MutableInteractionSource() } - val iconTint = if (dark) ColorMs.White else ColorMs.Text333 - Row( - Modifier - .fillMaxWidth() - .height(30.dp) - .clickable( - interactionSource = interaction, - indication = null, - onClick = onClick, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - NeumorphicIconButton( - icon = item.icon, - onClick = null, - size = 28.dp, - iconSize = 15.dp, - radius = 6.dp, - iconColor = iconTint, - hasShadow = true, - contentPadding = 5.dp, - interactionSource = interaction, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = item.title, - color = if (dark) ColorMs.EDF5FF else ColorMs.Text333, - fontSize = 15.sp, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/ArtistListItem.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/ArtistListItem.kt deleted file mode 100644 index 8c080fe..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/ArtistListItem.kt +++ /dev/null @@ -1,76 +0,0 @@ -package top.zhushenwudi.llmp.ui.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import top.zhushenwudi.llmp.domain.Artist -import top.zhushenwudi.llmp.navigation.HeroCircleClip -import top.zhushenwudi.llmp.navigation.HeroCoverSlot -import top.zhushenwudi.llmp.navigation.HeroKeys -import top.zhushenwudi.llmp.navigation.LocalOpenLibraryDetail -import top.zhushenwudi.llmp.navigation.hidesArtist -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme - -@Composable -fun ArtistListItem( - artist: Artist, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val dark = LocalDarkTheme.current - val openDetail = LocalOpenLibraryDetail.current - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - HeroCoverSlot( - key = HeroKeys.singer(artist.uid), - visible = !openDetail.hidesArtist(artist.uid), - clipShape = HeroCircleClip, - slotModifier = Modifier.size(48.dp), - ) { heroMod -> - ArtistPhotoImage( - artist = artist, - size = 48.dp, - modifier = Modifier.fillMaxSize().then(heroMod), - ) - } - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - text = artist.name, - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = "${artist.music.size} 首", - color = ColorMs.Gray999, - fontSize = 12.sp, - ) - } - Spacer(Modifier.width(10.dp)) - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BatchActionBar.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BatchActionBar.kt deleted file mode 100644 index 4d46eeb..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/BatchActionBar.kt +++ /dev/null @@ -1,274 +0,0 @@ -package top.zhushenwudi.llmp.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -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 androidx.compose.ui.window.Dialog -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.theme.ColorMs -import top.zhushenwudi.llmp.theme.LocalDarkTheme -import top.zhushenwudi.llmp.ui.icons.SvgAsset - -/** - * Flutter [DialogBottomBtn]: height 102, top radius 16, equal Expanded columns, - * neumorphic 36 icons tinted D1E0F3 / #666, title 15sp. - */ -@Composable -fun BatchActionBar( - showUnlove: Boolean, - onAddToPlaylist: () -> Unit, - onAddToMenu: () -> Unit, - onUnlove: () -> Unit, - modifier: Modifier = Modifier, - /** Flutter menu detail: delete selected from playlist. */ - showRemoveFromMenu: Boolean = false, - onRemoveFromMenu: () -> Unit = {}, -) { - val dark = LocalDarkTheme.current - val bg = if (dark) ColorMs.NightPrimary else ColorMs.LightPrimary - val iconTint = if (dark) ColorMs.UnselectedIcon else ColorMs.Gray666 - val textColor = if (dark) ColorMs.UnselectedIcon else ColorMs.Text333 - val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - - Column( - modifier = modifier - .fillMaxWidth() - .shadow( - elevation = 16.dp, - shape = shape, - ambientColor = bg, - spotColor = bg, - ) - .clip(shape) - .background(bg) - .navigationBarsPadding(), - ) { - // Flutter DialogBottomBtn height: 102.h (content area above system nav) - Row( - Modifier - .fillMaxWidth() - .height(102.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BatchActionItem( - icon = SvgAsset("drawable/dialog_ic_add_play_list2.svg"), - title = "添加到播放列表", - iconTint = iconTint, - textColor = textColor, - onClick = onAddToPlaylist, - modifier = Modifier.weight(1f), - ) - BatchActionItem( - icon = SvgAsset("drawable/dialog_ic_add_play_list.svg"), - title = "添加到歌单", - iconTint = iconTint, - textColor = textColor, - onClick = onAddToMenu, - modifier = Modifier.weight(1f), - ) - if (showUnlove) { - BatchActionItem( - icon = SvgAsset("drawable/dialog_ic_delete_2.svg"), - title = "取消喜欢", - iconTint = iconTint, - textColor = textColor, - onClick = onUnlove, - modifier = Modifier.weight(1f), - ) - } - if (showRemoveFromMenu) { - BatchActionItem( - icon = SvgAsset("drawable/dialog_ic_delete_2.svg"), - title = "从歌单删除", - iconTint = iconTint, - textColor = textColor, - onClick = onRemoveFromMenu, - modifier = Modifier.weight(1f), - ) - } - } - } -} - -@Composable -private fun BatchActionItem( - icon: SvgAsset, - title: String, - iconTint: androidx.compose.ui.graphics.Color, - textColor: androidx.compose.ui.graphics.Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interaction = remember { MutableInteractionSource() } - Column( - modifier = modifier - .fillMaxHeight() - .clickable( - interactionSource = interaction, - indication = null, - onClick = onClick, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - // Flutter neumorphicButton(width:36, height:36, hasShadow:false) - NeumorphicIconButton( - icon = icon, - onClick = null, - size = 36.dp, - iconSize = 20.dp, - radius = 6.dp, - iconColor = iconTint, - hasShadow = false, - contentPadding = 4.dp, - interactionSource = interaction, - ) - Text( - text = title, - color = textColor, - fontSize = 15.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 4.dp), - ) - Spacer(Modifier.height(20.dp)) - } -} - -@Composable -fun AddToMenuDialog( - menus: List, - onPick: (Menu) -> Unit, - onCreate: (String) -> Unit, - onDismiss: () -> Unit, -) { - val dark = LocalDarkTheme.current - var showCreate by remember { mutableStateOf(false) } - Dialog(onDismissRequest = onDismiss) { - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (dark) ColorMs.NightPrimary else ColorMs.White) - .padding(16.dp), - ) { - Text( - "添加到歌单", - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 16.sp, - ) - Spacer(Modifier.height(12.dp)) - LazyColumn(Modifier.height(220.dp)) { - items(menus, key = { it.id }) { menu -> - Text( - text = menu.name, - color = if (dark) ColorMs.EDF5FF else ColorMs.Text333, - fontSize = 15.sp, - modifier = Modifier - .fillMaxWidth() - .clickable { onPick(menu) } - .padding(vertical = 12.dp), - ) - } - } - Spacer(Modifier.height(8.dp)) - Text( - "+ 新建歌单", - color = ColorMs.PinkF940A7, - fontSize = 15.sp, - modifier = Modifier - .clickable { showCreate = true } - .padding(vertical = 8.dp), - ) - } - } - if (showCreate) { - var name by remember { mutableStateOf("") } - Dialog(onDismissRequest = { showCreate = false }) { - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (dark) ColorMs.NightPrimary else ColorMs.White) - .padding(16.dp), - ) { - Text("新建歌单", color = if (dark) ColorMs.White else ColorMs.Text333, fontSize = 16.sp) - Spacer(Modifier.height(12.dp)) - BasicTextField( - value = name, - onValueChange = { name = it.take(40) }, - singleLine = true, - textStyle = TextStyle( - color = if (dark) ColorMs.White else ColorMs.Text333, - fontSize = 15.sp, - ), - cursorBrush = SolidColor(ColorMs.PinkF940A7), - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(if (dark) ColorMs.White.copy(0.08f) else ColorMs.Text333.copy(0.05f)) - .padding(12.dp), - decorationBox = { inner -> - if (name.isEmpty()) { - Text("歌单名称", color = ColorMs.Gray999, fontSize = 15.sp) - } - inner() - }, - ) - Spacer(Modifier.height(16.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Text( - "取消", - color = ColorMs.Gray999, - modifier = Modifier - .clickable { showCreate = false } - .padding(8.dp), - ) - Spacer(Modifier.width(12.dp)) - Text( - "确定", - color = ColorMs.PinkF940A7, - modifier = Modifier - .clickable { - if (name.isNotBlank()) onCreate(name.trim()) - showCreate = false - } - .padding(8.dp), - ) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/MusicCover.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/MusicCover.kt deleted file mode 100644 index 2a24d04..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/components/MusicCover.kt +++ /dev/null @@ -1,154 +0,0 @@ -package top.zhushenwudi.llmp.ui.components - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import org.jetbrains.compose.resources.ExperimentalResourceApi -import top.zhushenwudi.llmp.AppContainer -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Artist -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.generated.resources.Res - -private const val FALLBACK_LOGO = "drawable/logo_svg_logo.svg" - -/** Resolve display URI for player / list / drive (null → show logo). */ -@Composable -fun rememberMusicCoverUri(music: Music?): String? = - remember(music?.musicId, music?.coverPath, music?.existFile, music?.baseUrl) { - music?.let { AppContainer.pathResolver.coverDisplayUri(it) } - } - -/** Absolute local cover path for palette / widgets (no file://). */ -@Composable -fun rememberMusicCoverLocalPath(music: Music?): String? = - remember(music?.musicId, music?.coverPath, music?.existFile, music?.baseUrl) { - music?.let { AppContainer.pathResolver.absoluteCoverPath(it) } - ?.takeIf { path -> AppContainer.fileSystem.exists(path) } - } - -/** Flutter showImg: cover when resolvable & loadable, else logo_svg_logo.svg. */ -@OptIn(ExperimentalResourceApi::class) -@Composable -fun MusicCoverImage( - music: Music, - modifier: Modifier = Modifier, - size: Dp = 48.dp, - radius: Dp = 12.dp, -) { - val uri = rememberMusicCoverUri(music) - CoverAsyncImage( - modelUri = uri, - // Size first, then caller modifiers (e.g. sharedElement after size). - modifier = Modifier.size(size).then(modifier), - shape = RoundedCornerShape(radius), - fallbackPadding = 6.dp, - ) -} - -@OptIn(ExperimentalResourceApi::class) -@Composable -fun AlbumCoverImage( - album: Album, - modifier: Modifier = Modifier, - radius: Dp = 12.dp, -) { - val uri = remember(album.albumId, album.coverPath, album.existFile) { - AppContainer.pathResolver.coverDisplayUri(album) - } - CoverAsyncImage( - modelUri = uri, - modifier = modifier, - shape = RoundedCornerShape(radius), - fallbackPadding = 6.dp, - ) -} - -/** - * Flutter ListViewItemSinger: showImg(artist.photo, 48, 48, isCircle: true). - * Loads https `{R2}/LLMP-M/artist_webp/{uid}.webp` (network via Coil). - */ -@OptIn(ExperimentalResourceApi::class) -@Composable -fun ArtistPhotoImage( - artist: Artist, - modifier: Modifier = Modifier, - size: Dp = 48.dp, -) { - val uri = remember(artist.uid, artist.photo) { - artistPhotoDisplayUri(artist) - } - CoverAsyncImage( - modelUri = uri, - modifier = Modifier.size(size).then(modifier), - shape = CircleShape, - fallbackPadding = 6.dp, - ) -} - -/** Prefer DB photo; else rebuild Flutter URL from uid when not a name-only key. */ -private fun artistPhotoDisplayUri(artist: Artist): String? { - if (artist.photo.isNotBlank()) return artist.photo - if (artist.uid.startsWith("name:")) return null - return "${Const.R2_OSS_URL}LLMP-M/artist_webp/${artist.uid}.webp" -} - -/** - * Shared cover for WeSlide mini bar / full player / drive mode. - * [circular] matches Flutter circle covers; otherwise rounded rectangle. - */ -@OptIn(ExperimentalResourceApi::class) -@Composable -fun PlayerMusicCover( - music: Music?, - modifier: Modifier = Modifier, - circular: Boolean = false, - radius: Dp = 12.dp, - fallbackPadding: Dp = 8.dp, -) { - val uri = rememberMusicCoverUri(music) - CoverAsyncImage( - modelUri = uri, - modifier = modifier, - shape = if (circular) CircleShape else RoundedCornerShape(radius), - fallbackPadding = fallbackPadding, - ) -} - -@OptIn(ExperimentalResourceApi::class) -@Composable -private fun CoverAsyncImage( - modelUri: String?, - modifier: Modifier, - shape: Shape, - fallbackPadding: Dp, -) { - val fallback = Res.getUri(FALLBACK_LOGO) - var failed by remember(modelUri) { mutableStateOf(false) } - val hasCover = !modelUri.isNullOrBlank() && !failed - AsyncImage( - model = if (hasCover) modelUri else fallback, - contentDescription = null, - contentScale = if (hasCover) ContentScale.Crop else ContentScale.Fit, - modifier = modifier - .clip(shape) - .then(if (hasCover) Modifier else Modifier.padding(fallbackPadding)) - .fillMaxSize(), - onError = { failed = true }, - ) -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/CoilSvg.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/CoilSvg.kt deleted file mode 100644 index c25dcc6..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/icons/CoilSvg.kt +++ /dev/null @@ -1,24 +0,0 @@ -package top.zhushenwudi.llmp.ui.icons - -import androidx.compose.runtime.Composable -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.compose.setSingletonImageLoaderFactory -import coil3.svg.SvgDecoder - -/** - * Registers Coil singleton with SVG decoding for Compose Multiplatform. - * Network https (artist_webp etc.) needs [coil-network-ktor3] on the classpath — - * Coil 3 does not fetch network images without that module. - * Must be called once near the app root before any AsyncImage. - */ -@Composable -fun InstallCoilSvg() { - setSingletonImageLoaderFactory { context: PlatformContext -> - ImageLoader.Builder(context) - .components { - add(SvgDecoder.Factory()) - } - .build() - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.kt deleted file mode 100644 index 5a63d33..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.kt +++ /dev/null @@ -1,62 +0,0 @@ -package top.zhushenwudi.llmp.ui.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.graphics.Color -import top.zhushenwudi.llmp.theme.ColorMs - -/** Flutter Const.noMusicColorfulSkin — ARGB(77, 255, 174, 0). */ -val NoMusicColorfulSkin = Color(0x4DFFAE00) - -/** Flutter getColorFromImage → Color.fromARGB(150, r, g, b). */ -const val FlutterPaletteAlpha = 150f / 255f - -expect suspend fun extractDominantColor(coverPath: String?): Color? - -/** - * Flutter [AppUtils.getImagePaletteFromMusic]: dominant RGB with alpha 150. - * Falls back to [NoMusicColorfulSkin]. - */ -@Composable -fun rememberMiniPlayerBarColor(coverPath: String?): Color { - var color by remember(coverPath) { mutableStateOf(NoMusicColorfulSkin) } - LaunchedEffect(coverPath) { - color = extractDominantColor(coverPath) ?: NoMusicColorfulSkin - } - return color -} - -fun fallbackMiniBarColor(dark: Boolean): Color = - if (dark) ColorMs.BottomBarDark.copy(alpha = 0.85f) else ColorMs.LightPrimary - -fun Color.withFlutterPaletteAlpha(): Color = copy(alpha = FlutterPaletteAlpha) - -fun Color.relativeLuminance(): Float = - 0.299f * red + 0.587f * green + 0.114f * blue - -/** Luminance after compositing this (premultiplied by [alpha]) over [under]. */ -fun Color.blendedLuminance(under: Color): Float { - val a = alpha.coerceIn(0f, 1f) - val r = red * a + under.red * (1f - a) - val g = green * a + under.green * (1f - a) - val b = blue * a + under.blue * (1f - a) - return 0.299f * r + 0.587f * g + 0.114f * b -} - -/** - * Mini-bar title / icon colors. - * Flutter uses theme text over alpha-150 palette + BackdropFilter; we pick by - * blended luminance so dark covers stay readable on the frosted bar. - */ -fun miniPlayerContentColors(barColor: Color, shellBg: Color): Pair { - val darkBar = barColor.blendedLuminance(shellBg) < 0.55f - return if (darkBar) { - ColorMs.White to ColorMs.GrayCCC - } else { - ColorMs.Text333 to ColorMs.Text333 - } -} diff --git a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/theme/BgPhoto.kt b/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/theme/BgPhoto.kt deleted file mode 100644 index a33e3dc..0000000 --- a/composeApp/src/commonMain/kotlin/top/zhushenwudi/llmp/ui/theme/BgPhoto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package top.zhushenwudi.llmp.ui.theme - -import androidx.compose.runtime.staticCompositionLocalOf - -/** Flutter GlobalLogic.bgPhoto — empty until colorful skin pipeline lands. */ -val LocalBgPhoto = staticCompositionLocalOf { "" } diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/IosHostEventBridge.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/IosHostEventBridge.kt new file mode 100644 index 0000000..aebd0ab --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/IosHostEventBridge.kt @@ -0,0 +1,109 @@ +package com.zhushenwudi.lovelivemusicplayer + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.serialization.json.Json +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.darwin.NSObjectProtocol +import com.zhushenwudi.lovelivemusicplayer.platform.WidgetDataWriter +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode + +@OptIn(ExperimentalForeignApi::class) +object IosHostEventBridge { + private var observers = mutableListOf() + private val json = Json { ignoreUnknownKeys = true } + + fun start() { + if (observers.isNotEmpty()) return + val center = NSNotificationCenter.defaultCenter + val queue = NSOperationQueue.mainQueue + + observers += center.addObserverForName( + "llmpHostTogglePlay", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + AppContainer.player.toggle() + } + + observers += center.addObserverForName( + "llmpHostPrevious", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + AppContainer.player.previous() + } + + observers += center.addObserverForName( + "llmpHostNext", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + AppContainer.player.next() + } + + observers += center.addObserverForName( + "llmpHostToggleLove", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + val id = AppContainer.player.state.value.current?.musicId ?: return@addObserverForName + AppContainer.toggleLove(id) + } + + observers += center.addObserverForName( + "llmpHostCarPlayReady", + `object` = null, + queue = queue, + ) { _ -> + AppContainer.refreshCarPlayCatalog() + } + + observers += center.addObserverForName( + "llmpHostCarPlayCycleLoop", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + val next = when (AppContainer.player.state.value.loopMode) { + LoopMode.LIST -> LoopMode.SINGLE + LoopMode.SINGLE -> LoopMode.SHUFFLE + LoopMode.SHUFFLE -> LoopMode.LIST + } + AppContainer.player.setLoopMode(next) + AppContainer.settings.loopMode = next + } + + observers += center.addObserverForName( + "llmpHostCarPlayPlay", + `object` = null, + queue = queue, + ) { _ -> + if (!AppContainer.isInitialized) return@addObserverForName + val musicId = WidgetDataWriter.readString("carplay_play_musicId").orEmpty() + val queueJson = WidgetDataWriter.readString("carplay_play_queue").orEmpty() + if (queueJson.isBlank()) return@addObserverForName + val ids = runCatching { + json.decodeFromString>(queueJson) + }.getOrDefault(emptyList()) + if (ids.isEmpty()) return@addObserverForName + val items = AppContainer.library.getMusicByIdsPreservingOrder( + ids, + AppContainer.settings.enableHttp, + ) + if (items.isEmpty()) return@addObserverForName + val idx = items.indexOfFirst { it.musicId == musicId }.coerceAtLeast(0) + AppContainer.player.setQueue(items, idx) + if (WidgetDataWriter.readBool("carplay_play_shuffle")) { + AppContainer.player.setLoopMode(LoopMode.SHUFFLE) + AppContainer.settings.loopMode = LoopMode.SHUFFLE + } + AppContainer.player.play() + AppContainer.persistPlayQueue() + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/MainViewController.kt new file mode 100644 index 0000000..dfb97df --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/MainViewController.kt @@ -0,0 +1,22 @@ +package com.zhushenwudi.lovelivemusicplayer + +import androidx.compose.ui.window.ComposeUIViewController +import com.zhushenwudi.lovelivemusicplayer.db.DriverFactory +import com.zhushenwudi.lovelivemusicplayer.db.createDatabase +import com.zhushenwudi.lovelivemusicplayer.platform.installIosPlatformServices +import com.zhushenwudi.lovelivemusicplayer.player.PlayerController +import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings +import com.zhushenwudi.lovelivemusicplayer.settings.createSettings + +fun MainViewController() = ComposeUIViewController { + IosHostEventBridge.start() + installIosPlatformServices() + if (!AppContainer.isInitialized) { + AppContainer.init( + settings = AppSettings(createSettings()), + database = createDatabase(DriverFactory()), + player = PlayerController(), + ) + } + App() +} diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.ios.kt new file mode 100644 index 0000000..01ed548 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DetailBackHandler.ios.kt @@ -0,0 +1,8 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import androidx.compose.runtime.Composable + +@Composable +actual fun DetailBackHandler(enabled: Boolean, onBack: () -> Unit) { + // iOS 使用交互式滑动 / AppHeader 返回;无 Activity 返回回调。 +} diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.ios.kt new file mode 100644 index 0000000..b0d1e17 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/ImageCacheDirectory.ios.kt @@ -0,0 +1,19 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.image + +import coil3.PlatformContext +import okio.Path.Companion.toPath +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +@Suppress("UNUSED_PARAMETER") +internal actual fun persistentImageCacheDirectory(context: PlatformContext): okio.Path { + val urls = NSFileManager.defaultManager.URLsForDirectory( + directory = NSCachesDirectory, + inDomains = NSUserDomainMask, + ) + val base = (urls.firstOrNull() as? NSURL)?.path + ?: error("NSCachesDirectory unavailable") + return "$base/llmp_image_cache".toPath() +} diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.ios.kt new file mode 100644 index 0000000..8b27662 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/platform/ClipboardCopy.ios.kt @@ -0,0 +1,20 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import kotlinx.coroutines.launch + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual fun rememberCopyTextHandler(): (String) -> Unit { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + return { text -> + scope.launch { + clipboard.setClipEntry(ClipEntry.withPlainText(text)) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.ios.kt new file mode 100644 index 0000000..a52de09 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/scroll/ScrollToTopHandler.ios.kt @@ -0,0 +1,107 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.scroll + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.UIKitView +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import kotlinx.coroutines.launch +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.UIKit.UIScrollView +import platform.UIKit.UIScrollViewDelegateProtocol +import platform.darwin.NSObject + +/** + * 不可见 `UIScrollView`,启用 `scrollsToTop`, + * 使 iOS 状态栏点击将当前 Compose 列表/网格滚到顶部(Lazy* 不是 UIScrollView)。 + * + * 参考 JetBrains kotlinconf-app 实现。 + */ +@Composable +actual fun ScrollToTopHandler(listState: LazyListState) { + val scope = rememberCoroutineScope() + val delegate = remember(scope, listState) { + createScrollToTopDelegate { + scope.launch { listState.animateScrollToItem(0) } + } + } + ScrollToTopProxyScrollView(delegate) +} + +@Composable +actual fun ScrollToTopHandler(gridState: LazyGridState) { + val scope = rememberCoroutineScope() + val delegate = remember(scope, gridState) { + createScrollToTopDelegate { + scope.launch { gridState.animateScrollToItem(0) } + } + } + ScrollToTopProxyScrollView(delegate) +} + +@OptIn(ExperimentalForeignApi::class) +private fun createScrollToTopDelegate( + onScrollToTop: () -> Unit, +): UIScrollViewDelegateProtocol { + return object : NSObject(), UIScrollViewDelegateProtocol { + // https://developer.apple.com/documentation/uikit/uiscrollview/scrollstotop + override fun scrollViewShouldScrollToTop(scrollView: UIScrollView): Boolean = true + + override fun scrollViewDidScrollToTop(scrollView: UIScrollView) { + scrollView.setContentOffset(CGPointMake(0.0, 100.0)) + scrollingToTop = false + } + + private var firstScroll = true + private var scrollingToTop = false + private var lastKnownOffset = 1.0 + + override fun scrollViewDidScroll(scrollView: UIScrollView) { + if (firstScroll) { + firstScroll = false + return + } + if (scrollingToTop) return + + val newOffset = scrollView.contentOffset.useContents { y } + val scrollingUp = newOffset < lastKnownOffset + lastKnownOffset = newOffset + if (scrollingUp) { + scrollingToTop = true + onScrollToTop() + } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +@Composable +private fun ScrollToTopProxyScrollView(delegate: UIScrollViewDelegateProtocol) { + UIKitView( + factory = { + UIScrollView(CGRectMake(0.0, 0.0, 100.0, 1.0)).apply { + scrollsToTop = true + this.delegate = delegate + setContentSize(CGSizeMake(100.0, 10000.0)) + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + // 偏移不能为 0,否则状态栏点击被忽略。 + setContentOffset(CGPointMake(0.0, 100.0)) + } + }, + modifier = Modifier + .alpha(0f) + .height(1.dp) + .fillMaxWidth(), + ) +} diff --git a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/MainViewController.kt b/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/MainViewController.kt deleted file mode 100644 index dd9c4c9..0000000 --- a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/MainViewController.kt +++ /dev/null @@ -1,21 +0,0 @@ -package top.zhushenwudi.llmp - -import androidx.compose.ui.window.ComposeUIViewController -import top.zhushenwudi.llmp.db.DriverFactory -import top.zhushenwudi.llmp.db.createDatabase -import top.zhushenwudi.llmp.player.PlayerController -import top.zhushenwudi.llmp.settings.AppSettings -import top.zhushenwudi.llmp.platform.installIosPlatformServices -import top.zhushenwudi.llmp.settings.createSettings - -fun MainViewController() = ComposeUIViewController { - installIosPlatformServices() - if (!AppContainer.isInitialized) { - AppContainer.init( - settings = AppSettings(createSettings()), - database = createDatabase(DriverFactory()), - player = PlayerController(), - ) - } - App() -} diff --git a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.ios.kt b/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.ios.kt deleted file mode 100644 index 19f3560..0000000 --- a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/DetailBackHandler.ios.kt +++ /dev/null @@ -1,8 +0,0 @@ -package top.zhushenwudi.llmp.platform - -import androidx.compose.runtime.Composable - -@Composable -actual fun DetailBackHandler(enabled: Boolean, onBack: () -> Unit) { - // iOS uses interactive swipe / AppHeader back; no Activity back callback. -} diff --git a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.ios.kt b/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.ios.kt deleted file mode 100644 index ef27448..0000000 --- a/composeApp/src/iosMain/kotlin/top/zhushenwudi/llmp/ui/player/CoverPalette.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package top.zhushenwudi.llmp.ui.player - -import androidx.compose.ui.graphics.Color - -/** iOS: no Palette API yet — keep Flutter no-music skin tint. */ -actual suspend fun extractDominantColor(coverPath: String?): Color? = null diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..8b53169 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/690f22c7d5fabdba81db34b2bcbdfd40/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/19d7c2d18fdc600f184666745a0567c4/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5c55020ad1e1758e65cf84d78a6991d9/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/057be4d72513d094c747045acda26562/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/38d4fc46de6f02af9912aa3f66104600/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/34480c4bfcee5d3bd65f2a0592c5217b/redirect +toolchainVendor=JETBRAINS +toolchainVersion=25 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/iosApp/README.md b/iosApp/README.md index 9dfd009..fa7c347 100644 --- a/iosApp/README.md +++ b/iosApp/README.md @@ -1,71 +1,48 @@ # iOS App (Xcode host) -Kotlin Multiplatform Compose UI runs inside a native iOS shell. Sources here are **Xcode-ready scaffolding**; building requires **macOS + Xcode**. +Kotlin Multiplatform Compose UI runs inside a native iOS shell. -## App Group migration - -| Old (Flutter) | New (KMP) | -|---|---| -| `group.com.zhushenwudi.lovelivemusicplayer` | **`group.top.zhushenwudi.llmp`** | - -Kotlin writes widget / PiP / CarPlay keys via `WidgetDataWriter` (`shared/.../IosPlatformHooks.kt`). Swift Widget + CarPlay read the same suite. - -## Build steps (macOS) +## Open in Xcode ```bash -cd LoveLiveMusicPlayerKMP -./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -# or iosArm64 for device +cd iosApp +./setup.sh # needs xcodegen (`brew install xcodegen`) +open iosApp.xcodeproj ``` -1. Open Xcode → **File → New → Project → App** (or add targets to an existing workspace). -2. Set bundle ID **`top.zhushenwudi.llmp`** (main app). -3. Copy `iosApp/iosApp/*` into the app target; add `WidgetExtension/*` as a Widget Extension target (`top.zhushenwudi.llmp.widget`). -4. Link the Gradle-produced **`ComposeApp.framework`** (and embedded **`Shared.framework`** if split). -5. Signing: enable App Groups `group.top.zhushenwudi.llmp` on **both** app + widget targets (`iosApp.entitlements`, `WidgetExtension.entitlements`). -6. CarPlay: entitlements include `com.apple.developer.carplay-audio` and `com.apple.developer.playable-content` — **requires Apple Developer approval** before TestFlight/App Store CarPlay works. -7. Add **`black.mp4`** (minimal black video) to the app bundle for PiP — copy from Flutter `ios/Runner` if present, or generate a 1-frame H.264 clip. -8. Copy widget image assets from Flutter `ios/HomeWidgetExample/Assets.xcassets` (`logo`, `cd_white`, `cd_black`, favorites). +1. 选择模拟器或真机 +2. 签名已与 Flutter Runner **Manual** 对齐(见下表);模拟器会走本地签名 +3. Run(⌘R)——预编译脚本会自动跑 `:composeApp:embedAndSignAppleFrameworkForXcode` -Entry point: +## Bundle ID / App Group / 签名(与 Flutter 一致) -```swift -MainViewControllerKt.MainViewController() // ComposeApp framework -``` +| | Value | +|---|---| +| App | `com.zhushenwudi.lovelivemusicplayer` | +| Widget | `com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample` | +| App Group | `group.com.zhushenwudi.lovelivemusicplayer` | +| Team | `V73L9LHPCA` | +| Version | `1.7.4` (build `74`) | +| Debug profile | `LLMP-M-DEVELOP` / `LLMP-M-DEVELOP-WIDGET` | +| Release profile | `LLMP-M-RELEASE` / `LLMP-M-RELEASE-WIDGET` | +| Profile (AdHoc) | `LLMP-M-AD-HOC` / `LLMP-M-AD-HOC-WIDGET` | -`MainViewController.kt` calls `installIosPlatformServices()` on startup. +最低系统:App **iOS 18.0**,Widget **iOS 17.0**。 -## Targets & layout +## Layout ``` iosApp/ - iosApp/ - AppDelegate.swift — lifecycle, widget shutdown, notification wiring - SceneDelegate.swift — UIWindow + PiP notification handlers - ContentView.swift — SwiftUI host for Compose UIViewController - HostBridge.swift — share sheet, deep links, widget/CarPlay → host - Info.plist - iosApp.entitlements - CarPlay/CarPlaySceneDelegate.swift - pip/ — desktop lyric PiP (from Flutter Runner/pip) - WidgetExtension/ — White/Black home widgets (from Flutter HomeWidgetExample) + project.yml — XcodeGen 源配置(改完跑 ./setup.sh) + setup.sh + iosApp.xcodeproj — 用这个打开 + iosApp/ — 主 App(UIKit + SwiftUI host) + WidgetExtension/ — 桌面小组件 + 资源 ``` -## Kotlin ↔ Swift bridge (remaining macOS work) +## Notes -| Feature | Kotlin side | Swift side | Gap | -|---|---|---|---| -| Playback | `PlayerController.ios.kt` AVPlayer + MPRemoteCommandCenter | — | Done in Kotlin | -| Home widget data | `IosHomeWidgetController` → UserDefaults | Widget reads keys | Wire widget tap → Kotlin player (export callback or observe `llmpHostTogglePlay`) | -| PiP lyrics | `IosDesktopLyricController` posts notifications | `PipScreenManager` | Done via NotificationCenter | -| Share | `IosShareBridge` posts `llmpShareRequest` | `HostBridge.presentShare` | Done | -| Deep link `llmp://` | `IosDeepLinkHandler` | `SceneDelegate` + `HostBridge` | Handle payload in Kotlin UI layer | -| CarPlay | `IosCarPlayHook.refreshCatalog()` writes JSON | `CarPlaySceneDelegate` | Play/group/menu callbacks need Kotlin export | - -## CarPlay note - -`CarPlaySceneDelegate` reads `carplay_*` JSON from the App Group. Full parity with Flutter `flutter_carplay` needs exported Kotlin handlers for `onPlay(musicId)` — until then, NotificationCenter events (`llmpHostCarPlayPlay`, etc.) are posted for the host to forward. - -## Permissions - -Add usage strings in Xcode as needed (camera for QR, photo library, etc.) — see Flutter `ios/Runner/Info.plist` for reference. +- CarPlay entitlements 已挂在主 App(需账号侧已批准该 Bundle ID)。 +- PiP 用的 `black.mp4` 已在 `iosApp/pip/`。 +- SQLDelight 需要链接 `-lsqlite3`(已写在 `project.yml`)。 +- 日常运行请选 **iosApp** scheme。若选 **WidgetExtension**,Xcode 会回到主屏刷新小组件(调试 Widget 才用)。 diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/Modules/module.modulemap b/iosApp/Vendor/TencentOpenAPI.framework/Headers/Modules/module.modulemap new file mode 100644 index 0000000..d504d26 --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/Modules/module.modulemap @@ -0,0 +1,4 @@ +module TencentOpenApi{ + umbrella header "TencentOpenApiUmbrellaHeader.h" + export * +} diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterface.h b/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterface.h new file mode 100644 index 0000000..faca3ec --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterface.h @@ -0,0 +1,220 @@ +/// +/// \file QQApiInterface.h +/// \brief QQApi接口简化封装 +/// +/// Created by Tencent on 12-5-15. +/// Copyright (c) 2012年 Tencent. All rights reserved. +/// + +#import +#import "QQApiInterfaceObject.h" + +typedef void (^sendResultBlock)(NSDictionary *result); + +// 发送消息回调是否发送成功 +typedef void(^QQApiInterfaceSendMessageResultBlock)(QQApiSendResultCode sendResultCode, NSString *message); + +/** + \brief 处理来至QQ的请求及响应的回调协议 + */ +@protocol QQApiInterfaceDelegate + +/** + 处理来至QQ的请求 + */ +- (void)onReq:(QQBaseReq *)req; + +/** + 处理来至QQ的响应 + */ +- (void)onResp:(QQBaseResp *)resp; + +/** + 处理QQ在线状态的回调 + */ +- (void)isOnlineResponse:(NSDictionary *)response; + +@end + +/** + \brief 对QQApi的简单封装类 + */ +@interface QQApiInterface : NSObject + +/** + 处理由手Q唤起的普通跳转请求 + \param url 待处理的url跳转请求 + \param delegate 第三方应用用于处理来至QQ请求及响应的委托对象 + \return 跳转请求处理结果,YES表示成功处理,NO表示不支持的请求协议或处理失败 + */ ++ (BOOL)handleOpenURL:(NSURL *)url delegate:(id)delegate; + +/** + 处理由手Q唤起的universallink跳转请求 + \param universallink 待处理的universallink跳转请求 + \param delegate 第三方应用用于处理来至QQ请求及响应的委托对象 + \return 跳转请求处理结果,YES表示成功处理,NO表示不支持的请求协议或处理失败 + */ ++ (BOOL)handleOpenUniversallink:(NSURL*)universallink delegate:(id)delegate; + +/** + 向手Q发起分享请求 + \param req 分享内容的请求 + \return 请求发送结果码 + */ ++ (QQApiSendResultCode)sendReq:(QQBaseReq *)req; + + +/** + 向手Q QZone结合版发起分享请求 + \note H5分享只支持单张网络图片的传递 + \param req 分享内容的请求 + \return 请求发送结果码 + */ ++ (QQApiSendResultCode)SendReqToQZone:(QQBaseReq *)req; + +/** + 向手Q发起设置QQ头像 + \param req 分享内容的请求 + \return 请求发送结果码 + */ ++ (QQApiSendResultCode)sendMessageToQQAvatarWithReq:(QQBaseReq*)req; + + ++ (QQApiSendResultCode)sendMessageToQQAuthWithReq:(QQBaseReq*)req; + + +/** + 向手Q发起绑群请求 + \param req 请求的内容 + \param resultBlock 请求回调 + */ ++ (void)sendThirdAppBindGroupReq:(QQBaseReq *)req resultBlock:(sendResultBlock)resultBlock; + +/** + 向手Q发起加群请求 + \param req 请求的内容 + \param resultBlock 请求回调 + */ ++ (void)sendThirdAppJoinGroupReq:(QQBaseReq *)req resultBlock:(sendResultBlock)resultBlock; + +/** + 向手Q发起解绑群请求 + \param req 请求的内容 + \param resultBlock 请求回调 + */ ++ (void)sendThirdAppUnBindGroupReq:(QQBaseReq *)req resultBlock:(sendResultBlock)resultBlock; + +/** + 向手Q发起创建QQ频道的请求 + \param req 请求的内容 + \param resultBlock 回调发送结果 + \return void + */ ++ (void)sendMessageToCreateQQGroupProWithMessageRequest:(SendMessageToQQReq *)messageRequest sendResultBlock:(QQApiInterfaceSendMessageResultBlock)sendResultBlock; + + +/** + 向手Q发起加入QQ频道的请求 + \param req 请求的内容 + \param resultBlock 回调发送结果 + \return void + */ ++ (void)sendMessageToJoinQQGroupProWithMessageRequest:(SendMessageToQQReq *)messageRequest sendResultBlock:(QQApiInterfaceSendMessageResultBlock)sendResultBlock; + + +/** + 向手Q发起查询QQ频道openID的请求 + \param req 请求的内容 + \param resultBlock 请求回调 + */ ++ (void)sendQueryQQGroupProInfo:(QQBaseReq *)req resultBlock:(sendResultBlock)resultBlock; + +/** + 向手Q发起组图分享到表情收藏 + \param req 分享内容的请求 + \return 请求发送结果码 + */ ++ (QQApiSendResultCode)sendMessageToFaceCollectionWithReq:(QQBaseReq*)req; + +/** + 检测是否已安装QQ + \return 如果QQ已安装则返回YES,否则返回NO + + \note SDK目前已经支持QQ、TIM授权登录及分享功能, 会按照QQ>TIM的顺序进行调用。 + 只要用户安装了QQ、TIM中任意一个应用,都可为第三方应用进行授权登录、分享功能。 + 第三方应用在接入SDK时不需要判断是否安装QQ、TIM。若有判断安装QQ、TIM的逻辑建议移除。 + */ ++ (BOOL)isQQInstalled; + +/** + 检测是否已安装TIM + \return 如果TIM已安装则返回YES,否则返回NO + + \note SDK目前已经支持QQ、TIM授权登录及分享功能, 会按照QQ>TIM的顺序进行调用。 + 只要用户安装了QQ、TIM中任意一个应用,都可为第三方应用进行授权登录、分享功能。 + 第三方应用在接入SDK时不需要判断是否安装QQ、TIM。若有判断安装QQ、TIM的逻辑建议移除。 + */ ++ (BOOL)isTIMInstalled; + +/** + 检测QQ是否支持API调用 + \return 如果当前安装QQ版本支持API调用则返回YES,否则返回NO + */ ++ (BOOL)isQQSupportApi; + +/** + 检测TIM是否支持API调用 + \return 如果当前安装TIM版本支持API调用则返回YES,否则返回NO + */ ++ (BOOL)isTIMSupportApi __attribute__((deprecated("已过期, 建议删除调用,调用地方用YES替代。"))); + +/** + 检测是否支持分享 + \return 如果当前已安装QQ且QQ版本支持API调用 或者 当前已安装TIM且TIM版本支持API调用则返回YES,否则返回NO + */ ++ (BOOL)isSupportShareToQQ; + +/** + 检测是否支持分享到QQ结合版QZone + \return 如果当前已安装QQ且QQ版本支持API调用则返回YES,否则返回NO + */ ++ (BOOL)isSupportPushToQZone; + +/** + 获取QQ下载地址 + + 如果App通过QQApiInterface#isQQInstalled和QQApiInterface#isQQSupportApi检测发现QQ没安装或当前版本QQ不支持API调用,可引导用户通过打开此链接下载最新版QQ。 + \return iPhoneQQ下载地址 + */ ++ (NSString *)getQQInstallUrl; + +/** + 获取TIM下载地址 + + 如果App通过QQApiInterface#isTIMInstalled检测发现TIM没安装或当前版本TIM不支持API调用,可引导用户通过打开此链接下载最新版TIM。 + \return iPhoneTIM下载地址 + */ ++ (NSString *)getTIMInstallUrl; + +#pragma mark - Log + +/*! @brief 调用此函数可以导出QQSDK的Log到第三方中,用于定位问题 + + 注意1:SDK会强引用这个block,注意不要导致内存泄漏,注意不要导致内存泄漏 + 注意2:调用过一次startLog by block之后,如果再调用一次任意方式的startLoad,会释放上一次logBlock,不再回调上一个logBlock + * + * @param logBlock 打印log的回调block + */ ++ (void)startLogWithBlock:(QQApiLogBolock)logBlock; + +///停止回调打印 ++ (void)stopLog; + +///设置打印日志到文件开关on/off,如果不设置,默认不打印到文件 ++ (void)setSwitchPrintLogToFile:(BOOL)on; + +///日志文件目录 ++ (NSString *)getLogFilePath; + +@end diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterfaceObject.h b/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterfaceObject.h new file mode 100644 index 0000000..4a546a6 --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/QQApiInterfaceObject.h @@ -0,0 +1,754 @@ +/// +/// \file QQApiInterfaceObject.h +/// \brief QQApiInterface所依赖的请求及应答消息对象封装帮助类 +/// +/// Created by Tencent on 12-5-15. +/// Copyright (c) 2012年 Tencent. All rights reserved. +/// + +#ifndef QQApiInterface_QQAPIOBJECT_h +#define QQApiInterface_QQAPIOBJECT_h + +#import + +typedef NS_ENUM(NSInteger, QQApiSendResultCode) { + EQQAPISENDSUCESS = 0, + EQQAPIQQNOTINSTALLED = 1, // QQ未安装 + EQQAPIQQNOTSUPPORTAPI = 2, // QQ api不支持 + EQQAPIMESSAGETYPEINVALID = 3, + EQQAPIMESSAGECONTENTNULL = 4, + EQQAPIMESSAGECONTENTINVALID = 5, + EQQAPIAPPNOTREGISTED = 6, + EQQAPIAPPSHAREASYNC = 7, + EQQAPIQQNOTSUPPORTAPI_WITH_ERRORSHOW = 8, // QQ api不支持 && SDK显示error提示(已废弃) + EQQAPIMESSAGEARKCONTENTNULL = 9, // ark内容为空 + EQQAPIMESSAGE_MINI_CONTENTNULL = 10, // 小程序参数为空 + EQQAPISENDFAILD = -1, // 发送失败 + EQQAPISHAREDESTUNKNOWN = -2, // 未指定分享到QQ或TIM + EQQAPITIMSENDFAILD = -3, // 发送失败 + EQQAPITIMNOTINSTALLED = 11, // TIM未安装 + EQQAPITIMNOTSUPPORTAPI = 12, // TIM api不支持 + EQQAPI_INCOMING_PARAM_ERROR = 13, // 外部传参错误 + EQQAPI_THIRD_APP_GROUP_ERROR_APP_NOT_AUTHORIZIED = 14, // APP未获得授权 + EQQAPI_THIRD_APP_GROUP_ERROR_CGI_FAILED = 15, // CGI请求失败 + EQQAPI_THIRD_APP_GROUP_ERROR_HAS_BINDED = 16, // 该组织已经绑定群聊 + EQQAPI_THIRD_APP_GROUP_ERROR_NOT_BINDED = 17, // 该组织尚未绑定群聊 + EQQAPI_THIRD_APP_GROUP_ERROR_HAS_UNBINDED = 18, // 该组织已经解绑群聊 + EQQAPI_IMAGE_SIZE_OUT_OF_BOUNND = 19, // 图片大小超过限制(图片不能超过5M,缩略图不能超过1M) + EQQAPI_TITLE_LENGTH_OUT_OF_BOUNND = 20, // 标题长度超过限制(不能超过128) + EQQAPI_TITLE_NIL_ERROR = 21, // 标题不能为空 + EQQAPI_DESC_LENGTH_OUT_OF_BOUNND = 22, // 描述信息长度超过限制(不能超过512) + EQQAPI_URL_LENGTH_OUT_OF_BOUNND = 23, // URL参数长度超过限制(不能超过1024) + EQQAPI_URL_NIL_ERROR = 24, // URL参数不能为空 + EQQAPIQZONENOTSUPPORTTEXT = 10000, // qzone分享不支持text类型分享 + EQQAPIQZONENOTSUPPORTIMAGE = 10001, // qzone分享不支持image类型分享 + EQQAPIVERSIONNEEDUPDATE = 10002, // 当前QQ版本太低,需要更新至新版本才可以支持 + ETIMAPIVERSIONNEEDUPDATE = 10004, // 当前TIM版本太低,需要更新至新版本才可以支持 + EAPPURLTYPESILLEGALITY = 20000, // (>=3.3.8)第三方APP的info.plist中UrlTypes字段存在QQ的UrlScheme + EQQAPI_ERROR_USER_NOT_AGREED_AUTHORIZATION = 30001, // 用户未同意隐私协议,用户同意隐私协议后,需要设置[TencentOAuth setIsUserAgreedAuthorization:YES]; +}; + +#pragma mark - QQApiObject(分享对象类型) + +// QQApiObject control flags +typedef NS_ENUM(NSUInteger,kQQAPICtrlFlag) { + kQQAPICtrlFlagQZoneShareOnStart = 0x01, + kQQAPICtrlFlagQZoneShareForbid = 0x02, //屏蔽好友选择器上的空间入口 + kQQAPICtrlFlagQQShare = 0x04, + kQQAPICtrlFlagQQShareFavorites = 0x08, //收藏 + kQQAPICtrlFlagQQShareDataline = 0x10, //数据线 + kQQAPICtrlFlagQQShareEnableArk = 0x20, //支持ARK + kQQAPICtrlFlagQQShareEnableMiniProgram = 0x40, //支持小程序 +}; + +// 分享到QQ或TIM +typedef NS_ENUM(NSUInteger, ShareDestType) { + ShareDestTypeQQ = 0, + ShareDestTypeTIM, +}; + +//小程序的类型 +typedef NS_ENUM(NSUInteger, MiniProgramType) { + MiniProgramType_Develop = 0, // 开发版 + MiniProgramType_Test = 1, // 测试版 + MiniProgramType_Online = 3, // 正式版,默认 + MiniProgramType_Preview = 4, // 预览版 +}; + +/// 打印回调的block +typedef void(^QQApiLogBolock)(NSString *logStr); + +// QQApiObject +/** \brief 所有在QQ及插件间发送的数据对象的根类。 + */ +__attribute__((visibility("default"))) @interface QQApiObject : NSObject + +@property (nonatomic, copy) NSString *title; ///< 标题,最长128个字符 +@property (nonatomic, copy) NSString *description; ///<简要描述,最长512个字符 +@property (nonatomic, copy) NSString *universalLink; ///(>=3.3.7)支持第三方传入在互联开放平台注册的universallink +@property (nonatomic, assign) uint64_t cflag; +//353新增两个字断给游戏侧使用,对齐微信sdk +@property (nonatomic, copy) NSString *tagName; +@property (nonatomic, copy) NSString *messageExt; +/* + * 分享到QQ/TIM + * SDK根据是否安装对应客户端进行判断,判断顺序:QQ > TIM + * 默认分享到QQ,如果QQ未安装检测TIM是否安装 + */ +@property (nonatomic, assign) ShareDestType shareDestType; + +/** + * 检查参数是否完整有效 + * @return 检查参数结果 + */ +- (QQApiSendResultCode)checkParamValid; + +@end + +// ArkObject +/** \brief 支持Ark的根类。 + */ +__attribute__((visibility("default"))) @interface ArkObject : NSObject +@property (nonatomic, copy) NSString *arkData; ///< 显示Ark所需的数据,json串,长度暂不限制 +@property (nonatomic, strong) QQApiObject* qqApiObject; ///<原有老版本的QQApiObject + +- (id)initWithData:(NSString *)arkData qqApiObject:(QQApiObject*)qqApiObject; ++ (id)objectWithData:(NSString *)arkData qqApiObject:(QQApiObject*)qqApiObject; +@end + +#pragma mark QQ小程序 +//分享小程序消息 - QQ 8.0.8 +__attribute__((visibility("default"))) @interface QQApiMiniProgramObject : NSObject +@property (nonatomic, strong) QQApiObject* qqApiObject; //原有老版本的QQApiObject +@property (nonatomic, copy) NSString *miniAppID; //必填,小程序的AppId(注:必须在QQ互联平台中,将该小程序与分享的App绑定) +@property (nonatomic, copy) NSString *miniPath; //必填,小程序的展示路径 +@property (nonatomic, copy) NSString *webpageUrl; //必填,兼容低版本的网页链接 +@property (nonatomic, assign) MiniProgramType miniprogramType; //非必填,小程序的类型,默认正式版(3),可选测试版(1)、预览版(4) +@end + +//唤起小程序 - QQ 8.1.8 +__attribute__((visibility("default"))) @interface QQApiLaunchMiniProgramObject : QQApiObject +@property (nonatomic, copy) NSString *miniAppID; //必填,小程序的AppId(注:必须在QQ互联平台中,将该小程序与分享的App绑定) +@property (nonatomic, copy) NSString *miniPath; //小程序的展示路径,不填展示默认小程序首页 +@property (nonatomic, assign) MiniProgramType miniprogramType; //非必填,小程序的类型,默认正式版(3),可选测试版(1)、开发版(0) +@end + +//小程序唤起第三方 - SDK 3.3.9 +__attribute__((visibility("default"))) @interface QQApiMiniProgramLaunchObject : QQApiObject +@property (nonatomic, copy) NSString *appParameter; //小程序带来的数据,透传 ++ (instancetype)newWithAppParameter:(NSString *)parameter; +@end + +// QQApiResultObject +/** \brief 用于请求回应的数据类型。 +

可能错误码及描述如下:

+ + + + + + + + +
errorerrorDescription注释
0nil成功
-1param error参数错误
-2group code is invalid该群不在自己的群列表里面
-3upload photo failed上传图片失败
-4user give up the current operation用户放弃当前操作
-5client internal error客户端内部处理错误
+ */ +__attribute__((visibility("default"))) @interface QQApiResultObject : QQApiObject +@property (nonatomic, copy) NSString *error; ///<错误 +@property (nonatomic, copy) NSString *errorDescription; ///<错误描述 +@property (nonatomic, copy) NSString *extendInfo; ///<扩展信息 +@property (nonatomic, copy) NSDictionary *otherInfo; ///<其他扩展信息 +@end + +// QQApiTextObject +/** \brief 文本对象 + */ +@interface QQApiTextObject : QQApiObject +@property (nonatomic, copy)NSString *text; ///<文本内容,必填,最长1536个字符 + +- (id)initWithText:(NSString *)text; ///<初始化方法 ++ (id)objectWithText:(NSString *)text;///<工厂方法,获取一个QQApiTextObject对象. + +@end + +// QQApiURLObject +typedef NS_ENUM(NSUInteger, QQApiURLTargetType) { + QQApiURLTargetTypeNotSpecified = 0x00, + QQApiURLTargetTypeAudio = 0x01, + QQApiURLTargetTypeVideo = 0x02, + QQApiURLTargetTypeNews = 0x03 +}; + +/** @brief URL对象类型。 + + 包括URL地址,URL地址所指向的目标类型及预览图像。 + */ +__attribute__((visibility("default"))) @interface QQApiURLObject : QQApiObject +/** + URL地址所指向的目标类型. + @note 参见QQApi.h 中的 QQApiURLTargetType 定义. + */ +@property (nonatomic)QQApiURLTargetType targetContentType; + +@property (nonatomic, strong) NSURL *url; ///QQApiExtendObject对象 + @param data 数据内容 + @param previewImageData 用于预览的图片 + @param title 标题 + @param description 此对象,分享的描述 + @return + 一个自动释放的QQApiExtendObject实例 + */ ++ (id)objectWithData:(NSData *)data previewImageData:(NSData *)previewImageData title:(NSString *)title description:(NSString *)description; +/** + helper方法获取一个autorelease的QQApiExtendObject对象 + @param data 数据内容 + @param previewImageData 用于预览的图片 + @param title 标题 + @param description 此对象,分享的描述 + @param imageDataArray 发送的多张图片队列 + @return + 一个自动释放的QQApiExtendObject实例 + */ ++ (id)objectWithData:(NSData *)data previewImageData:(NSData *)previewImageData title:(NSString *)title description:(NSString *)description imageDataArray:(NSArray *)imageDataArray; + + +@end + +// QQApiImageObject +/** @brief 图片对象 + 用于分享图片内容的对象,是一个指定为图片类型的QQApiExtendObject + */ +@interface QQApiImageObject : QQApiExtendObject +@end + +// QQApiImageForQQAvatarObject +/** @brief 图片对象 + 用于设置QQ头像内容的对象,是一个指定为图片类型的QQApiExtendObject + */ +@interface QQApiImageForQQAvatarObject : QQApiExtendObject +@end +/** + * @brief 视频对象 + * 用于设置动态头像 + * assetURL可传ALAsset的ALAssetPropertyAssetURL,或者PHAsset的localIdentifier + 从手Q返回的错误码: + //第三方设置动态头像结果 + @"ret=0"//设置成功 + @"ret=-10&error_des=user cancel"//用户取消设置 + @"ret=-11&error_des=pasteboard have no video data"//剪切板没有数据 + @"ret=-12&error_des=export data failed"//从剪切板导出数据到本地失败 + @"ret=-13&error_des=url param invalid"//sdk传递过来的数据有误 + @"ret=-14&error_des=video param invalid"//视频的参数不符合要求(检测第三方视频源方案:1、分辨率跟480*480保持一致;2、视频长度0.5s~8s) + @"ret=-15&error_des=app authorised failed"//应用鉴权失败 + @"ret=-16&error_des=upload video failed"//设置头像,上传到后台失败 + @"ret=-17&error_des=account diff"//账号不一致 + */ +@interface QQApiVideoForQQAvatarObject : QQApiExtendObject +@property (nonatomic, copy) NSString *assetURL; +@end + + + +//QQApiAuthObject 用于拉起手Q的授权详情页 +@interface QQApiAuthObject : QQApiObject +@end + +// QQApiImageArrayForFaceCollectionObject +/** @brief 图片数组对象 + 用于分享图片组到表情收藏,是一个指定为图片类型的QQApiObject + */ +@interface QQApiImageArrayForFaceCollectionObject : QQApiObject + +@property (nonatomic, copy) NSArray *imageDataArray;///图片数组 + +/** + 初始化方法 + @param imageDataArray 图片数组 + */ +- (id)initWithImageArrayData:(NSArray *)imageDataArray; +/** + helper方法获取一个autorelease的QQApiObject对象 + @param imageDataArray 发送的多张图片队列 + @return + 一个自动释放的QQApiObject实例 + */ ++ (id)objectWithimageDataArray:(NSArray *)imageDataArray; + +@end + +// QQApiImageArrayForQZoneObject +/** @brief 图片对象 + 用于分享图片到空间,走写说说路径,是一个指定为图片类型的,当图片数组为空时,默认走文本写说说QQApiObject + */ +@interface QQApiImageArrayForQZoneObject : QQApiObject + +@property (nonatomic, copy) NSArray *imageDataArray;///图片数组 +@property (nonatomic, copy) NSDictionary *extMap; // 扩展字段 + +/** + 初始化方法 + @param imageDataArray 图片数组 + @param title 写说说的内容,可以为空 + @param extMap 扩展字段 + */ +- (id)initWithImageArrayData:(NSArray *)imageDataArray title:(NSString *)title extMap:(NSDictionary *)extMap; + +/** + helper方法获取一个autorelease的QQApiExtendObject对象 + @param title 写说说的内容,可以为空 + @param imageDataArray 发送的多张图片队列 + @param extMap 扩展字段 + @return + 一个自动释放的QQApiExtendObject实例 + */ ++ (id)objectWithimageDataArray:(NSArray *)imageDataArray title:(NSString *)title extMap:(NSDictionary *)extMap; + +@end + +// QQApiVideoForQZoneObject +/** @brief 视频对象 + 用于分享视频到空间,走写说说路径QQApiObject,assetURL和videoData两个参数必须设置至少一个参数,如果assetURL设置了忽略videoData参数 + @param assetURL可传ALAsset的ALAssetPropertyAssetURL,或者PHAsset的localIdentifier + @param extMap 扩展字段 + @param videoData 视频数据,大小不超过50M + */ +@interface QQApiVideoForQZoneObject : QQApiObject + +@property (nonatomic, copy) NSString *assetURL; +@property (nonatomic, copy) NSDictionary *extMap; // 扩展字段 +@property (nonatomic, copy) NSData *videoData; + +- (id)initWithAssetURL:(NSString *)assetURL title:(NSString *)title extMap:(NSDictionary *)extMap; + ++ (id)objectWithAssetURL:(NSString *)assetURL title:(NSString *)title extMap:(NSDictionary *)extMap; + +- (id)initWithVideoData:(NSData *)videoData title:(NSString *)title extMap:(NSDictionary *)extMap; + ++ (id)objectWithVideoData:(NSData *)videoData title:(NSString *)title extMap:(NSDictionary *)extMap; + +@end + +// QQApiWebImageObject +/** @brief 图片对象 + 用于分享网络图片内容的对象,是一个指定网络图片url的: 该类型只在2.9.0的h5分享中才支持, + 原有的手q分享是不支持该类型的。 + */ +@interface QQApiWebImageObject : QQApiObject + +@property (nonatomic, strong) NSURL *previewImageURL; ///<预览图像URL + +/** + 初始化方法 + @param previewImageURL 用于预览的图片 + @param title 标题 + @param description 此对象,分享的描述 + */ +- (id)initWithPreviewImageURL:(NSURL *)previewImageURL title:(NSString *)title description:(NSString *)description; + +/** + helper方法获取一个autorelease的QQApiWebImageObject对象 + @param previewImageURL 用于预览的图片 + @param title 标题 + @param description 此对象,分享的描述 + */ ++ (id)objectWithPreviewImageURL:(NSURL *)previewImageURL title:(NSString *)title description:(NSString *)description; + +@end + + +//QQApiFileObject +/** @brief 本地文件对象(暂只支持分享到手机QQ数据线功能) + 用于分享文件内容的对象,是一个指定为文件类型的QQApiExtendObject + */ +@interface QQApiFileObject : QQApiExtendObject { + NSString *_fileName; +} + +@property (nonatomic, copy)NSString *fileName; + +@end + +// QQApiAudioObject +/** @brief 音频URL对象 + 用于分享目标内容为音频的URL的对象 + */ +@interface QQApiAudioObject : QQApiURLObject + +@property (nonatomic, strong) NSURL *flashURL; ///<音频URL地址,最长512个字符 + +/** + 获取一个autorelease的QQApiAudioObject + @param url 音频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param data 分享内容的预览图像 + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageData:(NSData *)data; + +/** + 获取一个autorelease的QQApiAudioObject + @param url 音频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param previewURL 分享内容的预览图像URL + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageURL:(NSURL *)previewURL; + +@end + +// QQApiVideoObject +/** @brief 视频URL对象 + 用于分享目标内容为视频的URL的对象 + + QQApiVideoObject类型的分享,目前在Android和PC QQ上接收消息时,展现有待完善,待手机QQ版本以后更新支持 + 目前如果要分享视频,推荐使用 QQApiNewsObject 类型 + */ +@interface QQApiVideoObject : QQApiURLObject + +@property (nonatomic, strong) NSURL *flashURL; ///<视频URL地址,最长512个字符 + +/** + 获取一个autorelease的QQApiVideoObject + @param url 视频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param data 分享内容的预览图像 + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageData:(NSData *)data; +/** + 获取一个autorelease的QQApiVideoObject + @param url 视频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param previewURL 分享内容的预览图像URL + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageURL:(NSURL *)previewURL; + +@end + +// QQApiNewsObject +/** @brief 新闻URL对象 + 用于分享目标内容为新闻的URL的对象 + */ +@interface QQApiNewsObject : QQApiURLObject +/** + 获取一个autorelease的QQApiNewsObject + @param url 视频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param data 分享内容的预览图像 + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageData:(NSData *)data; +/** + 获取一个autorelease的QQApiNewsObject + @param url 视频内容的目标URL + @param title 分享内容的标题 + @param description 分享内容的描述 + @param previewURL 分享内容的预览图像URL + @note 如果url为空,调用QQApi#sendMessage:时将返回FALSE + */ ++ (id)objectWithURL:(NSURL *)url title:(NSString *)title description:(NSString *)description previewImageURL:(NSURL *)previewURL; + +@end + +// QQApiCommonContentObject; +/** @brief 通用模板类型对象 + 用于分享一个固定显示模板的图文混排对象 + @note 图片列表和文本列表不能同时为空 + */ +@interface QQApiCommonContentObject : QQApiObject +/** + 预定义的界面布局类型 + */ +@property (nonatomic,assign) unsigned int layoutType; +@property (nonatomic, copy) NSData *previewImageData;///<预览图 +@property (nonatomic, copy) NSArray *textArray;///<文本列表 +@property (nonatomic, copy) NSArray *pictureDataArray;///<图片列表 ++ (id)objectWithLayoutType:(int)layoutType textArray:(NSArray *)textArray pictureArray:(NSArray *)pictureArray previewImageData:(NSData *)data; +/** + 将一个NSDictionary对象转化为QQApiCommomContentObject,如果无法转换,则返回空 + */ ++ (id)objectWithDictionary:(NSDictionary *)dic; +- (NSDictionary *)toDictionary; +@end + +// QQApiExtraServiceObject; 通用业务消息处理类(旧版,后续使用QQApiCommonServiceObject) +/** + @brief OpenSDK扩展支持的服务,通用接口,后续会扩充能力 + @param serviceID [必选] 扩展支持的服务类型ID,参考官方文档说明 + @param openID [必选] 授权登录后对该用户的唯一标识 + @param toUin [可选] 对方的QQ号码 + @param extraInfo [可选] 扩展字段 + @note 该接口的使用须先登录 + */ +@interface QQApiExtraServiceObject : QQApiObject +@property (nonatomic, copy) NSString *serviceID; +@property (nonatomic, copy) NSString *openID; +@property (nonatomic, copy) NSString *toUin; +@property (nonatomic, copy) NSDictionary *extraInfo; + +- (id)initWithOpenID:(NSString *)openID serviceID:(NSString *)serviceID; ++ (id)objecWithOpenID:(NSString *)openID serviceID:(NSString *)serviceID; +@end + + +/** + * QQApiCommonServiceObject; 通用业务消息处理类(新),可以适用所有的需要通过互联SDK发消息给手Q的业务去处理。 + * 使用前需要申请serviceID,每个业务功能有个对应的serviceID + */ +@interface QQApiCommonServiceObject : QQApiObject + +// [必选] 授权登录后对该用户的唯一标识 +@property (nonatomic, copy) NSString *openID; + +// [必选] 扩展支持的服务类型ID,参考官方文档说明 +@property (nonatomic, copy) NSString *serviceID; + +// [可选] 扩展字段,由调用方跟具体的业务方协定具体的字段 +@property (nonatomic, copy) NSDictionary *extendInfo; + +- (instancetype)initWithOpenID:(NSString *)openID + serviceID:(NSString *)serviceID + extendInfo:(NSDictionary *)extendInfo; + ++ (instancetype)objecWithOpenID:(NSString *)openID + serviceID:(NSString *)serviceID + extendInfo:(NSDictionary *)extendInfo; + +@end + + +#pragma mark - QQApi请求消息类型 + +/** + QQApi请求消息类型 + */ +typedef NS_ENUM(NSUInteger, QQApiInterfaceReqType) { + EGETMESSAGEFROMQQREQTYPE = 0, /// < 手Q -> 第三方应用,请求第三方应用向手Q发送消息 + ESENDMESSAGETOQQREQTYPE = 1, /// < 第三方应用 -> 手Q,第三方应用向手Q分享消息 + ESHOWMESSAGEFROMQQREQTYPE = 2, /// < 手Q -> 第三方应用,请求第三方应用展现消息中的数据 + ESENDMESSAGEARKTOQQREQTYPE = 3, /// < 第三方应用 -> 手Q,第三方应用向手Q分享Ark消息 + ESENDMESSAGE_MINI_TOQQREQTYPE = 4 /// < 第三方应用 -> 手Q,第三方应用向手Q分享小程序消息 +}; + +/** + QQApi应答消息类型 + */ +typedef NS_ENUM(NSUInteger, QQApiInterfaceRespType) { + ESHOWMESSAGEFROMQQRESPTYPE = 0, /// < 第三方应用 -> 手Q,第三方应用应答消息展现结果 + EGETMESSAGEFROMQQRESPTYPE = 1, /// < 第三方应用 -> 手Q,第三方应用回应发往手Q的消息 + ESENDMESSAGETOQQRESPTYPE = 2 /// < 手Q -> 第三方应用,手Q应答处理分享消息的结果 +}; + +/** + QQApi请求消息基类 + */ +@interface QQBaseReq : NSObject + +/** 请求消息类型,参见\ref QQApiInterfaceReqType */ +@property (nonatomic, assign) int type; + +@end + +/** + QQApi应答消息基类 + */ +@interface QQBaseResp : NSObject + +/** 请求处理结果 */ +@property (nonatomic, copy) NSString *result; + +/** 具体错误描述信息 */ +@property (nonatomic, copy) NSString *errorDescription; + +/** 应答消息类型,参见\ref QQApiInterfaceRespType */ +@property (nonatomic, assign) int type; + +/** 扩展信息 */ +@property (nonatomic, copy) NSString *extendInfo; + +@end + +/** + GetMessageFromQQReq请求帮助类 + */ +@interface GetMessageFromQQReq : QQBaseReq + +/** + 创建一个GetMessageFromQQReq请求实例 + */ ++ (GetMessageFromQQReq *)req; + +@end + +@interface SendMessageToQQReq : QQBaseReq + +/** + 创建一个SendMessageToQQReq请求实例 + \param message 具体分享消息实例 + \return 新创建的SendMessageToQQReq请求实例 + */ ++ (SendMessageToQQReq *)reqWithContent:(QQApiObject *)message; + +/** + 创建一个支持Ark的SendMessageToQQReq请求实例 + \param message 具体分享消息实例 + \return 新创建的SendMessageToQQReq请求实例 + */ ++ (SendMessageToQQReq *)reqWithArkContent:(ArkObject *)message; +/** + * 创建一个支持小程序的消息请求实例 + * @param miniMessage 小程序实例对象 + * @return 消息请求实例 + */ ++ (SendMessageToQQReq *)reqWithMiniContent:(QQApiMiniProgramObject *)miniMessage; +/** 具体分享消息 */ +@property (nonatomic, strong) QQApiObject *apiObject; + +/** 支持Ark的具体分享消息 */ +@property (nonatomic, strong) ArkObject *arkObject; +/** 支持小程序的具体分享消息 */ +@property (nonatomic, strong) QQApiMiniProgramObject *miniProgramObject; +@end + +/** + SendMessageToQQResp应答帮助类 + */ +@interface SendMessageToQQResp : QQBaseResp + +/** 其他扩展信息 */ +@property (nonatomic, copy) NSDictionary *otherInfo; + +/** + 创建一个SendMessageToQQResp应答实例 + \param result 请求处理结果 + \param errDesp 具体错误描述信息 + \param extendInfo 扩展信息 + \return 新创建的SendMessageToQQResp应答实例 + */ ++ (SendMessageToQQResp *)respWithResult:(NSString *)result errorDescription:(NSString *)errDesp extendInfo:(NSString *)extendInfo; + ++ (SendMessageToQQResp *) respWithResult:(NSString *)result errorDescription:(NSString *)errDesp extendInfo:(NSString *)extendInfo otherInfo:(NSDictionary *)otherInfo; + +@end + +/** + ShowMessageFromQQReq请求帮助类 + */ +@interface ShowMessageFromQQReq : QQBaseReq + +/** + 创建一个ShowMessageFromQQReq请求实例 + \param message 具体待展现消息实例 + \return 新创建的ShowMessageFromQQReq请求实例 + */ ++ (ShowMessageFromQQReq *)reqWithContent:(QQApiObject *)message; + +/** 具体待展现消息 */ +@property (nonatomic, strong) QQApiObject *message; + +@end + +#pragma mark --一键加群&建群&解绑群 +// QQApiThirdAppBindGroupObject +/** \brief 第三方app绑定群 + */ +@interface QQApiThirdAppBindGroupObject : QQApiObject + +@property (nonatomic, copy) NSString *accessToken; +@property (nonatomic, copy) NSString *payToken; +@property (nonatomic, copy) NSString *pfkey; +@property (nonatomic, copy) NSString *unionID; +@property (nonatomic, copy) NSString *appDisplayName; + +- (id)initWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID appDisplayName:(NSString *)appDisplayName; ///<初始化方法 ++ (id)objectWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID appDisplayName:(NSString *)appDisplayName; ///<工厂方法,获取一个QQApiThirdAppBindGroupObject对象. + +@end + +// QQApiThirdAppJoinGroupObject +/** \brief 第三方app加入群 + */ +@interface QQApiThirdAppJoinGroupObject : QQApiObject + +@property (nonatomic, copy) NSString *accessToken; +@property (nonatomic, copy) NSString *payToken; +@property (nonatomic, copy) NSString *pfkey; +@property (nonatomic, copy) NSString *unionID; + +- (id)initWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID; ///<初始化方法 + ++ (id)objectWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID; ///<工厂方法,获取一个QQApiThirdAppJoinGroupObject对象. + +@end + +// QQApiThirdAppUnBindGroupObject +/** \brief 第三方app解绑群 + */ +@interface QQApiThirdAppUnBindGroupObject : QQApiObject + +@property (nonatomic, copy) NSString *accessToken; +@property (nonatomic, copy) NSString *openId; +@property (nonatomic, copy) NSString *payToken; +@property (nonatomic, copy) NSString *pfkey; +@property (nonatomic, copy) NSString *unionID; + +- (id)initWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID openId:(NSString *)openId appId:(NSString *)appId; ///<初始化方法 + ++ (id)objectWithAccessToken:(NSString *)accessToken payToken:(NSString *)payToken pfkey:(NSString *)pfkey unionID:(NSString *)unionID openId:(NSString *)openId appId:(NSString *)appId; ///<工厂方法,获取一个QQApiThirdAppBindGroupObject对象. + +@end + + +#endif diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOAuth.h b/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOAuth.h new file mode 100644 index 0000000..7bdbf8e --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOAuth.h @@ -0,0 +1,555 @@ +/// +/// \file TencentOAuth.h +/// \brief QQ互联开放平台授权登录及相关开放接口实现类 +/// +/// Created by Tencent on 12-12-21. +/// Copyright (c) 2012年 Tencent. All rights reserved. +/// + +#import +#import "SDKDef.h" + +@protocol TencentSessionDelegate; +@protocol TencentLoginDelegate; +@protocol TencentApiInterfaceDelegate; +@protocol TencentWebViewDelegate; + +@class TencentApiReq; +@class TencentApiResp; + +typedef NS_ENUM(NSUInteger, TencentAuthorizeState) { + kTencentNotAuthorizeState, + kTencentSSOAuthorizeState, + kTencentWebviewAuthorzieState, +}; + +typedef NS_ENUM(NSUInteger, TencentAuthMode) { + kAuthModeClientSideToken, + kAuthModeServerSideCode, +}; + +#pragma mark - TencentOAuth(授权登录及相关开放接口调用) + +/** + * \brief TencentOpenAPI授权登录及相关开放接口调用 + * + * TencentOAuth实现授权登录逻辑以及相关开放接口的请求调用 + */ +@interface TencentOAuth : NSObject { + NSMutableDictionary *_apiRequests; +} + +/** Access Token凭证,用于后续访问各开放接口 */ +@property(nonatomic, copy) NSString *accessToken; + +/** Access Token的失效期 */ +@property(nonatomic, strong) NSDate *expirationDate; + +/** 已实现的开放接口的回调委托对象 */ +@property(nonatomic, weak) id sessionDelegate; + +/** 第三方应用在开发过程中设置的URLSchema,用于浏览器登录后后跳到第三方应用 */ +@property(nonatomic, copy) NSString *localAppId; + +/** 用户授权登录后对该用户的唯一标识 */ +@property(nonatomic, copy) NSString *openId; + +/** 用户登录成功过后的跳转页面地址 */ +@property(nonatomic, copy) NSString *redirectURI; + +/** 第三方应用在互联开放平台申请的appID */ +@property(nonatomic, copy) NSString *appId; + +/** 第三方应用在互联开放平台注册的UniversalLink */ +@property(nonatomic, copy) NSString *universalLink; + +/** 主要是互娱的游戏设置uin */ +@property(nonatomic, copy) NSString *uin; + +/** 主要是互娱的游戏设置鉴定票据 */ +@property(nonatomic, copy) NSString *skey; + +/** 登陆透传的数据 */ +@property(nonatomic, copy) NSDictionary *passData; + +/** 授权方式(Client Side Token或者Server Side Code) */ +@property(nonatomic, assign) TencentAuthMode authMode; + +/** union id */ +@property(nonatomic, copy) NSString *unionid; + +/** 第三方在授权登录/分享 时选择 QQ,还是TIM 。在授权前一定要指定其中一个类型*/ +@property(nonatomic, assign) TencentAuthShareType authShareType; + +/** SDK打开web登录页,支持自动填充账号 */ +@property (nonatomic, copy) NSString *defaultUin; + +/** + * 获取上次登录得到的token + * + **/ +- (NSString *)getCachedToken; + +/** + * 获取上次登录得到的openid + * + **/ +- (NSString *)getCachedOpenID; + +/** + * 获取上次登录的token过期日期 + * + **/ +- (NSDate *)getCachedExpirationDate; + +/** + * 上次登录的token是否过期(本地判断) + **/ +- (BOOL)isCachedTokenValid; + +/** + * 删除上次登录登录的token信息 + * + **/ +- (BOOL)deleteCachedToken; + +/** + * 删除openid + * + **/ +- (void)deleteOpenId; + +/** + * 用来获得当前sdk的版本号 + * \return 返回sdk版本号 + **/ + ++ (NSString *)sdkVersion; + +/** + * 用来获得当前sdk的小版本号 + * \return 返回sdk小版本号 + **/ + ++ (NSString *)sdkSubVersion; + +/** + * 用来获得当前sdk的是否精简版 + * @return 返回YES表示精简版 + **/ + ++ (BOOL)isLiteSDK; + +/** + * 主要是用来帮助判断是否有登陆被发起,但是还没有过返回结果 + * \return + * kTencentNotAuthorizeState:无授权 + * kTencentSSOAuthorizeState:有人发起了sso授权但无返回 + * kTencentWebviewAuthorzieState:有人发起了webview授权还未返回 + **/ + ++ (TencentAuthorizeState *)authorizeState; + +/** + * 获取TencentOAuth单例 + */ ++ (instancetype)sharedInstance; + +/** + * 设置SDK参数 + * + * @param appId 不可为nil,第三方应用在互联开放平台申请的唯一标识 + * @param enableUniveralLink 默认为NO,第三方应用是否将sdk和手机QQ的交互方式切换为UniversalLink方式,启用后则在iOS9及以上的系统都会生效UniversalLink方式;否则,默认仅在iOS13及以上的系统生效UniversalLink方式。 + * @param universalLink 可以为nil,第三方应用在互联开放平台注册的UniversalLink,和bundleID一一对应(当为nil时,互联平台会按规则生成UniversalLink,详见官网说明) + * @param delegate 不可为nil,第三方应用用于接收请求返回结果的委托对象 + * + * @note + * 使用说明】 + * 1、支持sdk与手Q的交互切换为UniversalLink模式,主要目的"是为了避免手Q的UrlScheme被其他应用抢注后,导致sdk接口功能受到影响"。 + * 2 、由于手Q版本在 >=8.1.3 后才适配了UniversalLink,所以一旦开启了enabled开关,“务必做到”及时知会用户升级手Q版本。 + * + */ +- (void)setupAppId:(NSString *)appId +enableUniveralLink:(BOOL)enableUniveralLink + universalLink:(NSString *)universalLink + delegate:(id)delegate; + +/** + * 初始化TencentOAuth对象 + * !!!注意:3.5.17版本开始,内部单例实现,多次调用返回同一实例 + * + * @param appId 不可为nil,第三方应用在互联开放平台申请的唯一标识 + * @param delegate 不可为nil,第三方应用用于接收请求返回结果的委托对象 + * @return 初始化后的授权登录对象 + * + * 推荐使用初始化方法,并且适配UniversalLink: + * - (instancetype)initWithAppId:(NSString *)appId + * enableUniveralLink:(BOOL)enabled + * universalLink:(NSString *)universalLink + * delegate:(id)delegate; + */ +- (instancetype)initWithAppId:(NSString *)appId + andDelegate:(id)delegate __attribute__((deprecated("此接口即将下线!请使用setupAppId:enableUniveralLink:universalLink:delegate"))); + +/** + * 初始化TencentOAuth对象(>=3.3.7) + * !!!注意:3.5.17版本开始,内部单例实现,多次调用返回同一实例 + * + * @param appId 不可为nil,第三方应用在互联开放平台申请的唯一标识 + * @param universalLink 可以为nil,第三方应用在互联开放平台注册的UniversalLink,和bundleID一一对应(当为nil时,互联平台会按规则生成universallink,详见官网说明) + * @param delegate 不可为nil,第三方应用用于接收请求返回结果的委托对象 + * @return 初始化后的授权登录对象 + * + * 【使用说明】 + * 1、支持BundleId与UniversalLink的一一对应,主要目的"是为了解决应用的iPhone版本和iPad HD版本共用同一个AppId,导致同时安装情况下的跳转问题"。 + * 2、由于手Q版本在 >=8.1.8 后才支持了这种对应方式,所以一旦使用,“务必做到”及时知会用户升级手Q版本。 + * + * 推荐使用初始化方法,并且适配UniversalLink: + * - (instancetype)initWithAppId:(NSString *)appId + * enableUniveralLink:(BOOL)enabled + * universalLink:(NSString *)universalLink + * delegate:(id)delegate; + * + */ +- (instancetype)initWithAppId:(NSString *)appId + andUniversalLink:(NSString *)universalLink + andDelegate:(id)delegate __attribute__((deprecated("此接口即将下线!请使用setupAppId:enableUniveralLink:universalLink:delegate"))); + +/** + * 初始化TencentOAuth对象(>=3.3.8) + * !!!注意:3.5.17版本开始,内部单例实现,多次调用返回同一实例 + * + * @param appId 不可为nil,第三方应用在互联开放平台申请的唯一标识 + * @param enabled 默认为NO,第三方应用是否将sdk和手机QQ的交互方式切换为UniversalLink方式,启用后则在iOS9及以上的系统都会生效UniversalLink方式;否则,默认仅在iOS13及以上的系统生效UniversalLink方式。 + * @param universalLink 可以为nil,第三方应用在互联开放平台注册的UniversalLink,和bundleID一一对应(当为nil时,互联平台会按规则生成UniversalLink,详见官网说明) + * @param delegate 不可为nil,第三方应用用于接收请求返回结果的委托对象 + * @return 初始化后的授权登录对象 + * + * @note + * 使用说明】 + * 1、支持sdk与手Q的交互切换为UniversalLink模式,主要目的"是为了避免手Q的UrlScheme被其他应用抢注后,导致sdk接口功能受到影响"。 + * 2 、由于手Q版本在 >=8.1.3 后才适配了UniversalLink,所以一旦开启了enabled开关,“务必做到”及时知会用户升级手Q版本。 + * + */ +- (instancetype)initWithAppId:(NSString *)appId + enableUniveralLink:(BOOL)enabled + universalLink:(NSString *)universalLink + delegate:(id)delegate __attribute__((deprecated("此接口即将下线!请使用setupAppId:enableUniveralLink:universalLink:delegate"))); + +/** + * 设置用户是否已经授权同意授权隐私协议,在主体应用中,用户同意授权隐私协议后再初始化互联SDK,默认未同意授权 + * 注意:如未同意授权隐私协议,则互联SDK的所有功能都无法使用,包括初始化!!! + * 从3.5.7版本开始支持该方法 + * + * @param isAgreedAuthorization 是否已经授权,isAgreedAuthorization=YES, 表示已经同意授权;isAgreedAuthorization=NO,表示未同意授权,互联SDK的所有功能都无法使用 + */ ++ (void)setIsUserAgreedAuthorization:(BOOL)isUserAgreedAuthorization; + +/** + * 获取当前用户是否已经同意授权隐私协议 + * 从3.5.7版本开始支持该方法 + */ ++ (BOOL)isUserAgreedAuthorization; + +/** + * 判断用户手机上是否安装手机QQ + * @return YES:安装 NO:没安装 + * + * @note SDK目前已经支持QQ、TIM授权登录及分享功能, 会按照QQ>TIM的顺序进行调用。 + * 只要用户安装了QQ、TIM中任意一个应用,都可为第三方应用进行授权登录、分享功能。 + * 第三方应用在接入SDK时不需要判断是否安装QQ、TIM。若有判断安装QQ、TIM的逻辑建议移除。 + */ ++ (BOOL)iphoneQQInstalled; + +/** + * 判断用户手机上是否安装手机TIM + * @return YES:安装 NO:没安装 + * + * @note SDK目前已经支持QQ、TIM授权登录及分享功能, 会按照QQ>TIM的顺序进行调用。 + * 只要用户安装了QQ、TIM中任意一个应用,都可为第三方应用进行授权登录、分享功能。 + * 第三方应用在接入SDK时不需要判断是否安装QQ、TIM。若有判断安装QQ、TIM的逻辑建议移除。 + */ ++ (BOOL)iphoneTIMInstalled; + +/** + * 发起授权登录,如果安装了QQ APP,则拉起QQ发起授权登录;如果为安装QQ APP则在第三方应用进入H5页面,输入账密授权登录。 + * + * @param permissions 授权信息列 + * @return 发起调用是否成功 + */ +- (BOOL)authorize:(NSArray *)permissions; + +/** + * 发起授权登录, 在第三方应用进入H5页面,显示二维码,通过扫码完成授权登录 + * + * @param permissions 授权信息列 + * @return 发起调用结果 + */ +- (BOOL)authorizeWithQRlogin:(NSArray *)permissions; + +/** + * 增量授权,因用户没有授予相应接口调用的权限,需要用户确认是否授权 + * @param permissions 需增量授权的信息列表 + * @return 发起增量授权调用是否成功 + */ +- (BOOL)incrAuthWithPermissions:(NSArray *)permissions; + +/** + * 重新授权,因token废除或失效导致接口调用失败,需用户重新授权 + * @param permissions 授权信息列表,同登录授权 + * @return 重新授权调用是否成功 + */ +- (BOOL)reauthorizeWithPermissions:(NSArray *)permissions; + +/** + * 获取UnindID,可以根据UnindID的比较来确定OpenID是否属于同一个用户 + * 通过代理方法:- (void)didGetUnionID;返回请求结果。 + * + * @return NO未登录,信息不足;YES条件满足,发送请求成功,请等待回调 + */ +- (BOOL)RequestUnionId; + +/** + * (静态方法)处理应用拉起协议 + * @param url 处理被其他应用呼起时的逻辑 + * @return 处理结果,YES表示成功,NO表示失败 + */ ++ (BOOL)HandleOpenURL:(NSURL *)url; + +/** + * (静态方法)sdk是否可以处理应用拉起协议 + * @param url 处理被其他应用呼起时的逻辑 + * @return 处理结果,YES表示可以 NO表示不行 + */ ++ (BOOL)CanHandleOpenURL:(NSURL *)url; + +/** + * (静态方法)处理应用的UniversalLink拉起协议 + * @param url 处理被其他应用呼起时的逻辑 + * @return 处理结果,YES表示成功,NO表示失败 + */ ++ (BOOL)HandleUniversalLink:(NSURL *)url; + +/** + * (静态方法)sdk是否可以处理应用的Universallink拉起协议 + * @param url 处理被其他应用呼起时的逻辑(应用的Universallink链接须满足官网注册时的格式要求) + * @return 处理结果,YES表示可以 NO表示不行 + * 注:在调用其他Universallink相关处理接口之前,均需进行此项判断 + */ ++ (BOOL)CanHandleUniversalLink:(NSURL *)url; + +/** + * (静态方法)获取TencentOAuth调用的上一次错误信息 + */ ++ (NSString *)getLastErrorMsg; + +/** + * 以Server Side Code模式授权登录时,通过此接口获取返回的code值; + * 以Client Side Token模式授权登录时,忽略此接口。 + */ +- (NSString *)getServerSideCode; + +/** + * 退出登录(退出登录后,TecentOAuth失效,需要重新初始化) + * @param delegate 第三方应用用于接收请求返回结果的委托对象 + */ +- (void)logout:(id)delegate; + +/** + * 判断登录态是否有效 + * @return 处理结果,YES表示有效,NO表示无效,请用户重新登录授权 + */ +- (BOOL)isSessionValid; + +/** + * 获取用户个人信息 + * @return 处理结果,YES表示API调用成功,NO表示API调用失败,登录态失败,重新登录 + */ +- (BOOL)getUserInfo; + +/** + * 退出指定API调用 + * @param userData 用户调用某条API的时候传入的保留参数 + * @return 处理结果,YES表示成功 NO表示失败 + */ +- (BOOL)cancel:(id)userData; + +/** + * CGI类任务创建接口 + * @param apiURL CGI请求的URL地址 + * @param method CGI请求方式:"GET","POST" + * @param params CGI请求参数字典 + * @param callback CGI请求结果的回调接口对象 + * @return CGI请求任务实例,用于取消任务,返回nil代表任务创建失败 + */ +- (TCAPIRequest *)cgiRequestWithURL:(NSURL *)apiURL method:(NSString *)method params:(NSDictionary *)params callback:(id)callback; + +/** + * TencentOpenApi发送任务统一接口 + * @param request 请求发送的任务 + * @param callback 任务发送后的回调地址 + */ +- (BOOL)sendAPIRequest:(TCAPIRequest *)request callback:(id)callback; + +/** + * 获得用户的openId,仅有内存缓存 + * @return 返回openId + */ +- (NSString *)getUserOpenID; + +/** + * 获取appSignToken + * @return 返回appSignToken + */ ++ (NSString *)getAppSignToken; + +/** + * 设置appSignToken,跨进程的应用可以通过该方法手动设置appSignToken + */ ++ (void)setupAppSignToken:(NSString *)appSignToken; + +@end + +#pragma mark - TencentLoginDelegate(授权登录回调协议) + +/** + * \brief TencentLoginDelegate iOS Open SDK 1.3 API回调协议 + * + * 第三方应用实现登录的回调协议 + */ +@protocol TencentLoginDelegate + +@required + +/** + * 登录成功后的回调 + */ +- (void)tencentDidLogin; + +/** + * 登录失败后的回调 + * \param cancelled 代表用户是否主动退出登录 + */ +- (void)tencentDidNotLogin:(BOOL)cancelled; + +/** + * 登录时网络有问题的回调 + */ +- (void)tencentDidNotNetWork; + +@optional +/** + * 登录时权限信息的获得 + */ +- (NSArray *)getAuthorizedPermissions:(NSArray *)permissions withExtraParams:(NSDictionary *)extraParams __attribute__((deprecated("该接口已过期, 建议删除调用"))); + +/** + * unionID获得 + */ +- (void)didGetUnionID; + +/** + * 强制网页登录,包括账号密码登录和二维码登录 + * return YES时,就算本地有手Q也会打开web界面 + */ +- (BOOL)forceWebLogin; + +/* 获得appSignToken回调 */ +- (void)tencentDidGetAppSignToken:(NSString *)appSignToken; + +@end + +#pragma mark - TencentSessionDelegate(开放接口回调协议) + +/** + * \brief TencentSessionDelegate iOS Open SDK 1.3 API回调协议 + * + * 第三方应用需要实现每条需要调用的API的回调协议 + */ +@protocol TencentSessionDelegate + +@optional + +/** + * 退出登录的回调 + */ +- (void)tencentDidLogout; + +/** + * 因用户未授予相应权限而需要执行增量授权。在用户调用某个api接口时,如果服务器返回操作未被授权,则触发该回调协议接口,由第三方决定是否跳转到增量授权页面,让用户重新授权。 + * @param tencentOAuth 登录授权对象。 + * @param permissions 需增量授权的权限列表。 + * @return 是否仍然回调返回原始的api请求结果。 + * @note 不实现该协议接口则默认为不开启增量授权流程。若需要增量授权请调用\ref TencentOAuth#incrAuthWithPermissions: \n注意:增量授权时用户可能会修改登录的帐号 + */ +- (BOOL)tencentNeedPerformIncrAuth:(TencentOAuth *)tencentOAuth withPermissions:(NSArray *)permissions; + +/** + * [该逻辑未实现]因token失效而需要执行重新登录授权。在用户调用某个api接口时,如果服务器返回token失效,则触发该回调协议接口,由第三方决定是否跳转到登录授权页面,让用户重新授权。 + * @param tencentOAuth 登录授权对象。 + * @return 是否仍然回调返回原始的api请求结果。 + * @note 不实现该协议接口则默认为不开启重新登录授权流程。若需要重新登录授权请调用\ref TencentOAuth#reauthorizeWithPermissions: \n注意:重新登录授权时用户可能会修改登录的帐号 + */ +- (BOOL)tencentNeedPerformReAuth:(TencentOAuth *)tencentOAuth; + +/** + * 用户通过增量授权流程重新授权登录,token及有效期限等信息已被更新。 + * @param tencentOAuth token及有效期限等信息更新后的授权实例对象 + * @note 第三方应用需更新已保存的token及有效期限等信息。 + */ +- (void)tencentDidUpdate:(TencentOAuth *)tencentOAuth; + +/** + * 用户增量授权过程中因取消或网络问题导致授权失败 + * @param reason 授权失败原因,具体失败原因参见sdkdef.h文件中\ref UpdateFailType + */ +- (void)tencentFailedUpdate:(UpdateFailType)reason; + +/** + * 获取用户个人信息回调 + * @param response API返回结果,具体定义参见sdkdef.h文件中\ref APIResponse + * @remarks 正确返回示例: \snippet example/getUserInfoResponse.exp success + * 错误返回示例: \snippet example/getUserInfoResponse.exp fail + */ +- (void)getUserInfoResponse:(APIResponse*) response; + +/** + * 社交API统一回调接口 + * @param response API返回结果,具体定义参见sdkdef.h文件中\ref APIResponse + * @param message 响应的消息,目前支持‘SendStory’,‘AppInvitation’,‘AppChallenge’,‘AppGiftRequest’ + */ +- (void)responseDidReceived:(APIResponse*)response forMessage:(NSString *)message; + +/** + * post请求的上传进度 + * @param tencentOAuth 返回回调的tencentOAuth对象 + * @param bytesWritten 本次回调上传的数据字节数 + * @param totalBytesWritten 总共已经上传的字节数 + * @param totalBytesExpectedToWrite 总共需要上传的字节数 + * @param userData 用户自定义数据 + */ +- (void)tencentOAuth:(TencentOAuth *)tencentOAuth didSendBodyData:(NSInteger)bytesWritten totalBytesWritten:(NSInteger)totalBytesWritten totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite userData:(id)userData; + + +/** + * 通知第三方界面需要被关闭 + * @param tencentOAuth 返回回调的tencentOAuth对象 + * @param viewController 需要关闭的viewController + */ +- (void)tencentOAuth:(TencentOAuth *)tencentOAuth doCloseViewController:(UIViewController *)viewController; + +@end + +#pragma mark - TencentWebViewDelegate(H5登录webview旋转方向回调) + +/** + * @brief TencentWebViewDelegate: H5登录webview旋转方向回调协议 + * + * 第三方应用可以根据自己APP的旋转方向限制,通过此协议设置 + */ +@protocol TencentWebViewDelegate +@optional +- (BOOL) tencentWebViewShouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation; +- (NSUInteger) tencentWebViewSupportedInterfaceOrientationsWithWebkit; +- (BOOL) tencentWebViewShouldAutorotateWithWebkit; +@end diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOpenApiUmbrellaHeader.h b/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOpenApiUmbrellaHeader.h new file mode 100644 index 0000000..6a8a10e --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/TencentOpenApiUmbrellaHeader.h @@ -0,0 +1,24 @@ +// +// TencentOpenApiUmbrellaHeader.h +// TencentOpenApi_IOS +// +// Created by jyukeizhang(张储祺) on 2020/7/27. +// Copyright © 2020 Tencent. All rights reserved. +// + +#ifndef TencentOpenApiUmbrellaHeader_h +#define TencentOpenApiUmbrellaHeader_h + +#import + +FOUNDATION_EXPORT double StaticLibraryModuleVersionNumber; +FOUNDATION_EXPORT const unsigned char StaticLibraryModuleVersionString[]; + +#import "QQApiInterface.h" +#import "QQApiInterfaceObject.h" +#import "SDKDef.h" +#import "TencentOAuth.h" + + +#endif /* TencentOpenApiUmbrellaHeader_h */ + diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Headers/sdkdef.h b/iosApp/Vendor/TencentOpenAPI.framework/Headers/sdkdef.h new file mode 100644 index 0000000..bb56770 --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Headers/sdkdef.h @@ -0,0 +1,420 @@ +/// +/// \file sdkdef.h +/// \brief SDK中相关常量定义 +/// +/// Created by Tencent on 12-12-25. +/// Copyright (c) 2012年 Tencent. All rights reserved. +/// + +#import +#import + +/** + * \brief 设置sdk的log等级 + */ +typedef enum { + TCOLogLevel_Disabled = -1, // 关闭所有log + TCOLogLevel_Error = 0, + TCOLogLevel_Warning, + TCOLogLevel_Info, + TCOLogLevel_Debug, +} TCOLogLevel; + +/** + * \breif 授权/分享 方式 + */ +typedef enum TencentAuthShareType { + AuthShareType_QQ, + AuthShareType_TIM, +}TencentAuthShareType; + +/** + * \brief APIResponse.retCode可能的枚举常量 + */ +typedef enum +{ + URLREQUEST_SUCCEED = 0, /**< 网络请求成功发送至服务器,并且服务器返回数据格式正确 + * \note 这里包括所请求业务操作失败的情况,例如没有授权等原因导致 + */ + + URLREQUEST_FAILED = 1, /**< 网络异常,或服务器返回的数据格式不正确导致无法解析 */ +} REPONSE_RESULT; + +/** + * \brief 增量授权失败原因 + * + * \note 增量授权失败不影响原token的有效性(原token已失效的情况除外) + */ +typedef enum +{ + kUpdateFailUnknown = 1, ///< 未知原因 + kUpdateFailUserCancel, ///< 用户取消 + kUpdateFailNetwork, ///< 网络问题 +} UpdateFailType; + +/** + * \brief 封装服务器返回的结果 + * + * APIResponse用于封装所有请求的返回结果,包括错误码、错误信息、原始返回数据以及返回数据的json格式字典 + */ +@interface APIResponse : NSObject { + int _detailRetCode; + int _retCode; + int _seq; + NSString *_errorMsg; + NSDictionary *_jsonResponse; + NSString *_message; + id _userData; +} + +/** + * 新增的详细错误码\n + * detailRetCode主要用于区分不同的错误情况,参见\ref OpenSDKError + */ +@property (nonatomic, assign) int detailRetCode; + +/** + * 网络请求是否成功送达服务器,以及服务器返回的数据格式是否正确\n + * retCode具体取值可参考\ref REPONSE_RESULT + */ +@property (nonatomic, assign) int retCode; + +/** + * 网络请求对应的递增序列号,方便内部管理 + */ +@property (nonatomic, assign) int seq; + +/** + * 错误提示语 + */ +@property (nonatomic, copy) NSString *errorMsg; + +/** + * 服务器返回数据的json格式字典\n + * 字典内具体参数的命名和含义请参考\ref api_spec + */ +@property (nonatomic, copy) NSDictionary *jsonResponse; + +/** + * 服务器返回的原始数据字符串 + */ +@property (nonatomic, copy) NSString *message; + +/** + * 用户保留数据 + */ +@property (nonatomic, strong) id userData; + +@end + + +/** + * 用户自定义的保留字段 + */ +FOUNDATION_EXTERN NSString * const PARAM_USER_DATA; + +/** + * \name 应用邀请参数字段定义 + */ +///@{ + +/** 应用邀请展示图片url的key */ +FOUNDATION_EXTERN NSString * const PARAM_APP_ICON; + +/** 应用邀请描述文本的key */ +FOUNDATION_EXTERN NSString * const PARAM_APP_DESC; + +/** 应用邀请好友列表的key */ +FOUNDATION_EXTERN NSString * const PARAM_APP_INVITED_OPENIDS; + +///@} + +/** + * \name sendStory新分享参数字段定义 + */ +///@{ + +/** 预填入接受人列表的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_RECEIVER; + +/** 分享feeds标题的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_TITLE; + +/** 分享feeds评论内容的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_COMMENT; + +/** 分享feeds摘要的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_SUMMARY; + +/** 分享feeds展示图片url的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_IMAGE; + +/** 分享feeds跳转链接url的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_URL; + +/** 分享feeds点击操作默认行为的key */ +FOUNDATION_EXTERN NSString * const PARAM_SENDSTORY_ACT; + +///@} + +/** + * \name 设置头像参数字段定义 + */ +///@{ + +/** 头像图片数据的key */ +FOUNDATION_EXTERN NSString * const PARAM_SETUSERHEAD_PIC; + +/** 头像图片文件名的key */ +FOUNDATION_EXTERN NSString * const PARAM_SETUSERHEAD_FILENAME; + +///@} + +/** + * \name 服务器返回数据的参数字段定义 + */ +///@{ + +/** 服务器返回码的key */ +FOUNDATION_EXTERN NSString * const PARAM_RETCODE; + +/** 服务器返回错误信息的key */ +FOUNDATION_EXTERN NSString * const PARAM_MESSAGE; + +/** 服务器返回额外数据的key */ +FOUNDATION_EXTERN NSString * const PARAM_DATA; + +///@} + +/** + * \name 错误信息相关常量定义 + */ +///@{ + +/** 详细错误信息字典中额外信息的key */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorKeyExtraInfo; + +/** 详细错误信息字典中返回码的key */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorKeyRetCode; + +/** 详细错误信息字典中错误语句的key */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorKeyMsg; + +/** 不支持的接口 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgUnsupportedAPI; + +/** 操作成功 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgSuccess; + +/** 未知错误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgUnknown; + +/** 用户取消 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgUserCancel; + +/** 请重新登录 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgReLogin; + +/** 应用没有操作权限 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgOperationDeny; + +/** 网络异常或没有网络 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgNetwork; + +/** URL格式或协议错误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgURL; + +/** 解析数据出错 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgDataParse; + +/** 传入参数有误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgParam; + +/** 连接超时 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgTimeout; + +/** 安全问题 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgSecurity; + +/** 文件读写错误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgIO; + +/** 服务器端错误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgServer; + +/** 页面错误 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgWebPage; + +/** 设置头像图片过大 */ +FOUNDATION_EXTERN NSString * const TCOpenSDKErrorMsgUserHeadPicLarge; + +/** 用户未同意授权隐私协议 */ +FOUNDATION_EXPORT NSString * const TCOpenSDKErrorMsgUserNotAgreedAuthorization; + +///@} + +/** + * \brief SDK新增详细错误常量 + */ +typedef enum +{ + kOpenSDKInvalid = -1, ///< 无效的错误码 + kOpenSDKErrorUnsupportedAPI = -2, ///< 不支持的接口 + + /** + * \name CommonErrorCode + * 公共错误码 + */ + ///@{ + kOpenSDKErrorSuccess = 0, ///< 成功 + kOpenSDKErrorUnknown, ///< 未知错误 + kOpenSDKErrorUserCancel, ///< 用户取消 + kOpenSDKErrorReLogin, ///< token无效或用户未授权相应权限需要重新登录 + kOpenSDKErrorOperationDeny, ///< 第三方应用没有该api操作的权限 + ///@} + + /** + * \name NetworkRelatedErrorCode + * 网络相关错误码 + */ + ///@{ + kOpenSDKErrorNetwork, ///< 网络错误,网络不通或连接不到服务器 + kOpenSDKErrorURL, ///< URL格式或协议错误 + kOpenSDKErrorDataParse, ///< 数据解析错误,服务器返回的数据解析出错 + kOpenSDKErrorParam, ///< 传入参数错误 + kOpenSDKErrorConnTimeout, ///< http连接超时 + kOpenSDKErrorSecurity, ///< 安全问题 + kOpenSDKErrorIO, ///< 下载和文件IO错误 + kOpenSDKErrorServer, ///< 服务器端错误 + ///@} + + /** + * \name WebViewRelatedError + * webview特有错误 + */ + ///@{ + kOpenSDKErrorWebPage, ///< 页面错误 + ///@} + + /** + * \name SetUserHeadRelatedErrorCode + * 设置头像自定义错误码段 + */ + ///@{ + kOpenSDKErrorUserHeadPicLarge = 0x010000, ///< 图片过大 设置头像自定义错误码 + ///@} +} OpenSDKError; + +/** + * \name SDK版本(v1.3)支持的授权列表常量 + */ +///@{ + +/** 发表一条说说到QQ空间(需要申请权限) */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_ADD_TOPIC; + +/** 创建一个QQ空间相册(需要申请权限) */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_ADD_ALBUM; + +/** 上传一张照片到QQ空间相册(需要申请权限) */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_UPLOAD_PIC; + +/** 获取用户QQ空间相册列表(需要申请权限) */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_LIST_ALBUM; + +/** 验证是否认证空间粉丝 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_CHECK_PAGE_FANS; + +/** 获取登录用户自己的详细信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_INFO; + +/** 获取其他用户的详细信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_OTHER_INFO; + +/** 获取会员用户基本信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_VIP_INFO; + +/** 获取会员用户详细信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_VIP_RICH_INFO; + +/** 获取用户信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_USER_INFO; + +/** 移动端获取用户信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_GET_SIMPLE_USER_INFO; + +/** 移动端获取用户信息 */ +FOUNDATION_EXTERN NSString *const kOPEN_PERMISSION_ALL; +///@} + + +/** + * \name CGI接口相关参数类型定义 + */ + +/** 必填的字符串类型参数 */ +typedef NSString *TCRequiredStr; + +/** 必填的UIImage类型参数 */ +typedef UIImage *TCRequiredImage; + +/** 必填的整型参数 */ +typedef NSInteger TCRequiredInt; + +/** 必填的数字类型 */ +typedef NSNumber *TCRequiredNumber; + +/** 必填的NSData参数 */ +typedef NSData *TCRequiredData; + +/** 可选的字符串类型参数 */ +typedef NSString *TCOptionalStr; + +/** 可选的UIImage类型参数 */ +typedef UIImage *TCOptionalImage; + +/** 可选的整型参数 */ +typedef NSInteger TCOptionalInt; + +/** 可选的数字类型 */ +typedef NSNumber *TCOptionalNumber; + +/** 可选的不定类型参数 */ +typedef id TCRequiredId; +///@} + + +/** + * \brief CGI请求的参数字典封装辅助基类 + * + * 将相应属性的值以key-value的形式保存到参数字典中 + */ +@interface TCAPIRequest : NSMutableDictionary + +/** CGI请求的URL地址 */ +@property (nonatomic, readonly) NSURL *apiURL; + +/** CGI请求方式:"GET","POST" */ +@property (nonatomic, readonly) NSString *method; + +/** + * API参数中的保留字段,可以塞入任意字典支持的类型,再调用完成后会带回给调用方 + */ +@property (nonatomic, strong) TCRequiredId paramUserData; + +/** + * APIResponse,API的返回结果 + */ +@property (nonatomic, readonly) APIResponse *response; + +/** 取消相应的CGI请求任务 */ +- (void)cancel; + +@end + +@protocol TCAPIRequestDelegate +@optional +- (void)cgiRequest:(TCAPIRequest *)request didResponse:(APIResponse *)response; + +@end + diff --git a/iosApp/Vendor/TencentOpenAPI.framework/Modules/module.modulemap b/iosApp/Vendor/TencentOpenAPI.framework/Modules/module.modulemap new file mode 100644 index 0000000..d504d26 --- /dev/null +++ b/iosApp/Vendor/TencentOpenAPI.framework/Modules/module.modulemap @@ -0,0 +1,4 @@ +module TencentOpenApi{ + umbrella header "TencentOpenApiUmbrellaHeader.h" + export * +} diff --git a/iosApp/Vendor/TencentOpenAPI.framework/TencentOpenAPI b/iosApp/Vendor/TencentOpenAPI.framework/TencentOpenAPI new file mode 100644 index 0000000..1140e4a Binary files /dev/null and b/iosApp/Vendor/TencentOpenAPI.framework/TencentOpenAPI differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/Info.plist b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/Info.plist new file mode 100644 index 0000000..4a1632e Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/Info.plist differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel.png new file mode 100755 index 0000000..ca93fe3 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel_hover.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel_hover.png new file mode 100755 index 0000000..7dbc632 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/btn_cancel_hover.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh.png new file mode 100644 index 0000000..8738ff5 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh@2x.png new file mode 100644 index 0000000..b40c0af Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/refresh@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan.png new file mode 100755 index 0000000..6c036c5 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan@2x.png new file mode 100755 index 0000000..9f5a6ac Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_pengyouquan@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq.png new file mode 100755 index 0000000..6bb555d Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq@2x.png new file mode 100755 index 0000000..47b2a7b Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qq@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone.png new file mode 100755 index 0000000..a0583d5 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone@2x.png new file mode 100755 index 0000000..77bfc1d Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_qzone@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari.png new file mode 100644 index 0000000..8cb94ba Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari@2x.png new file mode 100644 index 0000000..64c8a29 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_safari@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin.png new file mode 100755 index 0000000..b761c06 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin@2x.png new file mode 100755 index 0000000..bc2df83 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/share_weixin@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back.png new file mode 100755 index 0000000..89ef3f1 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back@2x.png new file mode 100755 index 0000000..03a49b7 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_back@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame.png new file mode 100755 index 0000000..d03324e Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame@2x.png new file mode 100755 index 0000000..48bc652 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backtogame@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward.png new file mode 100755 index 0000000..da7806d Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward@2x.png new file mode 100755 index 0000000..b5f8a4c Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_backward@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for.png new file mode 100644 index 0000000..acc433c Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for@2x.png new file mode 100644 index 0000000..1429464 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_for@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward.png new file mode 100755 index 0000000..0ade455 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward@2x.png new file mode 100755 index 0000000..e19646d Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_forward@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more.png new file mode 100755 index 0000000..61cd374 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more@2x.png new file mode 100755 index 0000000..24b6573 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_more@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand.png new file mode 100644 index 0000000..e5b1cbe Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand@2x.png new file mode 100644 index 0000000..cbd268f Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_recommand@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop.png new file mode 100755 index 0000000..fb87477 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop@2x.png new file mode 100755 index 0000000..9ae2038 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_stop@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg.png new file mode 100755 index 0000000..0dca23f Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg@2x.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg@2x.png new file mode 100755 index 0000000..5d5df6c Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/WebViewResources/webview_toolbg@2x.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/error.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/error.png new file mode 100644 index 0000000..b0ce461 Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/error.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/local.html b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/local.html new file mode 100644 index 0000000..4a1cf42 --- /dev/null +++ b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/local.html @@ -0,0 +1,73 @@ + + + + + +社交渠道 + + + + + + +
+
+

+ 返回 + +
+
+
+ + + diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/qqicon.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/qqicon.png new file mode 100644 index 0000000..65c743c Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/qqicon.png differ diff --git a/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/success.png b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/success.png new file mode 100644 index 0000000..df3899d Binary files /dev/null and b/iosApp/Vendor/TencentOpenApi_IOS_Bundle.bundle/success.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/Contents.json new file mode 100644 index 0000000..6c18771 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "widget_fav_click.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "widget_fav_click 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "widget_fav_click 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 1.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 1.png new file mode 100644 index 0000000..3cff4d1 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 1.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 2.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 2.png new file mode 100644 index 0000000..3cff4d1 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click 2.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click.png new file mode 100644 index 0000000..3cff4d1 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteClick.imageset/widget_fav_click.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/Contents.json new file mode 100644 index 0000000..667885f --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "widget_fav_unclick.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "widget_fav_unclick 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "widget_fav_unclick 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 1.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 1.png new file mode 100644 index 0000000..31219ba Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 1.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 2.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 2.png new file mode 100644 index 0000000..31219ba Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick 2.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick.png b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick.png new file mode 100644 index 0000000..31219ba Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/FavoriteUnClick.imageset/widget_fav_unclick.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/Contents.json new file mode 100644 index 0000000..53a5106 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "player_play_next.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/player_play_next.svg b/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/player_play_next.svg new file mode 100644 index 0000000..58ca5e6 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/PlayerNext.imageset/player_play_next.svg @@ -0,0 +1,6 @@ + + + diff --git a/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/Contents.json new file mode 100644 index 0000000..884c5f6 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "player_play_prev.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/player_play_prev.svg b/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/player_play_prev.svg new file mode 100644 index 0000000..668cb32 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/PlayerPrev.imageset/player_play_prev.svg @@ -0,0 +1,8 @@ + + + diff --git a/iosApp/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/Contents.json new file mode 100644 index 0000000..36bcc40 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "cd.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "cd 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "cd 2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 1.png b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 1.png new file mode 100644 index 0000000..be380c8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 1.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 2.png b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 2.png new file mode 100644 index 0000000..be380c8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd 2.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd.png b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd.png new file mode 100644 index 0000000..be380c8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_black.imageset/cd.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/Contents.json new file mode 100644 index 0000000..36bcc40 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "cd.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "cd 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "cd 2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 1.png b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 1.png new file mode 100644 index 0000000..b630991 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 1.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 2.png b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 2.png new file mode 100644 index 0000000..b630991 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd 2.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd.png b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd.png new file mode 100644 index 0000000..b630991 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/cd_white.imageset/cd.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/Contents.json b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..16dfee5 --- /dev/null +++ b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "logo.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "logo 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "logo 2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 1.png b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 1.png new file mode 100644 index 0000000..88cb6d8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 1.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 2.png b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 2.png new file mode 100644 index 0000000..88cb6d8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo 2.png differ diff --git a/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo.png b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo.png new file mode 100644 index 0000000..88cb6d8 Binary files /dev/null and b/iosApp/WidgetExtension/Assets.xcassets/logo.imageset/logo.png differ diff --git a/iosApp/WidgetExtension/BackgroundIntent.swift b/iosApp/WidgetExtension/BackgroundIntent.swift index 6320d02..ab44d7d 100644 --- a/iosApp/WidgetExtension/BackgroundIntent.swift +++ b/iosApp/WidgetExtension/BackgroundIntent.swift @@ -3,7 +3,7 @@ import Foundation @available(iOS 17, *) public struct BackgroundIntent: AppIntent { - static public var title: LocalizedStringResource = "LLMP Widget Action" + static public var title: LocalizedStringResource = "HomeWidget Background Intent" @Parameter(title: "Widget URI") var url: URL? @@ -16,7 +16,7 @@ public struct BackgroundIntent: AppIntent { public func perform() async throws -> some IntentResult { NotificationCenter.default.post( - name: Notification.Name("fromWidgetToHost"), + name: Notification.Name("fromWigetToRunner"), object: nil, userInfo: ["url": url?.absoluteString ?? ""] ) @@ -24,6 +24,7 @@ public struct BackgroundIntent: AppIntent { } } +/// 在宿主进程中继续执行,使小组件点击能通知主 App(对齐 Flutter Runner/BackgroundIntent)。 @available(iOS 17, *) @available(iOSApplicationExtension, unavailable) extension BackgroundIntent: ForegroundContinuableIntent {} diff --git a/iosApp/WidgetExtension/HomeWidgetExample.swift b/iosApp/WidgetExtension/HomeWidgetExample.swift index 89581f3..e0bb9e5 100644 --- a/iosApp/WidgetExtension/HomeWidgetExample.swift +++ b/iosApp/WidgetExtension/HomeWidgetExample.swift @@ -1,48 +1,41 @@ +// +// BackgroundIntent.swift +// Runner +// +// Created by hoshizora-rin on 2024/9/19. +// + import SwiftUI import WidgetKit -private let widgetGroupId = "group.top.zhushenwudi.llmp" -private var lastLyricLine1 = "" -private var lastLyricLine2 = "" +private let widgetGroupId = "group.com.zhushenwudi.lovelivemusicplayer" struct Provider: TimelineProvider { func placeholder(in context: Context) -> ExampleEntry { - ExampleEntry( - date: Date(), - widgetFamily: context.family, - songName: "", - songArtist: "", - isFavorite: false, - isPlaying: false, - playText: "", - lyricLine1: "", - lyricLine2: "", - currentLine: 1, - isShutdown: true, - bgColor: "255,255,255" - ) + sampleEntry(family: context.family, isPreview: true) } func getSnapshot(in context: Context, completion: @escaping (ExampleEntry) -> Void) { - completion(readEntry(family: context.family)) + // 与 Flutter 一样读 App Group;isPreview 仅用于避免 Button(intent) 把添加页打成鱼骨。 + completion(readEntry(family: context.family, isPreview: true)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - completion(Timeline(entries: [readEntry(family: context.family)], policy: .atEnd)) + // 数据由主应用写入 App Group 后主动 reload;不要让单条当前时间的 + // timeline 以 .atEnd 反复申请无意义的系统刷新预算。 + completion(Timeline(entries: [readEntry(family: context.family, isPreview: false)], policy: .never)) } - private func readEntry(family: WidgetFamily) -> ExampleEntry { + private func readEntry(family: WidgetFamily, isPreview: Bool) -> ExampleEntry { let data = UserDefaults(suiteName: widgetGroupId) let songName = data?.string(forKey: "songName") ?? "" let songArtist = data?.string(forKey: "songArtist") ?? "" let isFavorite = data?.bool(forKey: "songFavorite") ?? false let isPlaying = data?.bool(forKey: "isPlaying") ?? false - let playText = data?.string(forKey: "playText") ?? "播放中,已暂停" - if let line1 = data?.string(forKey: "lyricLine1") { lastLyricLine1 = line1 } - if let line2 = data?.string(forKey: "lyricLine2") { lastLyricLine2 = line2 } - let currentLine = data?.integer(forKey: "currentLine") ?? 1 + let playText = data?.string(forKey: "playText") ?? "" let isShutdown = data?.bool(forKey: "isShutdown") ?? true let bgColor = data?.string(forKey: "bgColor") ?? "255,255,255" + return ExampleEntry( date: Date(), widgetFamily: family, @@ -51,15 +44,28 @@ struct Provider: TimelineProvider { isFavorite: isFavorite, isPlaying: isPlaying, playText: playText, - lyricLine1: lastLyricLine1, - lyricLine2: lastLyricLine2, - currentLine: currentLine, isShutdown: isShutdown, - bgColor: bgColor + bgColor: bgColor, + isPreview: isPreview ) } } +private func sampleEntry(family: WidgetFamily, isPreview: Bool) -> ExampleEntry { + ExampleEntry( + date: Date(), + widgetFamily: family, + songName: "", + songArtist: "", + isFavorite: false, + isPlaying: false, + playText: "", + isShutdown: true, + bgColor: "255,255,255", + isPreview: isPreview + ) +} + struct ExampleEntry: TimelineEntry { let date: Date let widgetFamily: WidgetFamily @@ -68,168 +74,303 @@ struct ExampleEntry: TimelineEntry { let isFavorite: Bool let isPlaying: Bool let playText: String - let lyricLine1: String - let lyricLine2: String - let currentLine: Int let isShutdown: Bool let bgColor: String + let isPreview: Bool } struct HomeWidgetExampleEntryView: View { var entry: Provider.Entry - let isWhiteBackground: Bool + var isWhiteBackground = true + private let actionButtonSize: CGFloat = 34 + private let transportIconSize: CGFloat = 20 + + init(entry: Provider.Entry, isWhiteBackground: Bool) { + self.entry = entry + self.isWhiteBackground = isWhiteBackground + } var body: some View { - GeometryReader { geometry in + let canvas = familyCanvasSize let cdSize: CGFloat = 140 - let coverSize = cdSize * 0.55 - let coverAndCdDiffSize = (cdSize - coverSize) / 2 - let playButtonAndCdDiffSize = (cdSize - 34) / 2 - let offsetX = calcCdOffsetX(entry: entry, geometry: geometry) - let offsetY = calcCdOffsetY(entry: entry, geometry: geometry) - let lyricMaxWidth = geometry.size.width - cdSize + let coverSize: CGFloat = cdSize * 0.55 + let coverAndCdDiffSize: CGFloat = (cdSize - coverSize) / 2 + let playButtonAndCdDiffSize: CGFloat = (cdSize - 34) / 2 // width:18 + padding:8x2 + let offsetX = calcCdOffsetX(entry: entry, canvas: canvas) + let offsetY = calcCdOffsetY(entry: entry, canvas: canvas) let bgColor = calcBgColor(entry: entry) let playText = calcPlayText(entry: entry) - + ZStack { + // 填充整个布局颜色 if isWhiteBackground { Rectangle() .fill( AngularGradient( - gradient: Gradient(colors: [ - Color(red: 229/255, green: 233/255, blue: 235/255), - Color(red: 219/255, green: 223/255, blue: 225/255), - bgColor, - ]), + gradient: Gradient( + colors: [ + Color(red: 229/255, green: 233/255, blue: 235/255), + Color(red: 219/255, green: 223/255, blue: 225/255), + bgColor + ] + ), center: .topLeading, - angle: .degrees(225) + angle: .degrees(180 + 45) ) ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { Rectangle() .fill(Color(red: 49/255, green: 49/255, blue: 49/255)) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - + + + // 绘制右上角唱片 ZStack(alignment: .topTrailing) { Image(isWhiteBackground ? "cd_white" : "cd_black") .resizable() + .interpolation(.high) .scaledToFit() .frame(width: cdSize, height: cdSize) + .clipped() .offset(x: offsetX, y: offsetY) - + if let image = loadImageFromFile() { Image(uiImage: image) .resizable() + .interpolation(.high) .scaledToFit() .frame(width: coverSize, height: coverSize) .clipShape(Circle()) + .clipped() .offset(x: -coverAndCdDiffSize + offsetX, y: coverAndCdDiffSize + offsetY) } - - if #available(iOSApplicationExtension 17, *) { - Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_play"))) { - Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill") - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - .foregroundColor(.white) - } - .buttonStyle(.plain) - .padding(8) - .offset(x: -playButtonAndCdDiffSize + offsetX, y: playButtonAndCdDiffSize + offsetY) - } + + playButton( + offsetX: -playButtonAndCdDiffSize + offsetX, + offsetY: playButtonAndCdDiffSize + offsetY + ) } - + .contentTransition(.interpolate) + .animation(.smooth(duration: 0.45), value: entry.isPlaying) + + // 绘制左侧一列歌曲信息 VStack(alignment: .leading) { Image("logo") .resizable() .scaledToFit() .frame(width: 30, height: 30) + .clipped() .padding(.bottom, 5) - - if entry.widgetFamily == .systemMedium { - Text(entry.lyricLine1) - .font(.system(size: 15)) - .foregroundColor(lyricColor(for: entry.currentLine, active: 1)) - .frame(maxWidth: entry.isPlaying ? .infinity : lyricMaxWidth, alignment: .leading) - Text(entry.lyricLine2) - .font(.system(size: 15)) - .foregroundColor(lyricColor(for: entry.currentLine, active: 2)) - .frame(maxWidth: entry.isPlaying ? .infinity : lyricMaxWidth, alignment: .leading) - } - + Spacer() - Text(playText).font(.system(size: 10)).bold().foregroundColor(.gray) - Text(entry.songName).font(.system(size: 12)).bold() - .foregroundColor(isWhiteBackground ? .black : .white) - Text(entry.songArtist).font(.system(size: 12, weight: .light)).foregroundColor(.gray) + + Text(playText) + .font(.system(size: 10)) + .bold() + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, actionReservedWidth) + .padding(.bottom, 3) + + Text(entry.songName) + .font(.system(size: 12)) + .bold() + .foregroundColor(lyricForeground(isPrimary: true)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, actionReservedWidth) + .padding(.bottom, 2) + + Text(entry.songArtist) + .font(.system(size: 12, weight: .light)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, actionReservedWidth) + } .padding() - + VStack { Spacer() - if #available(iOSApplicationExtension 17, *) { - Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_love"))) { - Image(entry.isFavorite ? "FavoriteClick" : "FavoriteUnClick") - .resizable() - .scaledToFit() - .frame(width: 34, height: 34) - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: .bottomTrailing) - .padding(8) - } - } - - if entry.isShutdown { - Link(destination: URL(string: "llmp://")!) { Color.clear } + favoriteButton() } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .widgetURL(shutdownOpenURL) + } + + /// 关机态点卡片打开 App;用 widgetURL 而不是全屏 Link,避免桌面白卡渲染成鱼骨。 + var shutdownOpenURL: URL? { + if entry.isShutdown && !entry.isPreview { + return URL(string: "llmp://") + } + return nil + } + + var familyCanvasSize: CGSize { + switch entry.widgetFamily { + case .systemMedium: + return CGSize(width: 338, height: 158) + default: + return CGSize(width: 158, height: 158) } } - private func lyricColor(for currentLine: Int, active: Int) -> Color { - let highlighted = currentLine == -1 || currentLine == active - if highlighted { - return isWhiteBackground ? .black : .white + var actionReservedWidth: CGFloat { + entry.widgetFamily == .systemMedium ? 110 : 72 + } + + /// 黑卡必须用明确 RGB,系统 `.white` 在 Widget 预览里可能被折成浅色叠在白底上,歌词会消失。 + func lyricForeground(isPrimary: Bool) -> Color { + if isPrimary { + return isWhiteBackground + ? Color(red: 0, green: 0, blue: 0) + : Color(red: 1, green: 1, blue: 1) } return Color(red: 0.7, green: 0.7, blue: 0.7, opacity: 0.7) } - func calcCdOffsetX(entry: ExampleEntry, geometry: GeometryProxy) -> CGFloat { + func calcCdOffsetX(entry: ExampleEntry, canvas: CGSize) -> CGFloat { + var offset = 0.0 switch entry.widgetFamily { - case .systemSmall: return (geometry.size.width - 70) / 2 - case .systemMedium: return (geometry.size.width - 152) / 2 + (entry.isPlaying ? 40 : 0) - default: return 0 + case .systemSmall: + offset = (canvas.width - 70) / 2 + case .systemMedium: + offset = (canvas.width - 152) / 2 + (entry.isPlaying ? 55 : 15) + default: + break } + return offset } - func calcCdOffsetY(entry: ExampleEntry, geometry: GeometryProxy) -> CGFloat { + func calcCdOffsetY(entry: ExampleEntry, canvas: CGSize) -> CGFloat { + var offset = 0.0 switch entry.widgetFamily { - case .systemSmall: return (70 - geometry.size.height) / 2 - case .systemMedium: return (152 - geometry.size.height) / 2 - (entry.isPlaying ? 40 : 0) - default: return 0 + case .systemSmall: + offset = (70 - canvas.height) / 2 + case .systemMedium: + offset = (152 - canvas.height) / 2 - (entry.isPlaying ? 55 : 15) + default: + break } + return offset } - + func calcBgColor(entry: ExampleEntry) -> Color { - let parts = entry.bgColor.split(separator: ",").compactMap { Double($0) } - let rgb = parts.count >= 3 ? parts : [230, 215, 210] - return Color(red: rgb[0]/255, green: rgb[1]/255, blue: rgb[2]/255) + var intColors: [Double] = [230.0, 215.0, 210.0] + if entry.bgColor.contains(",") { + intColors = entry.bgColor.components(separatedBy: ",").compactMap(Double.init) + } + return Color( + red: Double(intColors[0]) / 255, + green: Double(intColors[1]) / 255, + blue: Double(intColors[2]) / 255 + ) } - + func loadImageFromFile() -> UIImage? { - guard let fileURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: widgetGroupId)? - .appendingPathComponent("sharedImage.png") else { return nil } + // 没歌/关机/添加页预览都不该读 App Group 里的旧 sharedImage + //(和 Flutter 共用 group,重装后文件还在)。 + if entry.isPreview || entry.isShutdown || entry.songName.isEmpty { + return nil + } + guard let fileURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: widgetGroupId + )?.appendingPathComponent("sharedImage.png") else { return nil } return UIImage(contentsOfFile: fileURL.path) } - + func calcPlayText(entry: ExampleEntry) -> String { - let parts = entry.playText.split(separator: ",").map(String.init) - let playing = parts.first ?? "播放中" - let paused = parts.count > 1 ? parts[1] : "已暂停" - return entry.isPlaying ? playing : paused + var playTextArr: [String] = [WidgetI18n.t("playing"), WidgetI18n.t("paused")] + if entry.playText.contains(",") { + playTextArr = entry.playText.components(separatedBy: ",") + } + return entry.isPlaying ? playTextArr[0] : playTextArr[1] + } + + @ViewBuilder + func transportButton(assetName: String, action: String) -> some View { + let icon = Image(assetName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundColor(.white) + .frame(width: transportIconSize, height: transportIconSize) + .frame(width: actionButtonSize, height: actionButtonSize) + if entry.isPreview || entry.isShutdown { + icon + } else if #available(iOSApplicationExtension 17, *) { + Button( + intent: BackgroundIntent( + url: URL(string: "homeWidgetExample://\(action)") + ) + ) { + icon + } + .buttonStyle(.plain) + } else { + icon + } + } + + @ViewBuilder + func playButton(offsetX: CGFloat, offsetY: CGFloat) -> some View { + let icon = Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(.white) + // 预览和关机态都不能挂 App Intent,否则桌面白卡会掉回 placeholder 鱼骨。 + if entry.isPreview || entry.isShutdown { + icon + .padding(8) + .offset(x: offsetX, y: offsetY) + } else if #available(iOSApplicationExtension 17, *) { + Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_play"))) { + icon + } + .buttonStyle(.plain) + .padding(8) + .offset(x: offsetX, y: offsetY) + } + } + + @ViewBuilder + func favoriteButton() -> some View { + HStack(spacing: 8) { + if entry.widgetFamily == .systemMedium { + transportButton( + assetName: "PlayerPrev", + action: "previous" + ) + } + transportButton( + assetName: "PlayerNext", + action: "next" + ) + favoriteControl() + } + .frame(maxWidth: .infinity, alignment: .bottomTrailing) + .padding(8) + } + + @ViewBuilder + func favoriteControl() -> some View { + let icon = Image(entry.isFavorite ? "FavoriteClick" : "FavoriteUnClick") + .resizable() + .scaledToFit() + .frame(width: actionButtonSize, height: actionButtonSize) + if entry.isPreview || entry.isShutdown { + icon + } else if #available(iOSApplicationExtension 17, *) { + Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_love"))) { + icon + } + .buttonStyle(.plain) + } else { + icon + } } } @@ -237,14 +378,13 @@ struct HomeWidgetExampleWhite: Widget { let kind = "HomeWidgetExampleWhite" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: true) - .containerBackground(.fill.tertiary, for: .widget) + StaticConfiguration(kind: kind, provider: Provider()) { (entry) in + widgetRoot(entry: entry, isWhiteBackground: true) } - .configurationDisplayName("LLMP音乐组件") - .contentMarginsDisabled() - .supportedFamilies([.systemSmall, .systemMedium]) - .description("LoveLive!媒体播放器") + .configurationDisplayName(WidgetI18n.t("widget_white")) + .contentMarginsDisabled() + .supportedFamilies([.systemSmall, .systemMedium]) + .description(WidgetI18n.t("widget_desc")) } } @@ -252,13 +392,111 @@ struct HomeWidgetExampleBlack: Widget { let kind = "HomeWidgetExampleBlack" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: false) - .containerBackground(.fill.tertiary, for: .widget) + StaticConfiguration(kind: kind, provider: Provider()) { (entry) in + widgetRoot(entry: entry, isWhiteBackground: false) } - .configurationDisplayName("LLMP音乐组件") - .contentMarginsDisabled() - .supportedFamilies([.systemSmall, .systemMedium]) - .description("LoveLive!媒体播放器") + .configurationDisplayName(WidgetI18n.t("widget_black")) + .contentMarginsDisabled() + .supportedFamilies([.systemSmall, .systemMedium]) + .description(WidgetI18n.t("widget_desc")) } } + +@ViewBuilder +private func widgetRoot(entry: ExampleEntry, isWhiteBackground: Bool) -> some View { + let view = HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: isWhiteBackground) + .containerBackground(for: .widget) { + widgetContainerFill(entry: entry, isWhiteBackground: isWhiteBackground) + } + if entry.isPreview { + view.unredacted() + } else { + view + } +} + +@ViewBuilder +private func widgetContainerFill(entry: ExampleEntry, isWhiteBackground: Bool) -> some View { + if isWhiteBackground { + Color(red: 229/255, green: 233/255, blue: 235/255) + } else { + Color(red: 49/255, green: 49/255, blue: 49/255) + } +} + +private func sampleBgColor(_ bgColor: String) -> Color { + var intColors: [Double] = [230.0, 215.0, 210.0] + if bgColor.contains(",") { + intColors = bgColor.components(separatedBy: ",").compactMap(Double.init) + } + return Color( + red: Double(intColors[0]) / 255, + green: Double(intColors[1]) / 255, + blue: Double(intColors[2]) / 255 + ) +} + +/// Widget 扩展未链接 ComposeApp,按系统语言对齐主工程四套文案。 +private enum WidgetI18n { + static func t(_ key: String) -> String { + let table: [String: String] + switch Locale.current.language.languageCode?.identifier { + case "en": + table = [ + "playing": "Playing", + "paused": "Paused", + "widget_white": "LLMP Music Widget (White)", + "widget_black": "LLMP Music Widget (Black)", + "widget_desc": "LoveLive! music player", + ] + case "ja": + table = [ + "playing": "放送中", + "paused": "停止しました", + "widget_white": "LLMP 音楽ウィジェット (白)", + "widget_black": "LLMP 音楽ウィジェット (黒)", + "widget_desc": "LoveLive!メディアプレーヤー", + ] + case "de": + table = [ + "playing": "Wird abgespielt", + "paused": "Pausiert", + "widget_white": "LLMP Musik-Widget (Weiß)", + "widget_black": "LLMP Musik-Widget (Schwarz)", + "widget_desc": "LoveLive! Musikplayer", + ] + default: + table = [ + "playing": "播放中", + "paused": "已暂停", + "widget_white": "LLMP 音乐组件 (白)", + "widget_black": "LLMP 音乐组件 (黑)", + "widget_desc": "LoveLive!媒体播放器", + ] + } + return table[key] ?? key + } +} + +#Preview(as: .systemSmall) { + HomeWidgetExampleWhite() +} timeline: { + ExampleEntry( + date: Date(), + widgetFamily: .systemSmall, + songName: "Song Name", + songArtist: "Song Artist", + isFavorite: true, + isPlaying: true, + playText: "playing,paused", + isShutdown: true, + bgColor: "255,255,255", + isPreview: true + ) +} + +#Preview(as: .systemMedium) { + HomeWidgetExampleWhite() +} timeline: { + sampleEntry(family: .systemMedium, isPreview: true) +} diff --git a/iosApp/WidgetExtension/HomeWidgetExampleBundle.swift b/iosApp/WidgetExtension/HomeWidgetExampleBundle.swift index ff605fa..12f5f32 100644 --- a/iosApp/WidgetExtension/HomeWidgetExampleBundle.swift +++ b/iosApp/WidgetExtension/HomeWidgetExampleBundle.swift @@ -1,5 +1,12 @@ -import SwiftUI +// +// HomeWidgetExampleBundle.swift +// HomeWidgetExample +// +// Created by hoshizora-rin on 2024/9/19. +// + import WidgetKit +import SwiftUI @main struct HomeWidgetExampleBundle: WidgetBundle { diff --git a/iosApp/WidgetExtension/HomeWidgetExampleControl.swift b/iosApp/WidgetExtension/HomeWidgetExampleControl.swift new file mode 100644 index 0000000..a4c6ba0 --- /dev/null +++ b/iosApp/WidgetExtension/HomeWidgetExampleControl.swift @@ -0,0 +1,81 @@ +// +// HomeWidgetExampleControl.swift +// HomeWidgetExample +// +// Created by hoshizora-rin on 2024/9/19. +// + +import AppIntents +import SwiftUI +import WidgetKit + +@available(iOS 18.0, *) +struct HomeWidgetExampleControl: ControlWidget { + static let kind: String = "com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +@available(iOS 18.0, *) +extension HomeWidgetExampleControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + HomeWidgetExampleControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return HomeWidgetExampleControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +@available(iOS 18.0, *) +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +@available(iOS 18.0, *) +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/iosApp/WidgetExtension/HomeWidgetExampleLiveActivity.swift b/iosApp/WidgetExtension/HomeWidgetExampleLiveActivity.swift new file mode 100644 index 0000000..0d469c3 --- /dev/null +++ b/iosApp/WidgetExtension/HomeWidgetExampleLiveActivity.swift @@ -0,0 +1,74 @@ +// +// HomeWidgetExampleLiveActivity.swift +// HomeWidgetExample +// +// Created by hoshizora-rin on 2024/9/19. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct HomeWidgetExampleAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Dynamic stateful properties about your activity go here! + var emoji: String + } + + // Fixed non-changing properties about your activity go here! + var name: String +} + +@available(iOS 16.2, *) +struct HomeWidgetExampleLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: HomeWidgetExampleAttributes.self) { context in + // Lock screen/banner UI goes here + VStack { + Text("Hello \(context.state.emoji)") + } + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here. Compose the expanded UI through + // various regions, like leading/trailing/center/bottom + DynamicIslandExpandedRegion(.leading) { + Text("Leading") + } + DynamicIslandExpandedRegion(.trailing) { + Text("Trailing") + } + DynamicIslandExpandedRegion(.bottom) { + Text("Bottom \(context.state.emoji)") + // more content + } + } compactLeading: { + Text("L") + } compactTrailing: { + Text("T \(context.state.emoji)") + } minimal: { + Text(context.state.emoji) + } + .widgetURL(URL(string: "http://www.apple.com")) + .keylineTint(Color.red) + } + } +} + +extension HomeWidgetExampleAttributes { + fileprivate static var preview: HomeWidgetExampleAttributes { + HomeWidgetExampleAttributes(name: "World") + } +} + +extension HomeWidgetExampleAttributes.ContentState { + fileprivate static var smiley: HomeWidgetExampleAttributes.ContentState { + HomeWidgetExampleAttributes.ContentState(emoji: "😀") + } + + fileprivate static var starEyes: HomeWidgetExampleAttributes.ContentState { + HomeWidgetExampleAttributes.ContentState(emoji: "🤩") + } +} diff --git a/iosApp/WidgetExtension/Info.plist b/iosApp/WidgetExtension/Info.plist index 98ed060..4a89ac3 100644 --- a/iosApp/WidgetExtension/Info.plist +++ b/iosApp/WidgetExtension/Info.plist @@ -2,10 +2,24 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LLMP + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! CFBundleShortVersionString - 0.2.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier diff --git a/iosApp/WidgetExtension/WidgetExtension.entitlements b/iosApp/WidgetExtension/WidgetExtension.entitlements index 683a1b6..f0992eb 100644 --- a/iosApp/WidgetExtension/WidgetExtension.entitlements +++ b/iosApp/WidgetExtension/WidgetExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.top.zhushenwudi.llmp + group.com.zhushenwudi.lovelivemusicplayer diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..51942e6 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,867 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */; }; + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; }; + 2D0291E964949C7C8D4DF324 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88A292AA7607F14E8C66291E /* Assets.xcassets */; }; + 4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F846972E53BC33C1001D3 /* GradientView.swift */; }; + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; + 5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */; }; + 76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */; }; + 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; }; + B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; }; + C8D9E0F1A2B345678901BCDE /* WebViewContentBlocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */; }; + A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; }; + 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; }; + 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; }; + A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; }; + C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E2D6A135D7E87F67C8285D07 /* black.mp4 */; }; + F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; }; + FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */; }; + FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */; }; + D1E2F3A4B5C60718293A4B02 /* QqShareHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */; }; + D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */; }; + D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */; }; + I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 50F517066DF80E213FC4D6D2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 242FB5348943441C388468CE /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8803DE44F41D95FEAAC1346B; + remoteInfo = WidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E99160E501832DB2CECE6CFF /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C8353E6C8BE98C27FD768A59 /* WidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAppUtils.swift; sourceTree = ""; }; + A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewController.swift; sourceTree = ""; }; + B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewContentBlocker.swift; sourceTree = ""; }; + 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenView.swift; sourceTree = ""; }; + 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipScreenManager.swift; sourceTree = ""; }; + 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExampleBundle.swift; sourceTree = ""; }; + 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + 4B8F846972E53BC33C1001D3 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; + 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = ""; }; + 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = ""; }; + 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetExample.swift; sourceTree = ""; }; + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + E2D6A135D7E87F67C8285D07 /* black.mp4 */ = {isa = PBXFileReference; path = black.mp4; sourceTree = ""; }; + E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QqShareHost.swift; sourceTree = ""; }; + D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TencentOpenAPI.framework; path = Vendor/TencentOpenAPI.framework; sourceTree = ""; }; + D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = TencentOpenApi_IOS_Bundle.bundle; path = Vendor/TencentOpenApi_IOS_Bundle.bundle; sourceTree = ""; }; + I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + I18N0PLIST0FILE0REF0EN01 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + I18N0PLIST0FILE0REF0JA01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; + I18N0PLIST0FILE0REF0DE01 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "iosApp-Bridging-Header.h"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0ECDA917A71DFD183A635257 /* CarPlay */ = { + isa = PBXGroup; + children = ( + 310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */, + ); + path = CarPlay; + sourceTree = ""; + }; + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */ = { + isa = PBXGroup; + children = ( + 0ECDA917A71DFD183A635257 /* CarPlay */, + 5B4BD3733F8124940306FE19 /* pip */, + 8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */, + F543015C899423D4B01CEB2E /* Assets.xcassets */, + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, + A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */, + B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */, + 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */, + D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */, + D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */, + 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */, + 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */, + I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */, + ); + path = iosApp; + sourceTree = ""; + }; + 38B69C3F79CD97858CF7B609 = { + isa = PBXGroup; + children = ( + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */, + D1E2F3A4B5C60718293A4B09 /* Vendor */, + A795A079A61B73EB2CA424AE /* WidgetExtension */, + 94C98B8B3A2BFC1966447269 /* Products */, + ); + sourceTree = ""; + }; + D1E2F3A4B5C60718293A4B09 /* Vendor */ = { + isa = PBXGroup; + children = ( + D1E2F3A4B5C60718293A4B03 /* TencentOpenAPI.framework */, + D1E2F3A4B5C60718293A4B05 /* TencentOpenApi_IOS_Bundle.bundle */, + ); + name = Vendor; + sourceTree = ""; + }; + 5B4BD3733F8124940306FE19 /* pip */ = { + isa = PBXGroup; + children = ( + E2D6A135D7E87F67C8285D07 /* black.mp4 */, + 4B8F846972E53BC33C1001D3 /* GradientView.swift */, + 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */, + 0480DE3601E8C37A2D4745EA /* PipScreenView.swift */, + ); + path = pip; + sourceTree = ""; + }; + 94C98B8B3A2BFC1966447269 /* Products */ = { + isa = PBXGroup; + children = ( + 86038AE1032E284E79B32AD6 /* iosApp.app */, + E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + A795A079A61B73EB2CA424AE /* WidgetExtension */ = { + isa = PBXGroup; + children = ( + 88A292AA7607F14E8C66291E /* Assets.xcassets */, + 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */, + B8B1CCBD46108D407DF0C9A3 /* HomeWidgetExample.swift */, + 30942AA1A2E3A1AB832DD959 /* HomeWidgetExampleBundle.swift */, + ); + path = WidgetExtension; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 78B53CC79860CBFC2F5543C6 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 3EC48E5D6B774AB34AC4F9CF /* Build ComposeApp.framework */, + 297B59D9CE634556D6007C40 /* Sources */, + D1E2F3A4B5C60718293A4B08 /* Frameworks */, + 1623B02D10928C8101C1F806 /* Resources */, + E99160E501832DB2CECE6CFF /* Embed Foundation Extensions */, + C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */, + ); + buildRules = ( + ); + dependencies = ( + 8C46D4887752213A420B4355 /* PBXTargetDependency */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 86038AE1032E284E79B32AD6 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; + 8803DE44F41D95FEAAC1346B /* WidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = F78545275CCD4B3EFDC1B6CD /* Build configuration list for PBXNativeTarget "WidgetExtension" */; + buildPhases = ( + F846647070D4E44D5C5AEB29 /* Sources */, + 0E7A1DC2920D6844E8A58BE1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WidgetExtension; + packageProductDependencies = ( + ); + productName = WidgetExtension; + productReference = E6AF7C77765E074EE1C7E597 /* WidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 242FB5348943441C388468CE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 78B53CC79860CBFC2F5543C6 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + 8803DE44F41D95FEAAC1346B = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + "zh-Hans", + ja, + de, + ); + mainGroup = 38B69C3F79CD97858CF7B609; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 54; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8803DE44F41D95FEAAC1346B /* WidgetExtension */, + 78B53CC79860CBFC2F5543C6 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0E7A1DC2920D6844E8A58BE1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2D0291E964949C7C8D4DF324 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1623B02D10928C8101C1F806 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, + EAC4398962E17AAE02F2DB68 /* black.mp4 in Resources */, + D1E2F3A4B5C60718293A4B06 /* TencentOpenApi_IOS_Bundle.bundle in Resources */, + I18N0PLIST0BUILD0FILE001 /* InfoPlist.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D1E2F3A4B5C60718293A4B08 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D1E2F3A4B5C60718293A4B04 /* TencentOpenAPI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3EC48E5D6B774AB34AC4F9CF /* Build ComposeApp.framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build ComposeApp.framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nset -euo pipefail\nif [ \"YES\" = \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}\" ]; then\n echo \"Skipping Gradle build (OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES)\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode"; + }; + C516F4DE7FC107135868F830 /* Verify Widget Extension Embedded */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Verify Widget Extension Embedded"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -euo pipefail\nAPPEX=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}/PlugIns/WidgetExtension.appex\"\nif [ ! -d \"$APPEX\" ]; then\n echo \"error: WidgetExtension.appex is missing from ${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\n echo \"error: The app will install without widgets. Check WidgetExtension code signing.\"\n exit 1\nfi\necho \"Widget extension embedded: $APPEX\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXVariantGroup section */ + I18N0PLIST0VARNT0GROUP01 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + I18N0PLIST0FILE0REF0ZH01 /* zh-Hans */, + I18N0PLIST0FILE0REF0EN01 /* en */, + I18N0PLIST0FILE0REF0JA01 /* ja */, + I18N0PLIST0FILE0REF0DE01 /* de */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin PBXSourcesBuildPhase section */ + 297B59D9CE634556D6007C40 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FDE52B8D57A097A60A4CF366 /* AppDelegate.swift in Sources */, + FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */, + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, + 4701F6E8155C8E093C8CF81B /* GradientView.swift in Sources */, + 93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */, + A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */, + 02E790E4779E846963D3DC78 /* PipScreenView.swift in Sources */, + 88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */, + 8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */, + B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */, + C8D9E0F1A2B345678901BCDE /* WebViewContentBlocker.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */, + D1E2F3A4B5C60718293A4B02 /* QqShareHost.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F846647070D4E44D5C5AEB29 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F9A23E2F5F7514E5D6A3D9C0 /* BackgroundIntent.swift in Sources */, + 76ED3117C0B97CD5572EB808 /* HomeWidgetExample.swift in Sources */, + 5A902F589276392B4CBC0015 /* HomeWidgetExampleBundle.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 8C46D4887752213A420B4355 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8803DE44F41D95FEAAC1346B /* WidgetExtension */; + targetProxy = 50F517066DF80E213FC4D6D2 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 11814011E7D6EC822BFBC66D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 74; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = V73L9LHPCA; + ENABLE_BITCODE = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.7.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 18EBBCFA772E47B10DD95C22 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/Vendor", + ); + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lz", + "-lc++", + "-framework", + ComposeApp, + "-framework", + TencentOpenAPI, + "-framework", + Security, + "-framework", + SystemConfiguration, + "-framework", + CoreTelephony, + "-framework", + WebKit, + "-lsqlite3", + ); + SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h"; + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; + PRODUCT_NAME = LLMP; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-AD-HOC"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + 44097AC4C7F9A2BBDFDF3082 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-DEVELOP-WIDGET"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4EC94FBC5B79F3C98C54650C /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 74; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = V73L9LHPCA; + ENABLE_BITCODE = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.7.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 6C824A6EB4BAD43AE2A4BB29 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 74; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = V73L9LHPCA; + ENABLE_BITCODE = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.7.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 730BE128FD55F23ECB060431 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-RELEASE-WIDGET"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 9221A1FDC06803E8A2FC6E61 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/Vendor", + ); + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lz", + "-lc++", + "-framework", + ComposeApp, + "-framework", + TencentOpenAPI, + "-framework", + Security, + "-framework", + SystemConfiguration, + "-framework", + CoreTelephony, + "-framework", + WebKit, + "-lsqlite3", + ); + SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h"; + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; + PRODUCT_NAME = LLMP; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-DEVELOP"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AF835BFF1220CA5AB5B867E4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/Vendor", + ); + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lz", + "-lc++", + "-framework", + ComposeApp, + "-framework", + TencentOpenAPI, + "-framework", + Security, + "-framework", + SystemConfiguration, + "-framework", + CoreTelephony, + "-framework", + WebKit, + "-lsqlite3", + ); + SWIFT_OBJC_BRIDGING_HEADER = "iosApp/iosApp-Bridging-Header.h"; + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer; + PRODUCT_NAME = LLMP; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-RELEASE"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + BC1BDC6B2E0AEE85A44B675A /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LLMP; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LLMP-M-AD-HOC-WIDGET"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9221A1FDC06803E8A2FC6E61 /* Debug */, + 18EBBCFA772E47B10DD95C22 /* Profile */, + AF835BFF1220CA5AB5B867E4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + F78545275CCD4B3EFDC1B6CD /* Build configuration list for PBXNativeTarget "WidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 44097AC4C7F9A2BBDFDF3082 /* Debug */, + BC1BDC6B2E0AEE85A44B675A /* Profile */, + 730BE128FD55F23ECB060431 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C824A6EB4BAD43AE2A4BB29 /* Debug */, + 4EC94FBC5B79F3C98C54650C /* Profile */, + 11814011E7D6EC822BFBC66D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 242FB5348943441C388468CE /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/WidgetExtension.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/WidgetExtension.xcscheme new file mode 100644 index 0000000..c0e3e20 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/WidgetExtension.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 0000000..7288897 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index fb56e03..c5a15c1 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,9 +1,12 @@ import UIKit +import WidgetKit -let widgetGroupId = "group.top.zhushenwudi.llmp" +let widgetGroupId = "group.com.zhushenwudi.lovelivemusicplayer" @main class AppDelegate: UIResponder, UIApplicationDelegate { + private var widgetReloadWorkItem: DispatchWorkItem? + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -28,15 +31,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options: UIScene.ConnectionOptions ) -> UISceneConfiguration { if connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { - return UISceneConfiguration( + let config = UISceneConfiguration( name: "CarPlay Configuration", sessionRole: connectingSceneSession.role ) + config.delegateClass = CarPlaySceneDelegate.self + return config } - return UISceneConfiguration( + let config = UISceneConfiguration( name: "Default Configuration", sessionRole: connectingSceneSession.role ) + config.delegateClass = SceneDelegate.self + return config } private func registerHostNotifications() { @@ -48,18 +55,62 @@ class AppDelegate: UIResponder, UIApplicationDelegate { HostBridge.shared.presentShare(from: notification.userInfo) } NotificationCenter.default.addObserver( - forName: Notification.Name("fromWidgetToHost"), + forName: Notification.Name("fromWigetToRunner"), object: nil, queue: .main ) { notification in HostBridge.shared.handleWidgetAction(from: notification.userInfo) } NotificationCenter.default.addObserver( - forName: Notification.Name("llmpCarPlayAction"), + forName: Notification.Name("llmpWidgetSaveCover"), object: nil, queue: .main ) { notification in - HostBridge.shared.handleCarPlayAction(from: notification.userInfo) + guard let path = notification.userInfo?["path"] as? String else { return } + WidgetAppUtils.shared.saveImageToFile(imagePath: path) { [weak self] in + self?.scheduleWidgetReload() + } } + NotificationCenter.default.addObserver( + forName: Notification.Name("llmpReloadWidgets"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.scheduleWidgetReload() + } + NotificationCenter.default.addObserver( + forName: Notification.Name("llmpOpenWebView"), + object: nil, + queue: .main + ) { notification in + HostBridge.shared.presentWebView(from: notification.userInfo) + } + NotificationCenter.default.addObserver( + forName: Notification.Name("llmpShowToast"), + object: nil, + queue: .main + ) { notification in + guard let message = notification.userInfo?["message"] as? String else { return } + HostBridge.shared.showToast(message) + } + QqShareHost.shared.prepare() + } + + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + QqShareHost.shared.handleOpen(url) + } + + /// 合并切歌、播放状态、歌词行和封面完成时挤在一起的刷新请求。 + private func scheduleWidgetReload() { + widgetReloadWorkItem?.cancel() + let workItem = DispatchWorkItem { + WidgetCenter.shared.reloadAllTimelines() + } + widgetReloadWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem) } } diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..728e6c3 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-20@2x.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-40@3x.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-60@3x.png", + "scale": "3x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "icon-20-ipad.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "icon-20@2x-ipad.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "icon-29-ipad.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "icon-29@2x-ipad.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "icon-40.png", + "scale": "1x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "icon-40@2x.png", + "scale": "2x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-76.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-76@2x.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "icon-83.5@2x.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "icon-1024.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "icon.wuruihong.com" + } +} \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..2ae12b8 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png new file mode 100644 index 0000000..ce8da9d Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png new file mode 100644 index 0000000..5ba5f3e Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..5ba5f3e Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..f9fae9a Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png new file mode 100644 index 0000000..e3e0145 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png new file mode 100644 index 0000000..e3e0145 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png new file mode 100644 index 0000000..31c4767 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..31c4767 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..1153ccd Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png new file mode 100644 index 0000000..5ba5f3e Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..3038a23 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..84c3300 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..84c3300 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..4ea6e75 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png new file mode 100644 index 0000000..36795cf Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png new file mode 100644 index 0000000..bbb71aa Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png new file mode 100644 index 0000000..a68880e Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/Contents.json new file mode 100644 index 0000000..9d1dcc6 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "carplay_favorite.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/carplay_favorite.png b/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/carplay_favorite.png new file mode 100644 index 0000000..100a85a Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/CarPlayFavorite.imageset/carplay_favorite.png differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/Contents.json new file mode 100644 index 0000000..240533c --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/logo_logo.png b/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/logo_logo.png new file mode 100644 index 0000000..88cb6d8 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoApp.imageset/logo_logo.png differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/Contents.json new file mode 100644 index 0000000..49de690 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_aqours.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/logo_logo_aqours.jpg b/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/logo_logo_aqours.jpg new file mode 100644 index 0000000..a2d2718 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoAqours.imageset/logo_logo_aqours.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/Contents.json new file mode 100644 index 0000000..8185fd9 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_bluebird.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/logo_logo_bluebird.jpg b/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/logo_logo_bluebird.jpg new file mode 100644 index 0000000..aefa5bc Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoBluebird.imageset/logo_logo_bluebird.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/Contents.json new file mode 100644 index 0000000..158368d --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_combine.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/logo_logo_combine.jpg b/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/logo_logo_combine.jpg new file mode 100644 index 0000000..767b84c Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoCombine.imageset/logo_logo_combine.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/Contents.json new file mode 100644 index 0000000..bb8e1a0 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_hasunosora.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/logo_logo_hasunosora.jpg b/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/logo_logo_hasunosora.jpg new file mode 100644 index 0000000..e26e1df Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoHasunosora.imageset/logo_logo_hasunosora.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/Contents.json new file mode 100644 index 0000000..09fb87a --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_liella.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/logo_logo_liella.jpg b/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/logo_logo_liella.jpg new file mode 100644 index 0000000..1a4093c Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoLiella.imageset/logo_logo_liella.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/Contents.json new file mode 100644 index 0000000..8e139fd --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_musical.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/logo_logo_musical.jpg b/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/logo_logo_musical.jpg new file mode 100644 index 0000000..b592d5b Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoMusical.imageset/logo_logo_musical.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/Contents.json new file mode 100644 index 0000000..9e3304d --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_niji.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/logo_logo_niji.jpg b/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/logo_logo_niji.jpg new file mode 100644 index 0000000..cabdd6e Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoNiji.imageset/logo_logo_niji.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/Contents.json new file mode 100644 index 0000000..22d1fc9 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_us.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/logo_logo_us.jpg b/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/logo_logo_us.jpg new file mode 100644 index 0000000..2159a11 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoUs.imageset/logo_logo_us.jpg differ diff --git a/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/Contents.json new file mode 100644 index 0000000..c465d62 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo_logo_yohane.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/logo_logo_yohane.jpg b/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/logo_logo_yohane.jpg new file mode 100644 index 0000000..34edbb7 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/LogoYohane.imageset/logo_logo_yohane.jpg differ diff --git a/iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift b/iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift index e9d4897..a87d3d1 100644 --- a/iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift +++ b/iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift @@ -1,201 +1,729 @@ import CarPlay +import ComposeApp +import CryptoKit import Foundation +import ImageIO +import UIKit -/// CarPlay UI reading catalog JSON from App Group UserDefaults (written by Kotlin [WidgetDataWriter]). -/// Play actions post NotificationCenter events; wire to Kotlin via exported framework callbacks on macOS. +/// 对齐 Flutter `lib/pages/carplay/*`:音乐 / 专辑 / 我的 三栏 + 二级列表播放。 class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { private var interfaceController: CPInterfaceController? private let defaults = UserDefaults(suiteName: widgetGroupId) + private var snapshot = CarPlaySnapshot.fallback + private weak var nowPlayingItem: CPListItem? + private weak var musicListTemplate: CPListTemplate? + private var catalogObserver: NSObjectProtocol? + /// 正在播放页「我喜欢」图标状态。不走 `bool(forKey:)`:缺 key 时永远是 false。 + private var nowPlayingFavorite = false func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController ) { self.interfaceController = interfaceController + reloadSnapshot() + nowPlayingFavorite = snapshot.isFavorite rebuildTabs() - NotificationCenter.default.addObserver( - self, - selector: #selector(onCatalogRefresh), - name: Notification.Name("llmpCarPlayAction"), - object: nil - ) + setupNowPlayingButtons() + catalogObserver = NotificationCenter.default.addObserver( + forName: Notification.Name("llmpCarPlayAction"), + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleCatalogNotification(notification) + } + NotificationCenter.default.post(name: Notification.Name("llmpHostCarPlayReady"), object: nil) } func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController ) { + if let catalogObserver { + NotificationCenter.default.removeObserver(catalogObserver) + self.catalogObserver = nil + } self.interfaceController = nil - NotificationCenter.default.removeObserver(self) + nowPlayingItem = nil + CarPlayCoverLoader.shared.cancel() } - @objc private func onCatalogRefresh() { - rebuildTabs() + private func handleCatalogNotification(_ notification: Notification) { + let action = notification.userInfo?["action"] as? String ?? "refresh" + reloadSnapshot() + if let fav = notification.userInfo?["isFavorite"] as? String { + nowPlayingFavorite = (fav == "true") + } else { + nowPlayingFavorite = snapshot.isFavorite + } + if action == "nowPlaying" { + applyNowPlaying() + return + } + if isAtRoot { + rebuildTabs() + } else { + applyNowPlaying() + } + } + + private var isAtRoot: Bool { + interfaceController?.topTemplate is CPTabBarTemplate } private func rebuildTabs() { - let musicTab = CPTabBarTemplate(templates: [ + let tabs = CPTabBarTemplate(templates: [ buildMusicTab(), buildAlbumTab(), buildMineTab(), ]) - interfaceController?.setRootTemplate(musicTab, animated: true, completion: nil) + interfaceController?.setRootTemplate(tabs, animated: false, completion: nil) + applyNowPlaying() + setupNowPlayingButtons() } + // MARK: - Tabs + private func buildMusicTab() -> CPListTemplate { - let nowPlaying = defaults?.string(forKey: "carplay_nowPlaying") ?? "正在播放" - let groups = decodeGroups() - var sections: [CPListSection] = [] + let template = CPListTemplate(title: I18n.shared.t(key: "music"), sections: musicTabSections()) + template.tabImage = UIImage(systemName: "music.note.house") + template.emptyViewTitleVariants = [I18n.shared.t(key: "no_songs")] + musicListTemplate = template + return template + } - let nowItem = CPListItem(text: nowPlaying, detailText: "Now Playing") - nowItem.handler = { _, completion in - NotificationCenter.default.post( - name: Notification.Name("llmpHostTogglePlay"), - object: nil + /// 重建音乐 Tab 分区。盲盒行点过后必须换成新的 CPListItem,否则已选中行不会再回调。 + private func musicTabSections() -> [CPListSection] { + let playing = makeBlindBoxItem() + nowPlayingItem = playing + let groups = snapshot.groups.map { group -> CPListItem in + let item = CPListItem( + text: Self.splitName(group.displayName), + detailText: Self.splitName(group.detail), + image: UIImage(named: group.logoAsset), + accessoryImage: nil, + accessoryType: .disclosureIndicator ) - completion() - } - sections.append(CPListSection(items: [nowItem])) - - let groupItems = groups.map { group -> CPListItem in - let item = CPListItem(text: group.displayName, detailText: group.detail) item.handler = { [weak self] _, completion in - self?.pushSongs(for: group.displayName, title: group.displayName) - completion() - } - return item - } - if !groupItems.isEmpty { - sections.append(CPListSection(items: groupItems, header: "分组", sectionIndexTitle: nil)) - } - - let loveSongs = decodeLoveSongs() - let loveItems = loveSongs.map { song -> CPListItem in - let item = CPListItem(text: song.title, detailText: song.artist) - item.handler = { _, completion in - NotificationCenter.default.post( - name: Notification.Name("llmpHostCarPlayPlay"), - object: nil, - userInfo: ["musicId": song.musicId] + self?.pushSongs( + title: Self.splitName(group.displayName), + songs: self?.snapshot.songsByGroup[group.group] ?? [] ) completion() } return item } - if !loveItems.isEmpty { - sections.append(CPListSection(items: loveItems, header: "收藏", sectionIndexTitle: nil)) - } + return [ + CPListSection(items: [playing], header: I18n.shared.t(key: "music_blind_box"), sectionIndexTitle: nil), + CPListSection(items: groups, header: I18n.shared.t(key: "group_list"), sectionIndexTitle: nil), + ] + } - let template = CPListTemplate(title: "音乐", sections: sections) - template.tabImage = UIImage(systemName: "music.note") - return template + private func makeBlindBoxItem() -> CPListItem { + let title = defaults?.string(forKey: "carplay_nowPlaying") ?? snapshot.nowPlayingTitle + let playing = CPListItem( + text: title, + detailText: I18n.shared.t(key: "throbbing"), + image: UIImage(named: "LogoApp") + ) + playing.isPlaying = defaults?.object(forKey: "carplay_isPlaying") as? Bool ?? snapshot.isPlaying + applyCover(playing, uri: defaults?.string(forKey: "carplay_nowPlayingCover") ?? snapshot.nowPlayingCover) + playing.handler = { [weak self] _, completion in + self?.playBlindBox() + completion() + DispatchQueue.main.async { + self?.musicListTemplate?.updateSections(self?.musicTabSections() ?? []) + } + } + return playing } private func buildAlbumTab() -> CPListTemplate { - let groups = decodeGroups() - let items = groups.map { group -> CPListItem in - let item = CPListItem(text: group.displayName, detailText: "专辑") + let items = snapshot.groups.map { group -> CPListItem in + let item = CPListItem( + text: Self.splitName(group.displayName), + detailText: Self.splitName(group.detail), + image: UIImage(named: group.logoAsset), + accessoryImage: nil, + accessoryType: .disclosureIndicator + ) item.handler = { [weak self] _, completion in - self?.pushAlbums(for: group.displayName) + self?.pushAlbums(group: group) completion() } return item } let template = CPListTemplate( - title: "专辑", - sections: [CPListSection(items: items)] + title: I18n.shared.t(key: "album"), + sections: [CPListSection(items: items, header: I18n.shared.t(key: "group_list"), sectionIndexTitle: nil)] ) - template.tabImage = UIImage(systemName: "square.stack") + template.tabImage = UIImage(systemName: "music.note.list") + template.emptyViewTitleVariants = [I18n.shared.t(key: "no_songs")] return template } private func buildMineTab() -> CPListTemplate { - let menus = decodeMenus() - let items = menus.map { menu -> CPListItem in - let item = CPListItem(text: menu.name, detailText: "\(menu.count) 首") + var sections: [CPListSection] = [] + + let loveEntry = CPListItem( + text: I18n.shared.t(key: "iLove"), + detailText: nil, + image: UIImage(named: "CarPlayFavorite"), + accessoryImage: nil, + accessoryType: .disclosureIndicator + ) + loveEntry.handler = { [weak self] _, completion in + self?.pushSongs(title: I18n.shared.t(key: "iLove"), songs: self?.snapshot.loveSongs ?? []) + completion() + } + sections.append(CPListSection(items: [loveEntry], header: I18n.shared.t(key: "iLove"), sectionIndexTitle: nil)) + + let menus = capped(snapshot.menus) + let menuItems = menus.map { menu -> CPListItem in + let item = CPListItem( + text: Self.splitName(menu.name), + detailText: menu.isPhone ? I18n.shared.t(key: "phone") : I18n.shared.t(key: "pc"), + image: nil, + accessoryImage: nil, + accessoryType: .disclosureIndicator + ) item.handler = { [weak self] _, completion in - self?.pushMenuSongs(menuId: menu.menuId, title: menu.name) + let songs = self?.snapshot.songsByMenu[String(menu.menuId)] ?? [] + self?.pushSongs(title: Self.splitName(menu.name), songs: songs) completion() } + applyCover(item, uri: menu.coverPath) return item } - let template = CPListTemplate( - title: "我的", - sections: [CPListSection(items: items)] - ) - template.tabImage = UIImage(systemName: "person") + sections.append(CPListSection(items: menuItems, header: I18n.shared.t(key: "add_favorite_menu"), sectionIndexTitle: nil)) + + let template = CPListTemplate(title: I18n.shared.t(key: "mine"), sections: sections) + template.tabImage = UIImage(systemName: "person.fill") + template.emptyViewTitleVariants = [I18n.shared.t(key: "no_songs")] return template } - private func pushSongs(for group: String, title: String) { - // Full song lists per group require additional JSON keys from Kotlin catalog refresh. - let item = CPListItem(text: "播放 \(title)", detailText: nil) - item.handler = { _, completion in - NotificationCenter.default.post( - name: Notification.Name("llmpCarPlayAction"), - object: nil, - userInfo: ["action": "playGroup", "group": group] + // MARK: - Pushed lists + + private func pushAlbums(group: CarPlayGroupDTO) { + let albums = capped(snapshot.albumsByGroup[group.group] ?? []) + let items = albums.map { album -> CPListItem in + let item = CPListItem( + text: Self.splitName(album.title.isEmpty ? I18n.shared.t(key: "unknown") : album.title), + detailText: album.date, + image: nil, + accessoryImage: nil, + accessoryType: .disclosureIndicator ) - completion() + item.handler = { [weak self] _, completion in + let songs = self?.snapshot.songsByAlbum[album.albumId] ?? [] + self?.pushSongs(title: Self.splitName(album.title), songs: songs) + completion() + } + applyCover(item, uri: album.coverPath) + return item + } + let template = CPListTemplate( + title: Self.splitName(group.displayName), + sections: [CPListSection(items: items)] + ) + template.emptyViewTitleVariants = [I18n.shared.t(key: "no_songs")] + template.backButton = CPBarButton(title: I18n.shared.t(key: "back")) { [weak self] _ in + self?.interfaceController?.popTemplate(animated: true, completion: nil) } - let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])]) interfaceController?.pushTemplate(template, animated: true, completion: nil) } - private func pushAlbums(for group: String) { - let item = CPListItem(text: "浏览 \(group) 专辑", detailText: nil) - let template = CPListTemplate(title: group, sections: [CPListSection(items: [item])]) - interfaceController?.pushTemplate(template, animated: true, completion: nil) - } - - private func pushMenuSongs(menuId: Int64, title: String) { - let item = CPListItem(text: "播放歌单", detailText: title) - item.handler = { _, completion in - NotificationCenter.default.post( - name: Notification.Name("llmpCarPlayAction"), - object: nil, - userInfo: ["action": "playMenu", "menuId": String(menuId)] + private func pushSongs(title: String, songs: [CarPlaySongDTO]) { + let songs = capped(songs) + let items = songs.map { song -> CPListItem in + let item = CPListItem( + text: song.title.isEmpty ? I18n.shared.t(key: "unknown") : song.title, + detailText: song.artist, + image: nil ) - completion() + let playingId = defaults?.string(forKey: "carplay_nowPlayingId") + if let playingId, !playingId.isEmpty, playingId == song.musicId { + item.isPlaying = true + } + item.handler = { [weak self] _, completion in + self?.play(songs: songs, startId: song.musicId) + completion() + } + applyCover(item, uri: song.coverPath) + return item + } + let template = CPListTemplate( + title: title, + sections: [CPListSection(items: items)] + ) + template.emptyViewTitleVariants = [I18n.shared.t(key: "no_songs")] + template.backButton = CPBarButton(title: I18n.shared.t(key: "back")) { [weak self] _ in + self?.interfaceController?.popTemplate(animated: true, completion: nil) } - let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])]) interfaceController?.pushTemplate(template, animated: true, completion: nil) } - // MARK: - JSON decode helpers + // MARK: - Playback - private struct CarPlayGroupDTO: Decodable { - let displayName: String - let detail: String + /// 对齐原项目:切到随机循环,并在全曲库中任选一首(尽量避开当前曲)。 + private func playBlindBox() { + let ids = snapshot.allSongIds + guard !ids.isEmpty else { return } + let currentId = defaults?.string(forKey: "carplay_play_musicId") + ?? defaults?.string(forKey: "carplay_nowPlayingId") + ?? snapshot.nowPlayingId + let pool = currentId.map { current in ids.filter { $0 != current } } ?? ids + let startId = (pool.isEmpty ? ids : pool).randomElement() + play(ids: ids, startId: startId, shuffle: true) } - private struct CarPlaySongDTO: Decodable { - let musicId: String - let title: String - let artist: String? + private func play(songs: [CarPlaySongDTO], startId: String) { + play(ids: songs.map(\.musicId), startId: startId) } - private struct CarPlayMenuDTO: Decodable { - let menuId: Int64 - let name: String - let count: Int + private func play(ids: [String], startId: String?, shuffle: Bool = false) { + guard !ids.isEmpty else { return } + let data = (try? JSONEncoder().encode(ids)).flatMap { String(data: $0, encoding: .utf8) } + defaults?.set(data, forKey: "carplay_play_queue") + defaults?.set(startId ?? ids[0], forKey: "carplay_play_musicId") + defaults?.set(shuffle, forKey: "carplay_play_shuffle") + defaults?.synchronize() + NotificationCenter.default.post(name: Notification.Name("llmpHostCarPlayPlay"), object: nil) } - private func decodeGroups() -> [CarPlayGroupDTO] { - decode(key: "carplay_groups") ?? [] + private func applyNowPlaying() { + let title = defaults?.string(forKey: "carplay_nowPlaying") ?? snapshot.nowPlayingTitle + nowPlayingItem?.setText(title) + nowPlayingItem?.isPlaying = defaults?.bool(forKey: "carplay_isPlaying") ?? snapshot.isPlaying + let cover = defaults?.string(forKey: "carplay_nowPlayingCover") + if let item = nowPlayingItem { + if let cover, !cover.isEmpty { + applyCover(item, uri: cover) + } else { + item.setImage(UIImage(named: "LogoApp")) + } + } + setupNowPlayingButtons() } - private func decodeLoveSongs() -> [CarPlaySongDTO] { - decode(key: "carplay_love") ?? [] + // MARK: - Now Playing buttons + + private func setupNowPlayingButtons() { + let nowPlaying = CPNowPlayingTemplate.shared + nowPlaying.updateNowPlayingButtons([ + loveButton(), + loopButton(), + recentButton(), + ]) } - private func decodeMenus() -> [CarPlayMenuDTO] { - decode(key: "carplay_menus") ?? [] + private func loveButton() -> CPNowPlayingImageButton { + let loved = nowPlayingFavorite + let name = loved ? "heart.fill" : "heart" + let image = Self.nowPlayingSymbol(name) + return CPNowPlayingImageButton(image: image) { [weak self] _ in + guard let self else { return } + let musicId = self.defaults?.string(forKey: "carplay_nowPlayingId") ?? self.snapshot.nowPlayingId + guard let musicId, !musicId.isEmpty else { return } + self.nowPlayingFavorite.toggle() + NotificationCenter.default.post(name: Notification.Name("llmpHostToggleLove"), object: nil) + // 按钮回调内直接 updateNowPlayingButtons 会被 CarPlay 丢掉,下一拍再刷。 + DispatchQueue.main.async { [weak self] in + self?.setupNowPlayingButtons() + } + } } - private func decode(key: String) -> T? { - guard let json = defaults?.string(forKey: key), - let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(T.self, from: data) + private func loopButton() -> CPNowPlayingImageButton { + let mode = defaults?.string(forKey: "carplay_loopMode") ?? snapshot.loopMode + let symbol: String + switch mode { + case "SINGLE": + symbol = "repeat.1" + case "SHUFFLE": + symbol = "shuffle" + default: + symbol = "repeat" + } + return CPNowPlayingImageButton(image: Self.nowPlayingSymbol(symbol)) { _ in + NotificationCenter.default.post(name: Notification.Name("llmpHostCarPlayCycleLoop"), object: nil) + } + } + + private func recentButton() -> CPNowPlayingImageButton { + CPNowPlayingImageButton(image: Self.nowPlayingSymbol("clock.arrow.circlepath")) { [weak self] _ in + self?.pushRecent() + } + } + + private func pushRecent() { + reloadSnapshot() + let songs = loadRecentSongs() + pushSongs(title: I18n.shared.t(key: "history"), songs: songs) + } + + private func loadRecentSongs() -> [CarPlaySongDTO] { + if let url = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: widgetGroupId)? + .appendingPathComponent("carplay_recent.json"), + let data = try? Data(contentsOf: url), + let songs = try? JSONDecoder().decode([CarPlaySongDTO].self, from: data) { + return songs + } + return snapshot.recentSongs + } + + private static func nowPlayingSymbol(_ name: String) -> UIImage { + // CarPlay 会把整张图缩进固定槽位,加大画布不会让图标变大。 + // 三个符号画进同一正方形并铺满,避免心形撑开间距,同时尽量填满槽位。 + let canvas = CGSize(width: 44, height: 44) + let glyphBox = canvas.width + let config = UIImage.SymbolConfiguration(pointSize: glyphBox, weight: .medium) + let symbol = UIImage(systemName: name, withConfiguration: config)? + .withRenderingMode(.alwaysTemplate) + let format = UIGraphicsImageRendererFormat() + format.scale = 2 + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: canvas, format: format) + let image = renderer.image { _ in + guard let symbol, symbol.size.width > 0, symbol.size.height > 0 else { return } + let fit = min(glyphBox / symbol.size.width, glyphBox / symbol.size.height) + let drawSize = CGSize( + width: symbol.size.width * fit, + height: symbol.size.height * fit + ) + let rect = CGRect( + x: (canvas.width - drawSize.width) / 2, + y: (canvas.height - drawSize.height) / 2, + width: drawSize.width, + height: drawSize.height + ) + symbol.draw(in: rect) + } + return image.withRenderingMode(.alwaysTemplate) + } + + // MARK: - Snapshot + + private func reloadSnapshot() { + snapshot = CarPlaySnapshot.load() ?? .fallback + } + + private func capped(_ items: [T]) -> [T] { + let limit = CPListTemplate.maximumItemCount + if items.count <= limit { return items } + return Array(items.prefix(limit)) + } + + private var coverMaxPixel: CGFloat { + let point = CPListItem.maximumImageSize + let side = max(point.width, point.height, 90) + let scale = max(interfaceController?.carTraitCollection.displayScale ?? 2, 2) + return side * scale + } + + private func applyCover(_ item: CPListItem, uri: String?) { + CarPlayCoverLoader.shared.apply(item, uri: uri, maxPixel: coverMaxPixel) + } + + private static func splitName(_ name: String) -> String { + if name.count > 30 { + return String(name.prefix(30)) + "..." + } + return name } } + +/// CarPlay 列表没有 cell 复用,系统不会按滚动卸载图片。 +/// 用 ImageIO 缩略图 + 内存缓存,把全量封面控制在 `maximumImageSize` 内。 +private final class CarPlayCoverLoader { + static let shared = CarPlayCoverLoader() + + private let cache = NSCache() + private let sync = DispatchQueue(label: "llmp.carplay.cover.sync") + private let work = DispatchQueue(label: "llmp.carplay.cover.work") + private var waiters: [String: [WeakListItem]] = [:] + private var pending: [CoverJob] = [] + private var running = false + private let session: URLSession + private let diskDir: URL + + private struct WeakListItem { + weak var item: CPListItem? + } + + private struct CoverJob { + let uri: String + let maxPixel: CGFloat + } + + private init() { + cache.countLimit = 256 + cache.totalCostLimit = 24 * 1024 * 1024 + let config = URLSessionConfiguration.default + config.urlCache = URLCache(memoryCapacity: 16 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024) + config.requestCachePolicy = .returnCacheDataElseLoad + config.httpMaximumConnectionsPerHost = 1 + config.timeoutIntervalForRequest = 15 + session = URLSession(configuration: config) + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + diskDir = caches.appendingPathComponent("CarPlayCovers", isDirectory: true) + try? FileManager.default.createDirectory(at: diskDir, withIntermediateDirectories: true) + } + + func cancel() { + session.getAllTasks { tasks in + tasks.forEach { $0.cancel() } + } + sync.sync { + pending.removeAll() + waiters.removeAll() + running = false + } + } + + func apply(_ item: CPListItem, uri: String?, maxPixel: CGFloat) { + guard let uri, !uri.isEmpty else { return } + if let cached = cache.object(forKey: uri as NSString) { + item.setImage(cached) + return + } + sync.sync { + if waiters[uri] != nil { + waiters[uri, default: []].append(WeakListItem(item: item)) + return + } + waiters[uri] = [WeakListItem(item: item)] + pending.append(CoverJob(uri: uri, maxPixel: maxPixel)) + } + pump() + } + + private func pump() { + work.async { + let job: CoverJob? = self.sync.sync { + if self.running { return nil } + guard !self.pending.isEmpty else { return nil } + self.running = true + return self.pending.removeFirst() + } + guard let job else { return } + let image = self.loadOne(job) + self.deliver(job.uri, image: image, maxPixel: job.maxPixel) + self.sync.sync { self.running = false } + self.pump() + } + } + + private func deliver(_ uri: String, image: UIImage?, maxPixel: CGFloat) { + if let image { + cache.setObject(image, forKey: uri as NSString, cost: Self.cost(maxPixel)) + } + let items: [CPListItem] = sync.sync { + let list = waiters.removeValue(forKey: uri) ?? [] + return list.compactMap(\.item) + } + guard let image else { return } + DispatchQueue.main.async { + items.forEach { $0.setImage(image) } + } + } + + private func loadOne(_ job: CoverJob) -> UIImage? { + if let memory = cache.object(forKey: job.uri as NSString) { + return memory + } + if let disk = readDisk(job.uri) { + return disk + } + let image: UIImage? + if job.uri.hasPrefix("http://") || job.uri.hasPrefix("https://") { + image = loadRemoteSync(job.uri, maxPixel: job.maxPixel) + } else { + let path = job.uri.hasPrefix("file://") ? String(job.uri.dropFirst("file://".count)) : job.uri + let url = URL(fileURLWithPath: path) + image = Self.thumbnail(url: url, maxPixel: job.maxPixel) + ?? Self.downscale(UIImage(contentsOfFile: url.path), maxPixel: job.maxPixel) + } + if let image { + writeDisk(job.uri, image: image) + } + return image + } + + private func diskURL(for uri: String) -> URL { + let digest = SHA256.hash(data: Data(uri.utf8)) + let name = digest.map { String(format: "%02x", $0) }.joined() + return diskDir.appendingPathComponent(name).appendingPathExtension("jpg") + } + + private func readDisk(_ uri: String) -> UIImage? { + let url = diskURL(for: uri) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return UIImage(contentsOfFile: url.path) + } + + private func writeDisk(_ uri: String, image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.82) else { return } + try? data.write(to: diskURL(for: uri), options: .atomic) + } + + private func loadRemoteSync(_ uri: String, maxPixel: CGFloat) -> UIImage? { + guard let url = URL(string: uri) else { return nil } + var request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 15) + request.setValue( + "LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)", + forHTTPHeaderField: "User-Agent" + ) + let lock = DispatchSemaphore(value: 0) + var image: UIImage? + session.dataTask(with: request) { data, _, _ in + image = data.flatMap { + Self.thumbnail(data: $0, maxPixel: maxPixel) + ?? Self.downscale(UIImage(data: $0), maxPixel: maxPixel) + } + lock.signal() + }.resume() + _ = lock.wait(timeout: .now() + 20) + return image + } + + private static func cost(_ maxPixel: CGFloat) -> Int { + Int(maxPixel * maxPixel * 4) + } + + private static func thumbnail(url: URL, maxPixel: CGFloat) -> UIImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + return thumbnail(source: source, maxPixel: maxPixel) + } + + private static func thumbnail(data: Data, maxPixel: CGFloat) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + return thumbnail(source: source, maxPixel: maxPixel) + } + + private static func thumbnail(source: CGImageSource, maxPixel: CGFloat) -> UIImage? { + guard CGImageSourceGetCount(source) > 0, CGImageSourceGetType(source) != nil else { + return nil + } + let options: NSDictionary = [ + kCGImageSourceCreateThumbnailFromImageAlways: kCFBooleanTrue as Any, + kCGImageSourceCreateThumbnailWithTransform: kCFBooleanTrue as Any, + kCGImageSourceThumbnailMaxPixelSize: NSNumber(value: Int(maxPixel)), + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { + return nil + } + return UIImage(cgImage: cgImage) + } + + private static func downscale(_ image: UIImage?, maxPixel: CGFloat) -> UIImage? { + guard let image, image.size.width > 0, image.size.height > 0 else { return nil } + let longest = max(image.size.width, image.size.height) + if longest <= maxPixel { return image } + let scale = maxPixel / longest + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } +} + +// MARK: - DTOs + +private struct CarPlaySnapshot: Decodable { + let nowPlayingTitle: String + let nowPlayingCover: String? + let nowPlayingId: String? + let isPlaying: Bool + let isFavorite: Bool + let loopMode: String + let groups: [CarPlayGroupDTO] + let songsByGroup: [String: [CarPlaySongDTO]] + let albumsByGroup: [String: [CarPlayAlbumDTO]] + let songsByAlbum: [String: [CarPlaySongDTO]] + let loveSongs: [CarPlaySongDTO] + let recentSongs: [CarPlaySongDTO] + let menus: [CarPlayMenuDTO] + let songsByMenu: [String: [CarPlaySongDTO]] + let allSongIds: [String] + + static let catalogFileName = "carplay_catalog.json" + + static var fallback: CarPlaySnapshot { + CarPlaySnapshot( + nowPlayingTitle: I18n.shared.t(key: "now_playing_empty"), + nowPlayingCover: nil, + nowPlayingId: nil, + isPlaying: false, + isFavorite: false, + loopMode: "LIST", + groups: CarPlayGroupDTO.defaults, + songsByGroup: [:], + albumsByGroup: [:], + songsByAlbum: [:], + loveSongs: [], + recentSongs: [], + menus: [], + songsByMenu: [:], + allSongIds: [] + ) + } + + static func load() -> CarPlaySnapshot? { + guard + let url = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: widgetGroupId)? + .appendingPathComponent(catalogFileName), + let data = try? Data(contentsOf: url) + else { return nil } + return try? JSONDecoder().decode(CarPlaySnapshot.self, from: data) + } +} + +private struct CarPlayGroupDTO: Decodable { + let group: String + let displayName: String + let detail: String + let logoAsset: String + + static let defaults: [CarPlayGroupDTO] = [ + .init(group: "μ's", displayName: "μ's", detail: "ラブライブ!", logoAsset: "LogoUs"), + .init(group: "Aqours", displayName: "Aqours", detail: "ラブライブ!サンシャイン!!", logoAsset: "LogoAqours"), + .init(group: "Nijigasaki", displayName: "虹咲学园学园偶像同好会", detail: "虹ヶ咲学園スクールアイドル同好会", logoAsset: "LogoNiji"), + .init(group: "Liella!", displayName: "Liella!", detail: "ラブライブ!スーパースター!!", logoAsset: "LogoLiella"), + .init(group: "Hasunosora", displayName: "莲之空女学院", detail: "蓮ノ空女学院スクールアイドルクラブ", logoAsset: "LogoHasunosora"), + .init(group: "Yohane", displayName: "幻日夜羽", detail: "幻日のヨハネ -SUNSHINE in the MIRROR-", logoAsset: "LogoYohane"), + .init(group: "Musical", displayName: "学园偶像音乐剧", detail: "スクールアイドルミュージカル", logoAsset: "LogoMusical"), + .init(group: "BlueBird", displayName: "BlueBird", detail: "イキヅライブ!LOVELIVE! BLUEBIRD", logoAsset: "LogoBluebird"), + .init(group: "Combine", displayName: "其他", detail: "u咩", logoAsset: "LogoCombine"), + ] +} + +private struct CarPlaySongDTO: Decodable { + let musicId: String + let title: String + let artist: String? + let coverPath: String? +} + +private struct CarPlayAlbumDTO: Decodable { + let albumId: String + let title: String + let date: String? + let group: String? + let coverPath: String? +} + +private struct CarPlayMenuDTO: Decodable { + let menuId: Int64 + let name: String + let count: Int + let isPhone: Bool + let coverPath: String? +} diff --git a/iosApp/iosApp/HostBridge.swift b/iosApp/iosApp/HostBridge.swift index f4b980f..b5630f9 100644 --- a/iosApp/iosApp/HostBridge.swift +++ b/iosApp/iosApp/HostBridge.swift @@ -18,23 +18,26 @@ final class HostBridge { guard let url = userInfo?["url"] as? String else { return } if url.contains("toggle_play") { NotificationCenter.default.post(name: Notification.Name("llmpHostTogglePlay"), object: nil) + } else if url.contains("previous") { + NotificationCenter.default.post(name: Notification.Name("llmpHostPrevious"), object: nil) + } else if url.contains("next") { + NotificationCenter.default.post(name: Notification.Name("llmpHostNext"), object: nil) } else if url.contains("toggle_love") { NotificationCenter.default.post(name: Notification.Name("llmpHostToggleLove"), object: nil) } } - func handleCarPlayAction(from userInfo: [AnyHashable: Any]?) { - guard let action = userInfo?["action"] as? String else { return } - if action == "play", let musicId = userInfo?["musicId"] as? String { - NotificationCenter.default.post( - name: Notification.Name("llmpHostCarPlayPlay"), - object: nil, - userInfo: ["musicId": musicId] - ) - } - } - func presentShare(from userInfo: [AnyHashable: Any]?) { + if (userInfo?["type"] as? String) == "qq" { + QqShareHost.shared.share( + title: userInfo?["title"] as? String ?? "", + text: userInfo?["text"] as? String ?? "", + url: userInfo?["url"] as? String ?? "", + imageUrl: userInfo?["imageUrl"] as? String, + imagePath: userInfo?["imagePath"] as? String + ) + return + } guard let root = topViewController() else { return } var items: [Any] = [] if let text = userInfo?["text"] as? String, !text.isEmpty { @@ -48,6 +51,55 @@ final class HostBridge { root.present(controller, animated: true) } + func showToast(_ message: String) { + presentToast(message, retry: true) + } + + private func presentToast(_ message: String, retry: Bool) { + guard !message.isEmpty else { return } + guard let root = topViewController() else { + if retry { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.presentToast(message, retry: false) + } + } + return + } + let label = PaddingLabel() + label.text = message + label.textColor = .white + label.backgroundColor = UIColor.black.withAlphaComponent(0.78) + label.textAlignment = .center + label.semanticContentAttribute = .forceLeftToRight + label.font = .systemFont(ofSize: 14) + label.numberOfLines = 0 + label.layer.cornerRadius = 8 + label.clipsToBounds = true + label.translatesAutoresizingMaskIntoConstraints = false + root.view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: root.view.centerXAnchor), + label.bottomAnchor.constraint(equalTo: root.view.safeAreaLayoutGuide.bottomAnchor, constant: -24), + label.leadingAnchor.constraint(greaterThanOrEqualTo: root.view.leadingAnchor, constant: 32), + label.trailingAnchor.constraint(lessThanOrEqualTo: root.view.trailingAnchor, constant: -32), + ]) + UIView.animate(withDuration: 0.2, delay: 1.6, options: .curveEaseIn) { + label.alpha = 0 + } completion: { _ in + label.removeFromSuperview() + } + } + + func presentWebView(from userInfo: [AnyHashable: Any]?) { + guard let url = userInfo?["url"] as? String, !url.isEmpty else { return } + let title = userInfo?["title"] as? String + guard let root = topViewController() else { return } + let webVC = InAppWebViewController(url: url, title: title) + let nav = UINavigationController(rootViewController: webVC) + nav.modalPresentationStyle = .fullScreen + root.present(nav, animated: true) + } + private func topViewController() -> UIViewController? { let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } @@ -59,3 +111,19 @@ final class HostBridge { return top } } + +private final class PaddingLabel: UILabel { + private let inset = UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14) + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: inset)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize( + width: size.width + inset.left + inset.right, + height: size.height + inset.top + inset.bottom + ) + } +} diff --git a/iosApp/iosApp/InAppWebViewController.swift b/iosApp/iosApp/InAppWebViewController.swift new file mode 100644 index 0000000..737607d --- /dev/null +++ b/iosApp/iosApp/InAppWebViewController.swift @@ -0,0 +1,187 @@ +import ComposeApp +import UIKit +import WebKit + +/// 应用内网页(对齐 Android [WebViewActivity]),含广告过滤与页内导航。 +final class InAppWebViewController: UIViewController { + private let urlString: String + private let pageTitle: String? + private var webView: WKWebView? + private let toolbar = UIToolbar() + private let progressView = UIProgressView(progressViewStyle: .bar) + private var progressObservation: NSKeyValueObservation? + private var backButton: UIBarButtonItem! + private var forwardButton: UIBarButtonItem! + private var refreshButton: UIBarButtonItem! + + init(url: String, title: String?) { + urlString = url + pageTitle = title + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = pageTitle?.isEmpty == false ? pageTitle : I18n.shared.t(key: "web_page") + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(closeTapped) + ) + setupToolbar() + setupProgressView() + + let config = WKWebViewConfiguration() + let preferences = WKWebpagePreferences() + preferences.allowsContentJavaScript = true + config.defaultWebpagePreferences = preferences + + WebViewContentBlocker.configure(config, pageUrl: urlString) { [weak self] in + guard let self else { return } + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + webView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: self.toolbar.topAnchor), + ]) + self.webView = webView + self.view.bringSubviewToFront(self.progressView) + self.observeProgress(webView) + self.updateNavigationButtons() + guard let url = Self.resolveURL(self.urlString) else { return } + webView.load(URLRequest(url: url)) + } + } + + deinit { + progressObservation?.invalidate() + } + + private func setupToolbar() { + toolbar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(toolbar) + + backButton = UIBarButtonItem( + image: UIImage(systemName: "chevron.backward"), + style: .plain, + target: self, + action: #selector(goBackTapped) + ) + forwardButton = UIBarButtonItem( + image: UIImage(systemName: "chevron.forward"), + style: .plain, + target: self, + action: #selector(goForwardTapped) + ) + refreshButton = UIBarButtonItem( + barButtonSystemItem: .refresh, + target: self, + action: #selector(refreshTapped) + ) + let flex = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + toolbar.items = [backButton, flex, forwardButton, flex, refreshButton] + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + private func setupProgressView() { + progressView.progressViewStyle = .bar + progressView.progressTintColor = .systemBlue + progressView.trackTintColor = .clear + progressView.progress = 0 + progressView.isHidden = true + progressView.translatesAutoresizingMaskIntoConstraints = false + + let host: UIView = navigationController?.navigationBar ?? view + host.addSubview(progressView) + NSLayoutConstraint.activate([ + progressView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + progressView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + progressView.heightAnchor.constraint(equalToConstant: 3), + ]) + } + + private func observeProgress(_ webView: WKWebView) { + progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in + DispatchQueue.main.async { + guard let self else { return } + let progress = Float(webView.estimatedProgress) + self.progressView.setProgress(progress, animated: true) + self.progressView.isHidden = progress <= 0 || progress >= 1 + } + } + } + + private func updateNavigationButtons() { + guard let webView else { return } + backButton.isEnabled = webView.canGoBack + forwardButton.isEnabled = webView.canGoForward + } + + @objc private func closeTapped() { + dismiss(animated: true) + } + + @objc private func goBackTapped() { + webView?.goBack() + } + + @objc private func goForwardTapped() { + webView?.goForward() + } + + @objc private func refreshTapped() { + webView?.reload() + } + + private static func resolveURL(_ raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed) { return url } + if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) { + return URL(string: encoded) + } + return nil + } +} + +extension InAppWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + progressView.progress = 0 + progressView.isHidden = false + updateNavigationButtons() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + progressView.isHidden = true + updateNavigationButtons() + let url = webView.url?.absoluteString ?? urlString + webView.evaluateJavaScript(WebViewAdBlock.shared.injectionScript(url: url), completionHandler: nil) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + progressView.isHidden = true + updateNavigationButtons() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + progressView.isHidden = true + updateNavigationButtons() + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 3902c21..c329fb5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,6 +2,12 @@ + BGTaskSchedulerPermittedIdentifiers + + com.zhushenwudi.lovelivemusicplayer + + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -17,9 +23,29 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + $(MARKETING_VERSION) CFBundleURLTypes + + CFBundleTypeRole + Editor + CFBundleURLName + qq + CFBundleURLSchemes + + QQ0614FEC2 + + + + CFBundleTypeRole + Editor + CFBundleURLName + qzone + CFBundleURLSchemes + + tencent102039234 + + CFBundleTypeRole Editor @@ -30,6 +56,16 @@ llmp + + CFBundleTypeRole + Editor + CFBundleURLName + llmppush + CFBundleURLSchemes + + llmppush + + CFBundleTypeRole Editor @@ -42,9 +78,49 @@ CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.music + LSApplicationQueriesSchemes + + mqq + mqqapi + timapi + mqqopensdkminiapp + mqqopensdkapi + mqqopensdkapiV2 + mqqopensdkapiV3 + mqqopensdkapiV4 + mqqOpensdkSSoLogin + mqzoneopensdk + mqzoneopensdkapi + mqzoneopensdkapi19 + mqzoneopensdkapiV2 + mqqwpa + mqzone + wtloginmqq2 + LSRequiresIPhoneOS + LSSupportsOpeningDocumentsInPlace + + MOBAppKey + 375f94ab8316c + MOBAppSecret + 9cac7a0532d211eb04fcf6b25b197859 + NSAllowsArbitraryLoads + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + 需要获取摄像头权限扫描二维码 + NSPhotoLibraryUsageDescription + 需要读取相册获取图片 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -71,11 +147,32 @@ + UIApplicationSupportsIndirectInputEvents + UIBackgroundModes audio + fetch + processing + remote-notification + UIFileSharingEnabled + UILaunchScreen + UIRequiresFullScreen + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + diff --git a/iosApp/iosApp/QqShareHost.swift b/iosApp/iosApp/QqShareHost.swift new file mode 100644 index 0000000..6fe36b4 --- /dev/null +++ b/iosApp/iosApp/QqShareHost.swift @@ -0,0 +1,95 @@ +import ComposeApp +import UIKit + +/// 原项目 ShareSDK QQ 网页分享的原生实现(TencentOpenAPI)。 +final class QqShareHost: NSObject, QQApiInterfaceDelegate, TencentSessionDelegate { + static let shared = QqShareHost() + + private var oauth: TencentOAuth? + + func prepare() { + TencentOAuth.setIsUserAgreedAuthorization(true) + if oauth == nil { + oauth = TencentOAuth(appId: "102039234", andDelegate: self) + } + } + + func handleOpen(_ url: URL) -> Bool { + prepare() + // 分享回跳必须走 QQApi,否则 onResp 不会到,成功/失败 toast 出不来。 + let qqHandled = QQApiInterface.handleOpen(url, delegate: self) + let oauthHandled = TencentOAuth.canHandleOpen(url) && TencentOAuth.handleOpen(url) + return qqHandled || oauthHandled + } + + func handleUniversalLink(_ url: URL) -> Bool { + prepare() + return QQApiInterface.handleOpenUniversallink(url, delegate: self) + } + + func share(title: String, text: String, url: String, imageUrl: String?, imagePath: String?) { + prepare() + if !QQApiInterface.isQQInstalled() { + toast(I18n.shared.t(key: "no_qq")) + return + } + guard let page = URL(string: url) else { + toast(I18n.shared.t(key: "share_error") + "url") + return + } + // QQ 网页卡片封面必须是公网 URL,本地文件 QQ 侧抓不到。 + let preview = URL(string: imageUrl ?? "") + ?? URL(string: "https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/ic_launcher.png") + guard let news = QQApiNewsObject.object( + with: page, + title: title, + description: text, + previewImageURL: preview + ) as? QQApiNewsObject else { + toast(I18n.shared.t(key: "share_error") + "news") + return + } + news.shareDestType = .QQ + let req = SendMessageToQQReq(content: news) + let code = QQApiInterface.send(req) + switch code { + case .EQQAPISENDSUCESS, .EQQAPIAPPSHAREASYNC: + break + case .EQQAPIQQNOTINSTALLED, .EQQAPITIMNOTINSTALLED: + toast(I18n.shared.t(key: "no_qq")) + default: + toast(I18n.shared.t(key: "share_error") + "\(code.rawValue)") + } + } + + func onReq(_ req: QQBaseReq!) {} + + func onResp(_ resp: QQBaseResp!) { + switch resp.result { + case "0": + toast(I18n.shared.t(key: "share_success")) + case "-4": + toast(I18n.shared.t(key: "share_cancel")) + default: + let code = [resp.result, resp.errorDescription] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") + toast(I18n.shared.t(key: "share_error") + (code.isEmpty ? "unknown" : code)) + } + } + + private func toast(_ message: String) { + DispatchQueue.main.async { + HostBridge.shared.showToast(message) + } + } + + func isOnlineResponse(_ response: [AnyHashable: Any]!) {} + + func tencentDidLogin() {} + + func tencentDidNotLogin(_ cancelled: Bool) {} + + func tencentDidNotNetWork() {} +} diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift index 04b364a..09d5b99 100644 --- a/iosApp/iosApp/SceneDelegate.swift +++ b/iosApp/iosApp/SceneDelegate.swift @@ -1,9 +1,11 @@ +import AVKit import UIKit import SwiftUI class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private let pipManager = PipScreenManager() + private var didSetupPip = false func scene( _ scene: UIScene, @@ -14,17 +16,53 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: ContentView()) self.window = window + registerSceneObservers() window.makeKeyAndVisible() + setupDesktopLyricPip() if let url = connectionOptions.urlContexts.first?.url { - HostBridge.shared.handleDeepLink(url.absoluteString) + if !QqShareHost.shared.handleOpen(url) { + HostBridge.shared.handleDeepLink(url.absoluteString) + } } + } - registerSceneObservers() + func sceneDidBecomeActive(_ scene: UIScene) { + // 前台不显示桌面歌词:关掉 PiP,但播放中仍保持 dummy,方便下次进后台自动弹出。 + pipManager.stopPictureInPictureOnly() + let defaults = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer") + let playing = defaults?.bool(forKey: "pipIsPlaying") ?? false + let enabled = defaults?.bool(forKey: "desktopLyricEnabled") ?? false + if playing && enabled { + pipManager.picInPicAutoOpen(nil) + } } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - URLContexts.first.map { HostBridge.shared.handleDeepLink($0.url.absoluteString) } + guard let url = URLContexts.first?.url else { return } + if QqShareHost.shared.handleOpen(url) { + return + } + HostBridge.shared.handleDeepLink(url.absoluteString) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard let url = userActivity.webpageURL else { return } + if QqShareHost.shared.handleUniversalLink(url) { + return + } + HostBridge.shared.handleDeepLink(url.absoluteString) + } + + private func setupDesktopLyricPip() { + guard !didSetupPip else { return } + guard AVPictureInPictureController.isPictureInPictureSupported() else { return } + guard let window else { return } + pipManager.addScreenView(on: window) + let enabled = UserDefaults(suiteName: "group.com.zhushenwudi.lovelivemusicplayer")? + .bool(forKey: "desktopLyricEnabled") ?? false + pipManager.picInPicAutoOpen(enabled) + didSetupPip = true } private func registerSceneObservers() { @@ -34,16 +72,26 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { queue: .main ) { [weak self] notification in guard let self else { return } + self.setupDesktopLyricPip() let action = notification.userInfo?["action"] as? String ?? "" switch action { - case "show", "enter": - if let root = self.window?.rootViewController?.view { - self.pipManager.addScreenView(on: root) - self.pipManager.manualChangePicInPic(needStart: true) + case "pipAutoOpen": + let enabled = (notification.userInfo?["enabled"] as? String) == "true" + self.pipManager.picInPicAutoOpen(enabled) + case "playing": + let playing = (notification.userInfo?["playing"] as? String) == "true" + if playing { + self.pipManager.picInPicAutoOpen(nil) + } else { + self.pipManager.manualChangePicInPic(needStart: false) } + case "show": + self.pipManager.picInPicAutoOpen(nil) + self.pipManager.manualChangePicInPic(needStart: true) case "hide": - self.pipManager.manualChangePicInPic(needStart: false) - self.pipManager.removeScreenView() + self.pipManager.stopPictureInPictureOnly() + case "foreground": + self.pipManager.stopPictureInPictureOnly() default: break } @@ -54,8 +102,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { object: nil, queue: .main ) { [weak self] notification in - let line1 = notification.userInfo?["line1"] as? String - let line2 = notification.userInfo?["line2"] as? String + let line1Null = (notification.userInfo?["line1Null"] as? String) == "true" + let line2Null = (notification.userInfo?["line2Null"] as? String) == "true" + let line1: String? = line1Null ? nil : notification.userInfo?["line1"] as? String + let line2: String? = line2Null ? nil : notification.userInfo?["line2"] as? String let currentLine = Int(notification.userInfo?["currentLine"] as? String ?? "-1") ?? -1 self?.pipManager.updatePipScreenView( lyricLine1: line1, diff --git a/iosApp/iosApp/WebViewContentBlocker.swift b/iosApp/iosApp/WebViewContentBlocker.swift new file mode 100644 index 0000000..f28e248 --- /dev/null +++ b/iosApp/iosApp/WebViewContentBlocker.swift @@ -0,0 +1,47 @@ +import ComposeApp +import WebKit + +/// 将 Kotlin [WebViewAdBlock] 规则编译为 WKContentRuleList,并预装页面脚本。 +enum WebViewContentBlocker { + private static let identifier = "llmpAdBlock.v4" + + static func configure( + _ configuration: WKWebViewConfiguration, + pageUrl: String, + completion: @escaping () -> Void + ) { + configuration.userContentController.addUserScript( + WKUserScript( + source: WebViewAdBlock.shared.injectionScript(url: pageUrl), + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + ) + + let json = WebViewAdBlock.shared.buildContentBlockerJson() + guard let store = WKContentRuleListStore.default() else { + DispatchQueue.main.async { completion() } + return + } + store.lookUpContentRuleList(forIdentifier: identifier) { cached, _ in + if let cached { + configuration.userContentController.add(cached) + DispatchQueue.main.async { completion() } + return + } + store.compileContentRuleList( + forIdentifier: identifier, + encodedContentRuleList: json + ) { compiled, error in + DispatchQueue.main.async { + if let compiled { + configuration.userContentController.add(compiled) + } else if let error { + NSLog("LLMP WebView adblock compile failed: \(error.localizedDescription)") + } + completion() + } + } + } + } +} diff --git a/iosApp/iosApp/WidgetAppUtils.swift b/iosApp/iosApp/WidgetAppUtils.swift new file mode 100644 index 0000000..8a8e522 --- /dev/null +++ b/iosApp/iosApp/WidgetAppUtils.swift @@ -0,0 +1,97 @@ +import UIKit + +final class WidgetAppUtils { + static let shared = WidgetAppUtils() + + private init() {} + + func saveImageToFile(imagePath: String, completion: (() -> Void)? = nil) { + if imagePath.hasPrefix("http://") || imagePath.hasPrefix("https://") { + guard let url = URL(string: imagePath) else { + completion?() + return + } + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, let data, let image = UIImage(data: data) else { + DispatchQueue.main.async { completion?() } + return + } + DispatchQueue.main.async { + self.saveProcessedImage(image) + completion?() + } + }.resume() + return + } + + guard let image = UIImage(contentsOfFile: imagePath) else { + completion?() + return + } + saveProcessedImage(image) + completion?() + } + + private func saveProcessedImage(_ image: UIImage) { + let targetSize = calculateAspectRatioSize(image: image, targetWidth: 100) + let resizedImage = resizeImage(image: image, targetSize: targetSize) + + let fileURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: widgetGroupId + )?.appendingPathComponent("sharedImage.png") + + if let data = resizedImage.pngData(), let fileURL { + try? data.write(to: fileURL) + } + + if let color = image.dominantColor() { + let userDefaults = UserDefaults(suiteName: widgetGroupId) + userDefaults?.set("\(color.red),\(color.green),\(color.blue)", forKey: "bgColor") + userDefaults?.synchronize() + } + } + + private func calculateAspectRatioSize(image: UIImage, targetWidth: CGFloat) -> CGSize { + let aspectRatio = image.size.height / image.size.width + let targetHeight = targetWidth * aspectRatio + return CGSize(width: targetWidth, height: targetHeight) + } + + private func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } +} + +private extension UIImage { + func dominantColor() -> (red: CGFloat, green: CGFloat, blue: CGFloat)? { + guard let cgImage = self.cgImage else { return nil } + + let width = 1 + let height = 1 + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return nil + } + + context.draw( + cgImage, + in: CGRect(x: 0, y: 0, width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + ) + guard let data = context.data else { return nil } + let pixel = data.bindMemory(to: UInt8.self, capacity: 3) + return (red: CGFloat(pixel[0]), green: CGFloat(pixel[1]), blue: CGFloat(pixel[2])) + } +} diff --git a/iosApp/iosApp/de.lproj/InfoPlist.strings b/iosApp/iosApp/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..304bb36 --- /dev/null +++ b/iosApp/iosApp/de.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +NSCameraUsageDescription = "Kamerazugriff ist zum Scannen von QR-Codes erforderlich"; +NSPhotoLibraryUsageDescription = "Fotobibliothek-Zugriff ist zum Auswählen von Bildern erforderlich"; diff --git a/iosApp/iosApp/en.lproj/InfoPlist.strings b/iosApp/iosApp/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..acec244 --- /dev/null +++ b/iosApp/iosApp/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +NSCameraUsageDescription = "Camera access is required to scan QR codes"; +NSPhotoLibraryUsageDescription = "Photo library access is required to choose images"; diff --git a/iosApp/iosApp/iosApp-Bridging-Header.h b/iosApp/iosApp/iosApp-Bridging-Header.h new file mode 100644 index 0000000..42aedad --- /dev/null +++ b/iosApp/iosApp/iosApp-Bridging-Header.h @@ -0,0 +1,3 @@ +#import +#import +#import diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements index de3da64..df9118d 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/iosApp/iosApp/iosApp.entitlements @@ -2,13 +2,19 @@ + aps-environment + development + com.apple.developer.associated-domains + + applinks:0524259523de2fc4f2a1a2a32df3efd8.share2dlink.com + com.apple.developer.carplay-audio com.apple.developer.playable-content com.apple.security.application-groups - group.top.zhushenwudi.llmp + group.com.zhushenwudi.lovelivemusicplayer diff --git a/iosApp/iosApp/iosAppRelease.entitlements b/iosApp/iosApp/iosAppRelease.entitlements new file mode 100644 index 0000000..df9118d --- /dev/null +++ b/iosApp/iosApp/iosAppRelease.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:0524259523de2fc4f2a1a2a32df3efd8.share2dlink.com + + com.apple.developer.carplay-audio + + com.apple.developer.playable-content + + com.apple.security.application-groups + + group.com.zhushenwudi.lovelivemusicplayer + + + diff --git a/iosApp/iosApp/ja.lproj/InfoPlist.strings b/iosApp/iosApp/ja.lproj/InfoPlist.strings new file mode 100644 index 0000000..a22cfb0 --- /dev/null +++ b/iosApp/iosApp/ja.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +NSCameraUsageDescription = "QRコードをスキャンするためにカメラへのアクセスが必要です"; +NSPhotoLibraryUsageDescription = "画像を取得するために写真へのアクセスが必要です"; diff --git a/iosApp/iosApp/pip/GradientView.swift b/iosApp/iosApp/pip/GradientView.swift index 23a98ac..11e1ca4 100644 --- a/iosApp/iosApp/pip/GradientView.swift +++ b/iosApp/iosApp/pip/GradientView.swift @@ -1,37 +1,42 @@ import UIKit -class GradientLayer: CAGradientLayer, CAAnimationDelegate { +class GradientLayer: CAGradientLayer { + private static let animationKey = "moveAnimation" + let colorOne = UIColor(red: 0.24, green: 0.67, blue: 0.97, alpha: 1).cgColor let colorTwo = UIColor(red: 0.81, green: 0.03, blue: 0.33, alpha: 1).cgColor let colorThree = UIColor(red: 0.96, green: 0.71, blue: 0.20, alpha: 1).cgColor - let gradientChangeAnimation = CABasicAnimation(keyPath: "colors") var currentGradient = 0 var gradientSet = [[CGColor]]() func animateGradient() { - removeAnimation() - gradientChangeAnimation.fromValue = gradientSet[currentGradient] + guard !gradientSet.isEmpty else { return } + guard animation(forKey: Self.animationKey) == nil else { return } + colors = gradientSet[currentGradient] - - if currentGradient < gradientSet.count - 1 { - currentGradient += 1 - } else { - currentGradient = 0 - } - - gradientChangeAnimation.duration = 5.0 - gradientChangeAnimation.toValue = gradientSet[currentGradient] - gradientChangeAnimation.fillMode = .forwards - gradientChangeAnimation.isRemovedOnCompletion = false - gradientChangeAnimation.delegate = self - add(gradientChangeAnimation, forKey: "moveAnimation") + let keyframes = CAKeyframeAnimation(keyPath: "colors") + keyframes.values = gradientSet + [gradientSet[0]] + keyframes.keyTimes = [ + NSNumber(value: 0), + NSNumber(value: 1.0 / 3.0), + NSNumber(value: 2.0 / 3.0), + NSNumber(value: 1), + ] + keyframes.duration = 15 + keyframes.repeatCount = .infinity + keyframes.calculationMode = .linear + keyframes.isRemovedOnCompletion = false + add(keyframes, forKey: Self.animationKey) } func createGradientView() { - gradientSet.append([colorOne, colorTwo]) - gradientSet.append([colorTwo, colorThree]) - gradientSet.append([colorThree, colorOne]) + gradientSet = [ + [colorOne, colorTwo], + [colorTwo, colorThree], + [colorThree, colorOne], + ] + currentGradient = 0 colors = gradientSet[currentGradient] startPoint = CGPoint(x: 0, y: 0) endPoint = CGPoint(x: 1, y: 1) @@ -39,12 +44,12 @@ class GradientLayer: CAGradientLayer, CAAnimationDelegate { } func removeAnimation() { - removeAnimation(forKey: "moveAnimation") + removeAnimation(forKey: Self.animationKey) } - func animationDidStop(_ anim: CAAnimation, finished flag: Bool) { - if flag { - animateGradient() - } + /// Core Animation 在锁屏时可能丢弃动画,解锁后显式重建。 + func restartAnimation() { + removeAnimation() + animateGradient() } } diff --git a/iosApp/iosApp/pip/PipScreenManager.swift b/iosApp/iosApp/pip/PipScreenManager.swift index f030531..304d869 100644 --- a/iosApp/iosApp/pip/PipScreenManager.swift +++ b/iosApp/iosApp/pip/PipScreenManager.swift @@ -3,16 +3,21 @@ import UIKit @MainActor class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate { + // 4:1 能保持最大宽度,同时比 5:1 的歌词条更接近原来的高度。 + private static let pipAspectRatio: CGFloat = 2000.0 / 500.0 + private var firstWindow: UIWindow? private var pipController: AVPictureInPictureController? - private var screenView = PipScreenView() + private let sourceView = UIView() + private var sourceHeightConstraint: NSLayoutConstraint? private var picInPicView = PipScreenView() - private var isOpenPicInPic = true + private var isOpenPicInPic = false private var suspectedWindows: [UIWindow] = [] + private var didPrepare = false override init() { super.init() - isOpenPicInPic = true + isOpenPicInPic = false } deinit { @@ -20,20 +25,37 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe } func addScreenView(on view: UIView) { - screenView.removeFromSuperview() - view.addSubview(screenView) - NSLayoutConstraint.activate([ - screenView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30), - screenView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30), - screenView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 50), - screenView.heightAnchor.constraint(equalToConstant: 50), - ]) + if sourceView.superview !== view { + sourceView.removeFromSuperview() + sourceView.backgroundColor = .clear + sourceView.isUserInteractionEnabled = false + sourceView.clipsToBounds = true + sourceView.translatesAutoresizingMaskIntoConstraints = false + // 放在 UIWindow 最底层:仍处于可视区域,可触发自动 PiP,但不会盖住 App。 + view.insertSubview(sourceView, at: 0) + let height = sourceView.heightAnchor.constraint( + equalTo: sourceView.widthAnchor, + multiplier: 1.0 / Self.pipAspectRatio + ) + sourceHeightConstraint = height + NSLayoutConstraint.activate([ + sourceView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sourceView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sourceView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + height, + ]) + } view.layoutIfNeeded() - preparePicInPic(on: screenView) + if !didPrepare { + preparePicInPic(on: sourceView) + } else { + pipController?.playerLayer.frame = sourceView.bounds + } } func removeScreenView() { - screenView.removeFromSuperview() + sourceView.removeFromSuperview() + sourceHeightConstraint = nil NotificationCenter.default.removeObserver(self) suspectedWindows.removeAll() } @@ -54,7 +76,8 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe let item = AVPlayerItem(asset: AVAsset(url: url)) let player = AVPlayer(playerItem: item) let playerLayer = AVPlayerLayer(player: player) - playerLayer.frame = CGRect(x: 0, y: 0, width: 0.1, height: 0.1) + view.layoutIfNeeded() + playerLayer.frame = view.bounds playerLayer.backgroundColor = UIColor.black.cgColor playerLayer.videoGravity = .resizeAspectFill view.layer.insertSublayer(playerLayer, at: 0) @@ -68,6 +91,7 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe if #available(iOS 14.2, *) { pipController?.canStartPictureInPictureAutomaticallyFromInline = true } + didPrepare = true NotificationCenter.default.addObserver( self, @@ -81,6 +105,20 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe name: UIWindow.didBecomeVisibleNotification, object: nil ) + for name in [ + UIApplication.didEnterBackgroundNotification, + UIApplication.willEnterForegroundNotification, + UIApplication.didBecomeActiveNotification, + UIApplication.protectedDataDidBecomeAvailableNotification, + UIApplication.significantTimeChangeNotification, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationLifecycleDidChange), + name: name, + object: nil + ) + } } func picInPicAutoOpen(_ isOpen: Bool?) { @@ -94,7 +132,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe } @objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) { - screenView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine) picInPicView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine) } @@ -103,11 +140,37 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe if String(describing: type(of: object)) == "PGHostedWindow" { firstWindow = notification.object as? UIWindow NotificationCenter.default.removeObserver(self, name: UIWindow.didBecomeVisibleNotification, object: nil) + restartPipRendering() } else if let targetWindow = object as? UIWindow { suspectedWindows.append(targetWindow) } } + @objc private func applicationLifecycleDidChange(_ notification: Notification) { + restartPipRendering() + } + + private func restartPipRendering() { + if isOpenPicInPic, + pipController?.playerLayer.player?.timeControlStatus != .playing + { + pipController?.playerLayer.player?.play() + } + + picInPicView.setNeedsLayout() + picInPicView.layoutIfNeeded() + picInPicView.gradientLayer.restartAnimation() + + // 解锁后 PGHostedWindow 会异步恢复,再补一次以避开系统重建窗口的时序。 + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) + guard let self else { return } + self.picInPicView.setNeedsLayout() + self.picInPicView.layoutIfNeeded() + self.picInPicView.gradientLayer.restartAnimation() + } + } + private func filterTargetWindow() -> UIWindow? { for window in suspectedWindows where String(describing: type(of: window)) == "PGHostedWindow" { return window @@ -122,6 +185,9 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe } @objc private func playerItemDidReachEnd(notification: Notification) { + guard let item = notification.object as? AVPlayerItem, + item === pipController?.playerLayer.player?.currentItem + else { return } pipController?.playerLayer.player?.seek(to: .zero) pipController?.playerLayer.player?.play() } @@ -133,13 +199,18 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe pipController?.startPictureInPicture() } } else { - if pipController?.isPictureInPictureActive == true { - pipController?.stopPictureInPicture() - } + stopPictureInPictureOnly() pipController?.playerLayer.player?.pause() } } + /// 只关 PiP,dummy player 继续播,方便下次进后台自动弹出。 + func stopPictureInPictureOnly() { + if pipController?.isPictureInPictureActive == true { + pipController?.stopPictureInPicture() + } + } + func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { @@ -157,6 +228,6 @@ class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDe picInPicView.bottomAnchor.constraint(equalTo: firstWindow.bottomAnchor), ]) } - picInPicView.gradientLayer.animateGradient() + restartPipRendering() } } diff --git a/iosApp/iosApp/pip/black.mp4 b/iosApp/iosApp/pip/black.mp4 index 03376af..7f6b02b 100644 Binary files a/iosApp/iosApp/pip/black.mp4 and b/iosApp/iosApp/pip/black.mp4 differ diff --git a/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings b/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..ceb6e25 --- /dev/null +++ b/iosApp/iosApp/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +NSCameraUsageDescription = "需要获取摄像头权限扫描二维码"; +NSPhotoLibraryUsageDescription = "需要读取相册获取图片"; diff --git a/iosApp/project.yml b/iosApp/project.yml new file mode 100644 index 0000000..e4753bd --- /dev/null +++ b/iosApp/project.yml @@ -0,0 +1,294 @@ +name: iosApp +options: + bundleIdPrefix: com.zhushenwudi.lovelivemusicplayer + deploymentTarget: + iOS: "18.0" + createIntermediateGroups: true + groupSortPosition: top + +# Match Flutter Runner configs (Debug / Release / Profile=AdHoc) +configs: + Debug: debug + Release: release + Profile: release + +settings: + base: + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + CLANG_ENABLE_MODULES: YES + ENABLE_BITCODE: NO + ENABLE_USER_SCRIPT_SANDBOXING: NO + # Xcode 15+/26 Debug Dylib puts app code in *.debug.dylib without App Group + # entitlements → CFPrefs "client is not entitled" / Container null on simulator. + ENABLE_DEBUG_DYLIB: NO + # Synced from Flutter Runner (pubspec 1.7.4+74) + MARKETING_VERSION: "1.7.4" + CURRENT_PROJECT_VERSION: "74" + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES + configs: + Debug: + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Developer" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": V73L9LHPCA + Release: + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": V73L9LHPCA + VALIDATE_PRODUCT: YES + Profile: + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Apple Development" + "CODE_SIGN_IDENTITY[sdk=iphoneos*]": "iPhone Distribution" + DEVELOPMENT_TEAM: "" + "DEVELOPMENT_TEAM[sdk=iphoneos*]": V73L9LHPCA + VALIDATE_PRODUCT: YES + +targets: + iosApp: + type: application + platform: iOS + sources: + - path: iosApp + excludes: + - "Info.plist" + - "**/*.entitlements" + info: + path: iosApp/Info.plist + properties: + CFBundleDisplayName: LLMP + CFBundleName: LLMP + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + LSRequiresIPhoneOS: true + CADisableMinimumFrameDurationOnPhone: true + ITSAppUsesNonExemptEncryption: false + LSApplicationCategoryType: public.app-category.music + LSSupportsOpeningDocumentsInPlace: true + UIFileSharingEnabled: true + UIRequiresFullScreen: true + UIStatusBarHidden: false + UIViewControllerBasedStatusBarAppearance: false + UIApplicationSupportsIndirectInputEvents: true + UILaunchScreen: {} + BGTaskSchedulerPermittedIdentifiers: + - com.zhushenwudi.lovelivemusicplayer + UIBackgroundModes: + - audio + - fetch + - processing + - remote-notification + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + "UISupportedInterfaceOrientations~ipad": + - UIInterfaceOrientationPortrait + NSCameraUsageDescription: 需要获取摄像头权限扫描二维码 + NSPhotoLibraryUsageDescription: 需要读取相册获取图片 + NSAppTransportSecurity: + NSAllowsArbitraryLoads: true + NSAllowsArbitraryLoads: true + MOBAppKey: 375f94ab8316c + MOBAppSecret: 9cac7a0532d211eb04fcf6b25b197859 + LSApplicationQueriesSchemes: + - mqq + - mqqapi + - timapi + - mqqopensdkminiapp + - mqqopensdkapiV2 + - mqqopensdkapiV3 + - mqqopensdkapiV4 + - mqzoneopensdk + - mqzoneopensdkapi + - mqzoneopensdkapi19 + - mqzoneopensdkapiV2 + - mqqwpa + - mqzone + CFBundleURLTypes: + - CFBundleTypeRole: Editor + CFBundleURLName: qq + CFBundleURLSchemes: + - QQ0614FEC2 + - CFBundleTypeRole: Editor + CFBundleURLName: qzone + CFBundleURLSchemes: + - tencent102039234 + - CFBundleTypeRole: Editor + CFBundleURLName: llmp + CFBundleURLSchemes: + - llmp + - CFBundleTypeRole: Editor + CFBundleURLName: llmppush + CFBundleURLSchemes: + - llmppush + # KMP / WidgetKit deep link (also used on Android) + - CFBundleTypeRole: Editor + CFBundleURLName: homeWidget + CFBundleURLSchemes: + - homeWidgetExample + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UISceneConfigurations: + UIWindowSceneSessionRoleApplication: + - UISceneConfigurationName: Default Configuration + UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate + CPTemplateApplicationSceneSessionRoleApplication: + - UISceneConfigurationName: CarPlay Configuration + UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + # Base entitlements written by XcodeGen; Debug/Profile use Debug file, Release overrides below + entitlements: + path: iosApp/iosApp.entitlements + properties: + aps-environment: development + com.apple.developer.associated-domains: + - applinks:0524259523de2fc4f2a1a2a32df3efd8.share2dlink.com + com.apple.developer.carplay-audio: true + com.apple.developer.playable-content: true + com.apple.security.application-groups: + - group.com.zhushenwudi.lovelivemusicplayer + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.zhushenwudi.lovelivemusicplayer + PRODUCT_NAME: LLMP + INFOPLIST_FILE: iosApp/Info.plist + INFOPLIST_KEY_CFBundleDisplayName: LLMP + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.music + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES + LD_RUNPATH_SEARCH_PATHS: + - $(inherited) + - "@executable_path/Frameworks" + FRAMEWORK_SEARCH_PATHS: + - $(inherited) + - "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + OTHER_LDFLAGS: + - $(inherited) + - -framework + - ComposeApp + - -lsqlite3 + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": $(inherited) + configs: + Debug: + CODE_SIGN_ENTITLEMENTS: iosApp/iosApp.entitlements + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-DEVELOP + PROVISIONING_PROFILE_SPECIFIER: "" + Release: + CODE_SIGN_ENTITLEMENTS: iosApp/iosAppRelease.entitlements + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-RELEASE + PROVISIONING_PROFILE_SPECIFIER: "" + Profile: + CODE_SIGN_ENTITLEMENTS: iosApp/iosApp.entitlements + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-AD-HOC + PROVISIONING_PROFILE_SPECIFIER: "" + dependencies: + - target: WidgetExtension + embed: true + postBuildScripts: + - name: Verify Widget Extension Embedded + basedOnDependencyAnalysis: false + script: | + set -euo pipefail + APPEX="${TARGET_BUILD_DIR}/${WRAPPER_NAME}/PlugIns/WidgetExtension.appex" + if [ ! -d "$APPEX" ]; then + echo "error: WidgetExtension.appex is missing from ${TARGET_BUILD_DIR}/${WRAPPER_NAME}" + echo "error: The app will install without widgets. Check WidgetExtension code signing." + exit 1 + fi + echo "Widget extension embedded: $APPEX" + preBuildScripts: + - name: Build ComposeApp.framework + basedOnDependencyAnalysis: false + script: | + set -euo pipefail + if [ "YES" = "${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}" ]; then + echo "Skipping Gradle build (OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES)" + exit 0 + fi + cd "$SRCROOT/.." + ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode + + WidgetExtension: + type: app-extension + platform: iOS + sources: + - path: WidgetExtension + excludes: + - "Info.plist" + - "**/*.entitlements" + - "HomeWidgetExampleControl.swift" + - "HomeWidgetExampleLiveActivity.swift" + info: + path: WidgetExtension/Info.plist + properties: + CFBundleDisplayName: LLMP + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + entitlements: + path: WidgetExtension/WidgetExtension.entitlements + properties: + com.apple.security.application-groups: + - group.com.zhushenwudi.lovelivemusicplayer + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample + PRODUCT_NAME: "$(TARGET_NAME)" + INFOPLIST_FILE: WidgetExtension/Info.plist + INFOPLIST_KEY_CFBundleDisplayName: LLMP + INFOPLIST_KEY_NSHumanReadableCopyright: "" + CODE_SIGN_ENTITLEMENTS: WidgetExtension/WidgetExtension.entitlements + # Match Flutter HomeWidgetExampleExtension deployment + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME: WidgetBackground + LD_RUNPATH_SEARCH_PATHS: + - $(inherited) + - "@executable_path/Frameworks" + - "@executable_path/../../Frameworks" + SKIP_INSTALL: YES + SUPPORTS_MACCATALYST: NO + GENERATE_INFOPLIST_FILE: YES + "EXCLUDED_ARCHS[sdk=iphoneos*]": armv7 + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": $(inherited) + configs: + Debug: + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-DEVELOP-WIDGET + PROVISIONING_PROFILE_SPECIFIER: "" + Release: + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-RELEASE-WIDGET + PROVISIONING_PROFILE_SPECIFIER: "" + Profile: + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": LLMP-M-AD-HOC-WIDGET + PROVISIONING_PROFILE_SPECIFIER: "" + +schemes: + # Main app scheme — do NOT list WidgetExtension with `run` / `all`, or XcodeGen + # marks wasCreatedForAppExtension=YES and Run jumps to widget debug (needs _XCWidgetKind). + # Widget still builds + embeds via iosApp → WidgetExtension dependency. + iosApp: + build: + targets: + iosApp: all + run: + config: Debug + archive: + config: Release + profile: + config: Profile + + # Only use this scheme when debugging WidgetKit itself (intentionally opens Home). + WidgetExtension: + build: + targets: + WidgetExtension: [run, test, profile, analyze, archive] + iosApp: [run] + run: + config: Debug + environmentVariables: + _XCWidgetKind: HomeWidgetExampleWhite + _XCWidgetFamily: systemMedium diff --git a/iosApp/setup.sh b/iosApp/setup.sh new file mode 100755 index 0000000..2321dfa --- /dev/null +++ b/iosApp/setup.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +if ! command -v xcodegen >/dev/null 2>&1; then + echo "Installing xcodegen via Homebrew..." + brew install xcodegen +fi +xcodegen generate +echo "Generated iosApp.xcodeproj — open with: open iosApp.xcodeproj" diff --git a/settings.gradle.kts b/settings.gradle.kts index ba92882..e0121e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,42 +6,72 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") val nexusBaseUrl = "http://192.168.123.100:18081" -fun probeNexus(baseUrl: String): Boolean = +/** + * Probe a URL: returns HTTP status, or -1 on connection failure. + * Consumes/closes the body so connections are not leaked during settings eval. + */ +fun httpStatus(url: String, timeoutMs: Int = 2000): Int = try { - val conn = URI.create("$baseUrl/service/rest/v1/status").toURL() - .openConnection() as HttpURLConnection - conn.connectTimeout = 2000 - conn.readTimeout = 2000 + val conn = URI.create(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = timeoutMs + conn.readTimeout = timeoutMs conn.requestMethod = "GET" conn.instanceFollowRedirects = true val code = conn.responseCode - conn.inputStream?.close() + (if (code in 200 until 400) conn.inputStream else conn.errorStream)?.close() conn.disconnect() - code in 200 until 400 + code } catch (_: Throwable) { - false + -1 } +fun probeNexusStatus(baseUrl: String): Boolean = + httpStatus("$baseUrl/service/rest/v1/status") in 200 until 400 + +/** + * Nexus may serve a cached POM while the matching JAR is "Remote Auto Blocked". + * Gradle then treats the module as found at Nexus and never falls back to google(). + * Probe a known Google Maven JAR; only use Nexus google if that JAR is actually there. + */ +fun probeNexusGoogleProxy(baseUrl: String): Boolean { + val canaryJar = + "$baseUrl/repository/google/com/android/tools/build/jetifier/" + + "jetifier-core/1.0.0-beta10/jetifier-core-1.0.0-beta10.jar" + return httpStatus(canaryJar, timeoutMs = 3000) == 200 +} + val forcedNexus = startParameter.projectProperties["useNexus"] -val useNexusRepos = when { - forcedNexus != null -> forcedNexus.toBoolean() - else -> probeNexus(nexusBaseUrl) +val (useNexusRepos, useNexusGoogle) = when { + forcedNexus != null -> { + val on = forcedNexus.toBoolean() + on to on + } + !probeNexusStatus(nexusBaseUrl) -> false to false + else -> true to probeNexusGoogleProxy(nexusBaseUrl) } gradle.extra["useNexusRepos"] = useNexusRepos +gradle.extra["useNexusGoogle"] = useNexusGoogle gradle.extra["nexusBaseUrl"] = nexusBaseUrl logger.lifecycle( - if (useNexusRepos) { - "[repos] Nexus reachable — prefer $nexusBaseUrl, public repos as fallback" - } else { - "[repos] Nexus unreachable, using public repositories only" + when { + !useNexusRepos -> + "[repos] Nexus unreachable, using public repositories only" + !useNexusGoogle -> + "[repos] Nexus up but google proxy incomplete/blocked — " + + "skip Nexus google, use public google() (Nexus still used for other repos)" + else -> + "[repos] Nexus reachable — prefer $nexusBaseUrl, public repos as fallback" }, ) /** * Prefer local Nexus (fast) then fall back to public mirrors so missing - * proxy artifacts (e.g. AGP) still resolve. + * proxy artifacts (e.g. AGP / jetifier) still resolve. + * + * Never list a Nexus google repo that can return POM-without-JAR: that + * blocks Gradle fallback to the real Google Maven repository. */ fun RepositoryHandler.addLlmpRepositories(includeGradlePortal: Boolean) { if (useNexusRepos) { @@ -49,9 +79,11 @@ fun RepositoryHandler.addLlmpRepositories(includeGradlePortal: Boolean) { url = uri("$nexusBaseUrl/repository/maven-public/") isAllowInsecureProtocol = true } - maven { - url = uri("$nexusBaseUrl/repository/google/") - isAllowInsecureProtocol = true + if (useNexusGoogle) { + maven { + url = uri("$nexusBaseUrl/repository/google/") + isAllowInsecureProtocol = true + } } maven { url = uri("$nexusBaseUrl/repository/mob/") @@ -78,31 +110,49 @@ fun RepositoryHandler.addLlmpRepositories(includeGradlePortal: Boolean) { pluginManagement { val base = "http://192.168.123.100:18081" val forced = settings.startParameter.projectProperties["useNexus"] - val useNexus = when { - forced != null -> forced.toBoolean() - else -> try { - val conn = java.net.URI.create("$base/service/rest/v1/status").toURL() + + // pluginManagement is isolated from the rest of settings.gradle.kts — keep probes inline. + fun status(url: String, timeoutMs: Int = 2000): Int = + try { + val conn = java.net.URI.create(url).toURL() .openConnection() as java.net.HttpURLConnection - conn.connectTimeout = 2000 - conn.readTimeout = 2000 + conn.connectTimeout = timeoutMs + conn.readTimeout = timeoutMs conn.requestMethod = "GET" + conn.instanceFollowRedirects = true val code = conn.responseCode - conn.inputStream?.close() + (if (code in 200 until 400) conn.inputStream else conn.errorStream)?.close() conn.disconnect() - code in 200 until 400 + code } catch (_: Throwable) { - false + -1 + } + + val (useNexus, useNexusGoogle) = when { + forced != null -> { + val on = forced.toBoolean() + on to on + } + status("$base/service/rest/v1/status") !in 200 until 400 -> false to false + else -> { + val canary = + "$base/repository/google/com/android/tools/build/jetifier/" + + "jetifier-core/1.0.0-beta10/jetifier-core-1.0.0-beta10.jar" + true to (status(canary, timeoutMs = 3000) == 200) } } + repositories { if (useNexus) { maven { url = uri("$base/repository/maven-public/") isAllowInsecureProtocol = true } - maven { - url = uri("$base/repository/google/") - isAllowInsecureProtocol = true + if (useNexusGoogle) { + maven { + url = uri("$base/repository/google/") + isAllowInsecureProtocol = true + } } maven { url = uri("$base/repository/mob/") @@ -124,6 +174,9 @@ pluginManagement { maven { url = uri("https://jitpack.io") } } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 15f94d2..67f07f7 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -8,24 +8,23 @@ plugins { } kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + android { - namespace = "top.zhushenwudi.llmp.shared" + namespace = "com.zhushenwudi.lovelivemusicplayer.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() compilerOptions { jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xexpect-actual-classes") } } - listOf( - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "Shared" - isStatic = true - } - } + // iOS targets only — framework is exported via :composeApp (ComposeApp.framework) + iosArm64() + iosSimulatorArm64() sourceSets { commonMain.dependencies { @@ -61,7 +60,7 @@ kotlin { sqldelight { databases { create("LlmpDatabase") { - packageName.set("top.zhushenwudi.llmp.db") + packageName.set("com.zhushenwudi.lovelivemusicplayer.db") verifyMigrations.set(false) } } diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.android.kt similarity index 86% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.android.kt index 4699488..2fdf3ab 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.android.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.db +package com.zhushenwudi.lovelivemusicplayer.db import android.content.Context import app.cash.sqldelight.db.SqlDriver diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.android.kt new file mode 100644 index 0000000..3c47cbb --- /dev/null +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.android.kt @@ -0,0 +1,22 @@ +package com.zhushenwudi.lovelivemusicplayer.db + +import java.util.Calendar + +actual fun currentTimeMs(): Long = System.currentTimeMillis() + +actual fun formatLocalHm(epochMs: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = epochMs } + val hour = cal.get(Calendar.HOUR_OF_DAY) + val minute = cal.get(Calendar.MINUTE) + return hour.toString().padStart(2, '0') + ":" + minute.toString().padStart(2, '0') +} + +actual fun formatLocalHms(epochMs: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = epochMs } + val hour = cal.get(Calendar.HOUR_OF_DAY) + val minute = cal.get(Calendar.MINUTE) + val second = cal.get(Calendar.SECOND) + return hour.toString().padStart(2, '0') + ":" + + minute.toString().padStart(2, '0') + ":" + + second.toString().padStart(2, '0') +} diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.android.kt new file mode 100644 index 0000000..8e80502 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.android.kt @@ -0,0 +1,5 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +import java.util.Locale + +actual fun detectSystemLang(): AppLang = langFromTag(Locale.getDefault().toLanguageTag()) diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.android.kt new file mode 100644 index 0000000..9c72ed5 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.android.kt @@ -0,0 +1,7 @@ +package com.zhushenwudi.lovelivemusicplayer.lyric + +actual object PlatformInfo { + actual val allowEulaLyric: Boolean = true + actual val isIos: Boolean = false + actual val appVersion: String = "" +} diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.android.kt similarity index 74% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.android.kt index 59edf5c..3247a7c 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.android.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.network +package com.zhushenwudi.lovelivemusicplayer.network import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AndroidPlatformHooks.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AndroidPlatformHooks.kt similarity index 78% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AndroidPlatformHooks.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AndroidPlatformHooks.kt index 74e1c34..da5f265 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AndroidPlatformHooks.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AndroidPlatformHooks.kt @@ -1,7 +1,7 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform /** - * Android MediaSession metadata bridge (PlaybackService owns the session player). + * Android MediaSession 元数据桥接(PlaybackService 拥有会话播放器)。 */ class AndroidMediaSessionBridge : MediaSessionHook { @Volatile diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.android.kt similarity index 61% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.android.kt index b82fee9..53201ed 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.android.kt @@ -1,17 +1,28 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import android.util.Log actual object AppLog { private const val MAX = 3500 - actual fun d(tag: String, message: String) = log(Log.DEBUG, tag, message) + actual fun d(tag: String, message: String) { + AppLogStore.append("D", tag, message) + log(Log.DEBUG, tag, message) + } - actual fun i(tag: String, message: String) = log(Log.INFO, tag, message) + actual fun i(tag: String, message: String) { + AppLogStore.append("I", tag, message) + log(Log.INFO, tag, message) + } - actual fun w(tag: String, message: String) = log(Log.WARN, tag, message) + actual fun w(tag: String, message: String) { + AppLogStore.append("W", tag, message) + log(Log.WARN, tag, message) + } actual fun e(tag: String, message: String, throwable: Throwable?) { + val full = if (throwable != null) "$message | ${throwable.message}" else message + AppLogStore.append("E", tag, full) if (throwable != null) { chunk(message).forEachIndexed { index, part -> if (index == 0) Log.e(tag, part, throwable) else Log.e(tag, part) diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.android.kt similarity index 59% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.android.kt index e709b71..e4432c7 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.android.kt @@ -1,8 +1,6 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import android.content.Context -import android.os.Environment -import android.os.storage.StorageManager import java.io.File actual class FileSystem(private val context: Context) { @@ -32,6 +30,26 @@ actual class FileSystem(private val context: Context) { false } + actual fun writeBegin(absolutePath: String): Boolean = + try { + val file = File(absolutePath) + file.parentFile?.mkdirs() + file.writeBytes(ByteArray(0)) + true + } catch (_: Throwable) { + false + } + + actual fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean = + try { + java.io.FileOutputStream(File(absolutePath), true).use { out -> + out.write(bytes, offset, length) + } + true + } catch (_: Throwable) { + false + } + actual fun listFiles(absoluteDir: String): List { val dir = File(absoluteDir) if (!dir.isDirectory) return emptyList() @@ -41,18 +59,17 @@ actual class FileSystem(private val context: Context) { .orEmpty() } + /** + * 对齐 Flutter `SDUtils.getUsbPathList`: + * `getExternalFilesDirs` 含内部存储 + 可移动卷的应用私有目录, + * 再按 package 过滤。 + */ actual fun listUsbRoots(): List { - val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager - ?: return emptyList() - return try { - sm.storageVolumes.mapNotNull { volume -> - if (volume.isRemovable) { - @Suppress("DEPRECATION") - volume.directory?.absolutePath - } else null - } - } catch (_: Throwable) { - emptyList() + val dirs = context.getExternalFilesDirs(null) ?: return emptyList() + val pkg = context.packageName + return dirs.mapNotNull { dir -> + val path = dir?.absolutePath ?: return@mapNotNull null + if (path.contains(pkg)) path else null } } } diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt similarity index 66% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt index 468e4a5..c8a17b7 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.android.kt @@ -1,14 +1,16 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings +import android.view.WindowManager import android.widget.Toast +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n /** - * Holds the current Activity for launching platform UI from common code. + * 保存当前 Activity,供 common 代码启动平台 UI。 */ object AndroidUiHost { @Volatile @@ -17,15 +19,8 @@ object AndroidUiHost { @Volatile var qrResultHandler: ((String) -> Unit)? = null - @Volatile - var imagePickHandler: ((String?) -> Unit)? = null - - /** Set from MainActivity after registering Activity Result launcher. */ - @Volatile - var launchImagePicker: (() -> Unit)? = null - - const val QR_SCAN_ACTIVITY = "top.zhushenwudi.llmp.native.QrScanActivity" - const val WEB_VIEW_ACTIVITY = "top.zhushenwudi.llmp.native.WebViewActivity" + const val QR_SCAN_ACTIVITY = "com.zhushenwudi.lovelivemusicplayer.native.QrScanActivity" + const val WEB_VIEW_ACTIVITY = "com.zhushenwudi.lovelivemusicplayer.native.WebViewActivity" const val EXTRA_SCAN_RESULT = "scan_result" const val EXTRA_URL = "url" const val EXTRA_TITLE = "title" @@ -39,7 +34,15 @@ actual object PlatformUi { } actual fun openWebView(url: String, title: String?) { - val act = AndroidUiHost.activity ?: return + val act = AndroidUiHost.activity + if (act == null) { + showToast(I18n.t("cannot_open_page")) + return + } + if (url.isBlank()) { + showToast(I18n.t("invalid_link")) + return + } act.startActivity( Intent().setClassName(act, AndroidUiHost.WEB_VIEW_ACTIVITY).apply { putExtra(AndroidUiHost.EXTRA_URL, url) @@ -66,22 +69,12 @@ actual object PlatformUi { actual fun showToast(message: String) { val act = AndroidUiHost.activity ?: return + val text = message.asLtrDisplay() act.runOnUiThread { - Toast.makeText(act, message, Toast.LENGTH_SHORT).show() + Toast.makeText(act, text, Toast.LENGTH_SHORT).show() } } - actual fun pickImage(onResult: (String?) -> Unit) { - AndroidUiHost.imagePickHandler = onResult - val launcher = AndroidUiHost.launchImagePicker - if (launcher == null) { - onResult(null) - showToast("图片选择暂不可用") - return - } - launcher.invoke() - } - actual fun openExternalUrl(url: String) { val act = AndroidUiHost.activity ?: return runCatching { @@ -93,4 +86,15 @@ actual object PlatformUi { val act = AndroidUiHost.activity ?: return act.finishAffinity() } + + actual fun setKeepScreenOn(enabled: Boolean) { + val act = AndroidUiHost.activity ?: return + act.runOnUiThread { + if (enabled) { + act.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + act.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } } diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/AndroidPlayerHolder.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt similarity index 78% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/AndroidPlayerHolder.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt index 7b64dce..a39771b 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/AndroidPlayerHolder.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/AndroidPlayerHolder.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player import android.content.Context import androidx.media3.common.AudioAttributes @@ -6,7 +6,7 @@ import androidx.media3.common.C import androidx.media3.exoplayer.ExoPlayer /** - * Single ExoPlayer shared by [PlayerController] and [PlaybackService] MediaSession. + * [com.zhushenwudi.lovelivemusicplayer.player.PlayerController] 与 [com.zhushenwudi.lovelivemusicplayer.player.PlaybackService] MediaSession 共享的单一 ExoPlayer。 */ object AndroidPlayerHolder { @Volatile @@ -24,7 +24,7 @@ object AndroidPlayerHolder { .build(), /* handleAudioFocus = */ true, ) - // Pause when wired/BT headset is unplugged or disconnects. + // 有线/蓝牙耳机拔出或断开时暂停。 .setHandleAudioBecomingNoisy(true) .build() player = created diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.android.kt similarity index 57% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.android.kt index 81d4f11..e2d2bc3 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.android.kt @@ -1,9 +1,10 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player import android.content.Context import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.CoroutineScope @@ -16,8 +17,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.platform.PlatformServices +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog +import com.zhushenwudi.lovelivemusicplayer.platform.PlatformServices import java.io.File import kotlin.random.Random @@ -34,9 +37,17 @@ actual class PlayerController(context: Context) { actual var resolveCoverUri: ((Music) -> String?)? = null private var queue: List = emptyList() + private var nowPlayingLyric: String? = null - private fun playbackPath(music: Music): String? = - resolvePlaybackUri?.invoke(music) ?: music.musicPath + private fun playbackPath(music: Music): String? { + resolvePlaybackUri?.invoke(music)?.let { return it } + val raw = music.musicPath ?: return null + // 与 iOS 一致:绝不将数据库相对路径当作可播放 URI。 + return raw.takeIf { + it.startsWith("http") || it.startsWith("file") || + it.startsWith("asset") || it.startsWith("content") || it.startsWith("/") + } + } init { player.addListener( @@ -44,6 +55,7 @@ actual class PlayerController(context: Context) { override fun onIsPlayingChanged(isPlaying: Boolean) { _state.update { it.copy(isPlaying = isPlaying) } if (isPlaying) startPositionUpdates() else positionJob?.cancel() + applyNowPlayingTitle() syncPlatform(isPlaying = isPlaying) } @@ -58,14 +70,36 @@ actual class PlayerController(context: Context) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { val idx = player.currentMediaItemIndex + val current = queue.getOrNull(idx) ?: _state.value.current + val sameSong = current?.musicId != null && + current.musicId == _state.value.current?.musicId + if (!sameSong) nowPlayingLyric = null _state.update { it.copy( index = idx, - current = queue.getOrNull(idx) ?: it.current, + current = current, ) } + if (!sameSong) applyNowPlayingTitle() syncPlatform(isPlaying = player.isPlaying) } + + override fun onPlayerError(error: PlaybackException) { + val uri = player.currentMediaItem?.localConfiguration?.uri + AppLog.e( + TAG, + "exo error code=${error.errorCode} name=${error.errorCodeName} " + + "msg=${error.message} uri=$uri " + + "id=${_state.value.current?.musicId} name=${_state.value.current?.musicName}", + error, + ) + _state.update { + it.copy( + isPlaying = false, + error = error.message ?: error.errorCodeName, + ) + } + } }, ) } @@ -93,13 +127,7 @@ actual class PlayerController(context: Context) { isPlaying = isPlaying, ) PlatformServices.desktopLyric.setPlaying(isPlaying) - PlatformServices.homeWidget.update( - songName = current?.musicName, - artist = current?.artist, - isPlaying = isPlaying, - favorite = current?.isLove == true, - coverPath = current?.coverPath, - ) + // 小组件由 AppContainer.bindHomeWidget / pushHomeWidget 统一同步。 PlatformServices.carPlayCatalog.updateNowPlaying(current) } @@ -115,11 +143,11 @@ actual class PlayerController(context: Context) { return toUri(raw) } - /** Media3 notification / session metadata (title, artist, album, artwork). */ + /** Media3 通知/会话元数据(标题、艺人、专辑、封面)。 */ private fun toMediaItem(music: Music, playbackPath: String): MediaItem { val uri = toUri(playbackPath) val metadata = MediaMetadata.Builder() - .setTitle(music.musicName ?: "未知歌曲") + .setTitle(music.musicName ?: I18n.t("unknown_song")) .setArtist(music.artist.orEmpty()) .setAlbumTitle(music.albumName.orEmpty()) .setArtworkUri(artworkUri(music)) @@ -132,36 +160,65 @@ actual class PlayerController(context: Context) { } actual fun setQueue(items: List, startIndex: Int) { - player.stop() - player.clearMediaItems() val playable = items.mapNotNull { music -> - val path = playbackPath(music) ?: return@mapNotNull null + val path = playbackPath(music) + if (path == null) { + AppLog.w( + TAG, + "setQueue drop id=${music.musicId} name=${music.musicName} reason=nullUri", + ) + return@mapNotNull null + } music to toMediaItem(music, path) } if (playable.isEmpty()) { + AppLog.w(TAG, "setQueue emptyPlayable startIndex=$startIndex") + player.stop() + player.clearMediaItems() queue = items _state.update { - it.copy(queue = items, index = -1, current = null, isPlaying = false) + it.copy( + queue = items, + index = -1, + current = null, + isPlaying = false, + error = "No playable URI in queue", + ) } return } - // Prefer original startIndex among playable entries. val startMusic = items.getOrNull(startIndex) val mediaStart = playable.indexOfFirst { it.first.musicId == startMusic?.musicId } .takeIf { it >= 0 } ?: startIndex.coerceIn(0, playable.lastIndex) + val sameCurrent = startMusic?.musicId != null && + startMusic.musicId == _state.value.current?.musicId && + player.mediaItemCount > 0 + val resumePos = if (sameCurrent) player.currentPosition.coerceAtLeast(0) else 0L + val wasPlaying = player.isPlaying queue = playable.map { it.first } val mediaItems = playable.map { it.second } - player.setMediaItems(mediaItems, mediaStart.coerceIn(0, mediaItems.lastIndex), 0) + // Preserve timeline when only splicing the queue (e.g. 「下一首播放」). + player.setMediaItems( + mediaItems, + mediaStart.coerceIn(0, mediaItems.lastIndex), + resumePos, + ) player.prepare() + if (wasPlaying && sameCurrent) { + player.play() + } _state.update { it.copy( queue = queue, index = mediaStart.coerceIn(0, queue.lastIndex), current = queue.getOrNull(mediaStart), + positionMs = resumePos, + error = null, + isPlaying = wasPlaying && sameCurrent, ) } - syncPlatform(isPlaying = false) + syncPlatform(isPlaying = wasPlaying && sameCurrent) } actual fun play() { @@ -186,7 +243,33 @@ actual class PlayerController(context: Context) { } actual fun previous() { - if (player.hasPreviousMediaItem()) player.seekToPreviousMediaItem() + if (queue.isEmpty() || player.mediaItemCount == 0) return + val positionMs = player.currentPosition.coerceAtLeast(0) + if (positionMs > 3000) { + player.seekTo(player.currentMediaItemIndex, 0L) + _state.update { it.copy(positionMs = 0L) } + return + } + when (_state.value.loopMode) { + LoopMode.SHUFFLE -> player.seekToPrevious() + LoopMode.LIST -> { + if (queue.size <= 1) { + player.seekTo(0, 0L) + return + } + val currentIndex = player.currentMediaItemIndex.coerceIn(0, queue.lastIndex) + val prevIndex = if (currentIndex > 0) currentIndex - 1 else queue.lastIndex + player.seekTo(prevIndex, 0L) + } + LoopMode.SINGLE -> { + val currentIndex = player.currentMediaItemIndex.coerceIn(0, queue.lastIndex) + if (currentIndex > 0) { + player.seekTo(currentIndex - 1, 0L) + } else { + player.seekTo(0, 0L) + } + } + } } actual fun playAtIndex(index: Int) { @@ -214,6 +297,52 @@ actual class PlayerController(context: Context) { _state.update { it.copy(loopMode = mode) } } + actual fun syncLoveState(musicId: String, isLove: Boolean) { + queue = queue.map { music -> + if (music.musicId == musicId) music.copy(isLove = isLove) else music + } + _state.update { st -> + val current = st.current?.let { music -> + if (music.musicId == musicId) music.copy(isLove = isLove) else music + } + st.copy(queue = queue, current = current) + } + } + + actual fun setNowPlayingLyric(line: String?) { + val next = line?.trim()?.takeIf { it.isNotEmpty() } + if (nowPlayingLyric == next) return + nowPlayingLyric = next + // ExoPlayer 必须在主线程;歌词流在 Default 上推送。 + scope.launch { applyNowPlayingTitle() } + } + + /** + * 蓝牙 / 车机 / 媒体通知读的是当前 MediaItem 标题。 + * 播放中有歌词则用当前句,暂停或暂无歌词则用歌名。 + */ + private fun applyNowPlayingTitle() { + val current = _state.value.current ?: return + val songName = current.musicName ?: I18n.t("unknown_song") + val title = if (player.isPlaying) { + nowPlayingLyric?.takeIf { it.isNotBlank() } ?: songName + } else { + songName + } + val index = player.currentMediaItemIndex + if (index < 0 || index >= player.mediaItemCount) return + val item = player.getMediaItemAt(index) + if (item.mediaMetadata.title?.toString() == title) return + val metadata = item.mediaMetadata.buildUpon() + .setTitle(title) + .setDisplayTitle(title) + .build() + player.replaceMediaItem( + index, + item.buildUpon().setMediaMetadata(metadata).build(), + ) + } + actual fun removeAt(index: Int) { if (index !in queue.indices) return if (queue.size == 1) { @@ -307,6 +436,10 @@ actual class PlayerController(context: Context) { actual fun release() { positionJob?.cancel() - // Shared ExoPlayer is released with PlaybackService / process teardown. + // 共享 ExoPlayer 随 PlaybackService/进程销毁而释放。 + } + + private companion object { + const val TAG = "LLMP-Player" } } diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.android.kt similarity index 89% rename from shared/src/androidMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.android.kt rename to shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.android.kt index f4e49cf..427a885 100644 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.android.kt +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.android.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.settings +package com.zhushenwudi.lovelivemusicplayer.settings import android.content.Context import com.russhwolf.settings.Settings diff --git a/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.android.kt b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.android.kt new file mode 100644 index 0000000..1c8f012 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.android.kt @@ -0,0 +1,10 @@ +package com.zhushenwudi.lovelivemusicplayer.util + +import java.security.MessageDigest + +actual fun sha512Hex(text: String): String { + val bytes = MessageDigest.getInstance("SHA-512").digest(text.encodeToByteArray()) + return bytes.joinToString("") { b -> + (b.toInt() and 0xff).toString(16).padStart(2, '0') + } +} diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/Time.android.kt b/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/Time.android.kt deleted file mode 100644 index 33a4a8b..0000000 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/db/Time.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package top.zhushenwudi.llmp.db - -actual fun currentTimeMs(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.android.kt b/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.android.kt deleted file mode 100644 index 56e35c1..0000000 --- a/shared/src/androidMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package top.zhushenwudi.llmp.lyric - -actual object PlatformInfo { - actual val allowEulaLyric: Boolean = true -} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/Const.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/Const.kt new file mode 100644 index 0000000..165b8f3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/Const.kt @@ -0,0 +1,77 @@ +package com.zhushenwudi.lovelivemusicplayer + +import io.ktor.http.encodeURLPathPart + +/** 应用全局常量。 */ +object Const { + /** 双端传输协议版本,须与 PC Electron 一致。 */ + const val TRANS_VER: Int = 1 + + /** 设计稿基准尺寸(UI_WIDTH / UI_HEIGHT)。 */ + const val UI_WIDTH: Float = 375f + const val UI_HEIGHT: Float = 812f + + const val PACKAGE_ID: String = "com.zhushenwudi.lovelivemusicplayer" + + /** App Store 上架 bundleId(与原 Flutter `Const.packageId` 一致)。 */ + const val APPSTORE_BUNDLE_ID: String = "com.zhushenwudi.lovelivemusicplayer" + const val APPSTORE_ID: String = "1641625393" + const val APPSTORE_LOOKUP_URL: String = + "https://itunes.apple.com/lookup?bundleId=$APPSTORE_BUNDLE_ID" + const val APPSTORE_PAGE_URL: String = "https://apps.apple.com/app/id$APPSTORE_ID" + + // Cloudflare R2 对象存储 + const val R2_OSS_URL: String = "https://llmp-oss.zhushenwudi.top/" + + // 阿里云 OSS + const val ALI_OSS_URL: String = + "https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/" + + const val LYRIC_URL: String = "${R2_OSS_URL}lyric/" + const val SPLASH_URL: String = "${R2_OSS_URL}LLMP-M/splash_bg/" + const val SPLASH_CONFIG_URL: String = "${SPLASH_URL}splash_config.json" + const val SHARE_DEFAULT_LOGO: String = "${ALI_OSS_URL}ic_launcher.png" + const val MOE_GIRL_URL: String = "https://zh.moegirl.org.cn/" + const val PUSH_URL: String = "${ALI_OSS_URL}push/push.txt" + + const val BACKEND_URL: String = + "https://netease-backend.zhushenwudi.top/song/detail" + + /** QQ 互联开放平台 AppId(与原 Flutter / ShareSDK 一致)。 */ + const val QQ_APP_ID: String = "102039234" + + /** + * QQ 网页分享落地页。 + * H5 `urlParse.getUrlKey` 从整段 href 读 `type`/`data`; + * 参数必须放在 hash 前的 query,否则 QQ 丢掉 `#/...` 后会进下载页。 + */ + const val SHARE_QQ_PAGE: String = "https://shareqq.zhushenwudi.top/" + + /** 歌单分享 KV(阿里 Serverless)。 */ + const val SHARE_KV_URL: String = + "http://fc-mp-92601a5b-3adb-44ca-b6f6-fc4e8b3edbca.next.bspapp.com/saveShareKV" + + /** WiFi 传输命令 WebSocket 端口(PC)。 */ + const val WS_MUSIC_PORT: Int = 4388 + + /** 数据同步命令 WebSocket 端口(PC)。 */ + const val WS_DATA_PORT: Int = 4389 + + fun dataBaseUrl(env: String = "prod"): String = + "${R2_OSS_URL}LLMP-M/data/v2/$env/" + + fun dataUrl(env: String = "prod"): String = "${dataBaseUrl(env)}data.json" + + fun artistModelUrl(env: String = "prod"): String = + "${dataBaseUrl(env)}artist.json" + + fun updateUrl(env: String = "prod"): String = + "${dataBaseUrl(env)}version.json" + + /** 萌娘百科条目/搜索 URL(路径段 UTF-8 编码,对齐 Flutter Const.moeGirlUrl + 歌名)。 */ + fun buildMoeGirlSearchUrl(query: String): String { + val q = query.trim() + if (q.isEmpty()) return MOE_GIRL_URL + return MOE_GIRL_URL + q.encodeURLPathPart() + } +} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/data/DataImportService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/data/DataImportService.kt similarity index 51% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/data/DataImportService.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/data/DataImportService.kt index 7c2eb21..f07a998 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/data/DataImportService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/data/DataImportService.kt @@ -1,16 +1,17 @@ -package top.zhushenwudi.llmp.data +package com.zhushenwudi.lovelivemusicplayer.data import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.db.LibraryRepository -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Artist -import top.zhushenwudi.llmp.domain.ArtistModel -import top.zhushenwudi.llmp.domain.CloudData -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.network.OssApi -import top.zhushenwudi.llmp.settings.AppSettings -import top.zhushenwudi.llmp.util.PathResolver +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.Artist +import com.zhushenwudi.lovelivemusicplayer.domain.ArtistModel +import com.zhushenwudi.lovelivemusicplayer.domain.CloudData +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings +import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils +import com.zhushenwudi.lovelivemusicplayer.util.PathResolver class DataImportService( private val oss: OssApi, @@ -27,7 +28,7 @@ class DataImportService( val artistCount: Int, ) - /** Remote data.json version, or null on network/parse failure. */ + /** 远程 data.json 版本号,网络/解析失败时返回 null。 */ suspend fun fetchRemoteVersion(): Int? = runCatching { val raw = oss.fetchDataJson() json.decodeFromString(raw).version @@ -49,20 +50,23 @@ class DataImportService( }.orEmpty() val artistByName = artistModels.associateBy { it.k } - library.clearMusic() - library.clearAlbums() - library.clearArtists() - - var musicCount = 0 - var albumCount = 0 + // 先在内存构建,解析/路径失败不会清空已有曲库。 + data class Pending( + val music: Music, + val album: Album?, + val upgradeAlbumExist: Album?, + ) + val pendingMusic = mutableListOf() + val pendingArtists = linkedMapOf() val artistMusic = mutableMapOf>() val seenAlbums = mutableSetOf() + var albumCount = 0 for ((groupName, albums) in cloud.album) { val albumByCloudId = albums.associateBy { it.id } val musics = cloud.music[groupName].orEmpty() for (m in musics) { - // Mobile catalog: only export=true tracks are allowed on phone. + // 移动端曲库:仅允许 export=true 的曲目。 if (!m.export) continue val album = albumByCloudId[m.album] ?: continue @@ -75,7 +79,8 @@ class DataImportService( albumId = album.uid, albumName = m.albumName ?: album.name, coverPath = m.coverPath, - musicPath = m.musicPath, + // 导入时将 musicPath 转为 wav(iOS 本地格式) + musicPath = AudioPathUtils.flac2wav(m.musicPath), time = m.time, baseUrl = m.baseUrl, category = album.category, @@ -85,40 +90,35 @@ class DataImportService( existFile = false, ), ) - library.upsertMusic(domain) - musicCount++ - // Insert / upgrade album only for exportable tracks (Flutter importMusic). + var firstAlbum: Album? = null + var upgradeAlbum: Album? = null val firstForAlbum = album.uid !in seenAlbums if (firstForAlbum) { seenAlbums += album.uid albumCount++ - library.upsertAlbum( - Album( - albumId = album.uid, - albumName = album.name, - date = album.date, - coverPath = album.coverPath.firstOrNull(), - category = album.category, - group = groupName, - existFile = domain.existFile == true, - ), + firstAlbum = Album( + albumId = album.uid, + albumName = album.name, + date = album.date, + coverPath = album.coverPath.firstOrNull(), + category = album.category, + group = groupName, + existFile = domain.existFile == true, ) } else if (domain.existFile == true) { - library.upsertAlbum( - Album( - albumId = album.uid, - albumName = album.name, - date = album.date, - coverPath = album.coverPath.firstOrNull(), - category = album.category, - group = groupName, - existFile = true, - ), + upgradeAlbum = Album( + albumId = album.uid, + albumName = album.name, + date = album.date, + coverPath = album.coverPath.firstOrNull(), + category = album.category, + group = groupName, + existFile = true, ) } + pendingMusic += Pending(domain, firstAlbum, upgradeAlbum) - // Link artists by display-name split (simplified vs Flutter bitmask). m.artist.split('/', '/') .map { it.trim() } .filter { it.isNotEmpty() } @@ -126,32 +126,46 @@ class DataImportService( val model = artistByName[name] val uid = model?.v ?: "name:$name" artistMusic.getOrPut(uid) { mutableListOf() }.add(m.uid) - if (library.getArtist(uid) == null) { + if (uid !in pendingArtists) { val photo = if (model != null) { "${Const.R2_OSS_URL}LLMP-M/artist_webp/${model.v}.webp" } else "" - library.upsertArtist( - Artist( - uid = uid, - name = name, - photo = photo, - group = groupName, - music = listOf(m.uid), - ), + pendingArtists[uid] = Artist( + uid = uid, + name = name, + photo = photo, + group = groupName, + music = listOf(m.uid), ) } } } } - artistMusic.forEach { (uid, ids) -> - val existing = library.getArtist(uid) ?: return@forEach - library.upsertArtist(existing.copy(music = ids.distinct())) + library.transaction { + library.clearMusic() + library.clearAlbums() + library.clearArtists() + pendingMusic.forEach { row -> + library.upsertMusic(row.music) + row.album?.let { library.upsertAlbum(it) } + row.upgradeAlbumExist?.let { library.upsertAlbum(it) } + } + pendingArtists.values.forEach { library.upsertArtist(it) } + artistMusic.forEach { (uid, ids) -> + val existing = library.getArtist(uid) ?: return@forEach + library.upsertArtist(existing.copy(music = ids.distinct())) + } } if (cloud.version > 0) { settings.dataVersion = cloud.version.toString() } - return ImportResult(cloud.version, musicCount, albumCount, library.getAllArtists().size) + return ImportResult( + cloud.version, + pendingMusic.size, + albumCount, + library.getAllArtists().size, + ) } } diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.kt similarity index 84% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.kt index 79510b5..6e68f8e 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.db +package com.zhushenwudi.lovelivemusicplayer.db import app.cash.sqldelight.db.SqlDriver diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/LibraryRepository.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt similarity index 81% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/LibraryRepository.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt index 675c8fc..bdbf97e 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/LibraryRepository.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/LibraryRepository.kt @@ -1,14 +1,13 @@ -package top.zhushenwudi.llmp.db +package com.zhushenwudi.lovelivemusicplayer.db -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Artist -import top.zhushenwudi.llmp.domain.Love -import top.zhushenwudi.llmp.domain.Lyric -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.util.PathResolver +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.Artist +import com.zhushenwudi.lovelivemusicplayer.domain.Love +import com.zhushenwudi.lovelivemusicplayer.domain.Lyric +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.util.PathResolver private val json = Json { ignoreUnknownKeys = true } @@ -24,17 +23,19 @@ data class LibraryLists( class LibraryRepository(private val db: LlmpDatabase) { private val q get() = db.llmpDatabaseQueries + /** 原子性执行曲库重写(避免导入中途失败导致数据库为空)。 */ + fun transaction(body: () -> R): R = db.transactionWithResult { body() } + fun getAllMusic(): List = q.selectAllMusic().executeAsList().map { it.toDomain() } /** - * Flutter [DBLogic.findAllListByGroup]: group filter + local-only when HTTP off. - * Music/albums ordered by release [Music.date] / [Album.date] (then id), matching DAO ASC/DESC. + * 按团组筛选曲库;HTTP 关闭时仅显示本地文件。 + * 歌曲/专辑按发行日期 `Music.date` / `Album.date`(再按 id)排序。 */ fun findAllListByGroup( group: String, enableHttp: Boolean, - sortAsc: Boolean = true, ): LibraryLists { val allGroup = group == "all" fun Music.matches(): Boolean = @@ -43,19 +44,16 @@ class LibraryRepository(private val db: LlmpDatabase) { val musicComparator = compareBy({ it.date.orEmpty() }, { it.musicId.orEmpty() }) val albumComparator = compareBy({ it.date.orEmpty() }, { it.albumId.orEmpty() }) - val music = getAllMusic().filter { it.matches() }.let { list -> - if (sortAsc) list.sortedWith(musicComparator) else list.sortedWith(musicComparator.reversed()) - } - // Visible songs already satisfy: export (import-time) + (HTTP or local existFile). + // 各页正倒序由 UI 独立计算;此处只返回规范顺序。 + val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator) + // 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。 val musicIds = music.mapNotNull { it.musicId }.toSet() val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet() val albums = getAllAlbums().filter { a -> (allGroup || a.group == group) && (enableHttp || a.existFile == true || a.albumId in visibleAlbumIds) - }.let { list -> - if (sortAsc) list.sortedWith(albumComparator) else list.sortedWith(albumComparator.reversed()) - } - // Artists: keep only songs that appear in the songs tab; hide if none left. + }.sortedWith(albumComparator) + // 艺人:仅保留歌曲页中出现的曲目;无曲目则隐藏。 val artistsRaw = getAllArtists() .filter { a -> allGroup || a.group == group } .sortedWith(artistComparator) @@ -63,15 +61,11 @@ class LibraryRepository(private val db: LlmpDatabase) { val artists = artistsMerged .map { a -> a.copy(music = a.music.filter { id -> id in musicIds }.distinct()) } .filter { it.music.isNotEmpty() } - val loved = getLovedMusic().filter { it.matches() }.let { list -> - // SQL is timestamp DESC; Flutter LoveDaoExt uses ASC/DESC with sortMode. - if (sortAsc) list.asReversed() else list - } - // Flutter recent is intersected with the already-filtered music list. - // selectAllHistory is DESC; Flutter HistoryDaoExt uses ASC/DESC with sortMode. - val recent = getHistoryMusic().let { hist -> - if (sortAsc) hist.asReversed() else hist - }.filter { m -> + // SQL 默认 timestamp DESC;规范顺序为最早收藏在前。 + val loved = getLovedMusic().filter { it.matches() }.asReversed() + // 最近播放与已筛选歌曲列表取交集。 + // selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。 + val recent = getHistoryMusic().filter { m -> m.musicId in musicIds && (allGroup || m.group == group) } return LibraryLists( @@ -85,7 +79,7 @@ class LibraryRepository(private val db: LlmpDatabase) { } private fun mergeArtists(artists: List): List { - // Preserve encounter order (Flutter Map insertion order after sortList). + // 保持遍历顺序(合并后按插入顺序)。 val merged = linkedMapOf() for (artist in artists) { val existing = merged[artist.uid] @@ -101,9 +95,9 @@ class LibraryRepository(private val db: LlmpDatabase) { } /** - * Flutter [Artist.compare] / [ArtistModelExtension.sortList]. - * Order: non-U first-digit → bit-count of uid[1..] (base36) → binary-as-decimal; - * uid starting with `U` sorted last. + * 艺人排序比较器。 + * 顺序:非 U 首字符 → uid[1..] 的 1 位计数(base36)→ 二进制转十进制; + * 以 U 开头的 uid 排最后。 */ private val artistComparator = Comparator { a, b -> compareArtistUid(a.uid, b.uid) @@ -129,7 +123,7 @@ class LibraryRepository(private val db: LlmpDatabase) { val bOnes = bBin.countOneBits() if (aOnes != bOnes) return aOnes - bOnes - // Flutter: int.parse(n.toRadixString(2)) — binary digits re-read as decimal + // 将二进制字符串按十进制解析比较 val aDec = aBin.toString(2).toLongOrNull() ?: 0L val bDec = bBin.toString(2).toLongOrNull() ?: 0L return aDec.compareTo(bDec) @@ -137,8 +131,8 @@ class LibraryRepository(private val db: LlmpDatabase) { } /** - * Re-check local files and update [Music.existFile] / [Album.existFile]. - * Needed after storage-root fixes or SD path changes without full re-import. + * 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`。 + * 存储根路径或 SD 路径变更后、无需全量重导入时使用。 */ fun rescanExistFiles(paths: PathResolver): Int { var changed = 0 @@ -153,7 +147,9 @@ class LibraryRepository(private val db: LlmpDatabase) { if (updated.existFile == true) { albumHasFile[albumId] = true } else { - albumHasFile.putIfAbsent(albumId, false) + if (albumId !in albumHasFile) { + albumHasFile[albumId] = false + } } } getAllAlbums().forEach { album -> @@ -330,13 +326,13 @@ class LibraryRepository(private val db: LlmpDatabase) { Lyric(uid = it.uid, jp = it.jp, zh = it.zh, roma = it.roma) } - /** Flutter lyricDao.insertLyric — first insert for uid. */ + /** 首次为 uid 插入歌词行。 */ fun insertLyricNew(lyric: Lyric) { val uid = lyric.uid ?: return q.insertLyricNew(uid, lyric.jp, lyric.zh, lyric.roma) } - /** Flutter lyricDao.updateLrc. */ + /** 更新歌词行。 */ fun updateLyric(lyric: Lyric) { val uid = lyric.uid ?: return q.updateLyricRow(lyric.jp, lyric.zh, lyric.roma, uid) @@ -350,13 +346,13 @@ class LibraryRepository(private val db: LlmpDatabase) { fun getPlayListIds(): List = q.selectPlayList().executeAsList().map { it.musicId } - /** Flutter playListMusicDao.findAllPlayListMusics — ordered rows. */ + /** 按顺序加载播放队列行。 */ fun loadPlayListRows(): List = q.selectPlayList().executeAsList() /** - * Flutter [DBLogic.findMusicByMusicIds]: keep [ids] order; - * skip missing / non-local when HTTP is off. + * 按 `ids` 顺序获取曲目; + * HTTP 关闭时跳过缺失或非本地文件。 */ fun getMusicByIdsPreservingOrder(ids: List, enableHttp: Boolean): List = ids.mapNotNull { id -> @@ -380,15 +376,16 @@ class LibraryRepository(private val db: LlmpDatabase) { fun clearPlayList() = q.deletePlayList() + /** 取 101…200 中未被占用的最小 id;已满时返回 -1。对齐 Flutter [calcSmallAtIntArr]。 */ fun nextPhoneMenuId(): Long { val used = getAllMenus().map { it.id }.filter { it > 100 }.toSet() for (id in 101L..200L) { if (id !in used) return id } - return 101L + return -1L } - private fun top.zhushenwudi.llmp.db.Music.toDomain() = Music( + private fun com.zhushenwudi.lovelivemusicplayer.db.Music.toDomain() = Music( musicId = musicId, musicName = musicName, artist = artist, @@ -408,7 +405,7 @@ class LibraryRepository(private val db: LlmpDatabase) { existFile = (existFile ?: 0L) != 0L, ) - private fun top.zhushenwudi.llmp.db.Artist.toDomain() = Artist( + private fun com.zhushenwudi.lovelivemusicplayer.db.Artist.toDomain() = Artist( id = id, uid = uid, name = name, @@ -418,7 +415,7 @@ class LibraryRepository(private val db: LlmpDatabase) { .getOrDefault(emptyList()), ) - private fun top.zhushenwudi.llmp.db.Menu.toDomain() = Menu( + private fun com.zhushenwudi.lovelivemusicplayer.db.Menu.toDomain() = Menu( id = id, isPhone = isPhone != 0L, music = runCatching { json.decodeFromString>(musicJson) } @@ -429,3 +426,9 @@ class LibraryRepository(private val db: LlmpDatabase) { } expect fun currentTimeMs(): Long + +/** 本地时区 `HH:mm`,给定时关闭弹窗显示结束时刻。 */ +expect fun formatLocalHm(epochMs: Long): String + +/** 本地时区 `HH:mm:ss`,给 Talker 日志卡片用。 */ +expect fun formatLocalHms(epochMs: Long): String diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/MusicRepository.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/MusicRepository.kt similarity index 94% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/MusicRepository.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/MusicRepository.kt index 48f7e39..8e2739b 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/db/MusicRepository.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/MusicRepository.kt @@ -1,6 +1,6 @@ -package top.zhushenwudi.llmp.db +package com.zhushenwudi.lovelivemusicplayer.db -import top.zhushenwudi.llmp.domain.Music as DomainMusic +import com.zhushenwudi.lovelivemusicplayer.domain.Music as DomainMusic class MusicRepository(private val db: LlmpDatabase) { fun getAll(): List = diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/CloudModels.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/CloudModels.kt similarity index 96% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/CloudModels.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/CloudModels.kt index 1f26b26..d58de4c 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/CloudModels.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/CloudModels.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.domain +package com.zhushenwudi.lovelivemusicplayer.domain import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/GroupKey.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/GroupKey.kt similarity index 86% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/GroupKey.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/GroupKey.kt index 205b6eb..473e9e7 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/GroupKey.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/GroupKey.kt @@ -1,9 +1,9 @@ -package top.zhushenwudi.llmp.domain +package com.zhushenwudi.lovelivemusicplayer.domain import kotlinx.serialization.Serializable /** - * Idol group keys aligned with Flutter `GroupKey`. + * 偶像团体分组键。 */ @Serializable enum class GroupKey(val displayName: String) { diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/Models.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/Models.kt similarity index 97% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/Models.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/Models.kt index 9acfe04..a09482c 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/domain/Models.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/domain/Models.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.domain +package com.zhushenwudi.lovelivemusicplayer.domain import kotlinx.serialization.Serializable diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt new file mode 100644 index 0000000..c6487df --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/DeStrings.kt @@ -0,0 +1,331 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +internal val DeStrings: Map = mapOf( + "music" to "Die Musik", + "album" to "Das Album", + "music_name" to "Der Liedtitel", + "singer" to "Der Sänger", + "iLove" to "Ich mag", + "songMenu" to "Lieder Liste", + "history" to "Letztes Spiel", + "update_fail" to "Update fehlgeschlagen", + "update" to "Versionsaktualisierung", + "update_yes" to "Ja", + "update_no" to "Nein", + "no_need_update" to "Die aktuelle Version ist die neuste", + "music_trans" to "Lieder übertragen", + "data_sync" to "Datensynchronisierung", + "theme_with_system" to "Mit System", + "colorful_mode" to "Bunter Modus", + "splash_photo" to "Splash-Foto", + "enable_background_photo" to "Hintergrundfoto aktivieren", + "choose_background_photo" to "Hintergrundbild auswählen", + "fetch_songs" to "Daten aktualisieren", + "clear_database" to "Daten löschen", + "will_shutdown" to "Die App wird gerade geschlossen, bitte starten Sie sie manuell neu", + "clean_success" to "Erfolgreich gelöscht", + "view_log" to "Protokoll ansehen", + "privacy_agreement" to "Datenschutzvereinbarung", + "system_settings" to "Systemeinstellungen", + "drive_mode" to "Fahren ist", + "night_mode" to "Nacht-Modus", + "downloading" to "Wird heruntergeladen", + "now_is_latest" to "Bereits die neuste Version, überschreiben?", + "data_error" to "Datenausnahme", + "fetch_songs_fail" to "Datenquelle konnte nicht abgerufen werden", + "importing" to "Importieren...", + "transferring" to "Unterwegs...", + "loading" to "Wird geladen...", + "song_lib" to "Songbibliothek", + "mine" to "Mein", + "requesting" to "Netzwerk anfordern...", + "please_check_network" to "Bitte prüfen Sie das Netzwerk", + "keep_same_lan" to "Bitte bewahren Sie Ihr Mobiltelefon und Ihren Computer im dasselbe lokales Netzwerk auf", + "keep_screen_and_scan_qr" to "Lassen Sie den Bildschirm immer eingeschaltet und scannen Sie den QR-Code auf der PC-Seite", + "phone2pc" to "Handy ≫ Computer", + "pc2phone" to "Computer ≫ Handy", + "device_pair" to "Gerätepaarung", + "terminate_trans" to "Nach dem Beenden wird die Verbindung und Übertragung unterbrochen, Möchten Sie weiter machen?", + "confirm" to "Bestätigen", + "connect_fail" to "Verbindung fehlgeschlagen", + "album_info" to "Albumdetails", + "add_success" to "Erfolgreich hinzugefügt", + "add_fail" to "Hinzufügen fehlgeschlagen", + "add_to_playlist" to "Zur Wiedergabeliste hinzufügen", + "add_to_menu" to "Zum Liedmenü hinzufügen", + "create_menu" to "Songmenü erstellen", + "create_success" to "Erfolg schaffen", + "create_over_max" to "Über Höchstmenge", + "add_to_iLove" to "Erfolgreich zu \"Ich mag\" hinzugefügt", + "remove_from_iLove" to "Erfolgreich aus \"Ich mag\" entfernt", + "rename_menu" to "Menü umbenennen", + "delete_menu" to "Menü entfernen", + "music_info" to "Musikinfo", + "view_album" to "Album ansehen", + "remove_music" to "Lied löschen", + "shuffle_play" to "Shuffle spielen", + "order_play" to "Reihenfolge spielen", + "single_play" to "Einzelspiel", + "total_number_unit" to "Lied(er)", + "total_album_number_unit" to "Ablum(Alben)", + "duration" to "Dauer", + "position" to "Position", + "release_date" to "Veröffentlichungsdatum", + "classification" to "Einstufung", + "unknown" to "Unbekannt", + "select_items" to "Ausgewählt", + "cancel" to "Absagen", + "click_again_to_back" to "Klicken Sie erneut, um zum Desktop zurückzukehren", + "data_parsing" to "Datenanalyse...", + "github_url" to "Link zu diesem Software-Open-Source-Projekt", + "no_songs" to "Keine Lieder", + "no_lyrics" to "Keine Texte", + "title" to "Der Titel", + "message" to "Die Message", + "input_menu_name" to "Bitte geben Sie einen Menu-Namen ein", + "privacy_policy" to "Datenschutz-Bestimmungen", + "permission_show_full" to "Vollversion anzeigen", + "copy_success" to "Erfolg kopieren", + "need_enable_bg" to "Bitte aktivieren Sie zuerst Hintergrundbilder", + "permission_main_message" to "Willkommen bei LoveLiveMusicPlayer!\n\nWir werden die \"Nutzungsvereinbarung und Datenschutzrichtlinie“ verwenden, um Ihnen zu helfen, die von dieser Software für Sie bereitgestellten Dienste und die Art und Weise der Erfassung und Verarbeitung Ihrer personenbezogenen Daten zu verstehen.\nDurch Klicken auf die Schaltfläche \"Zustimmen und fortfahren“ haben Sie zugestimmt.", + "permission_external_link" to "\"Nutzungsvereinbarung und Datenschutzrichtlinie\"", + "permission_agree" to "Zustimmen und weitermachen", + "permission_disagree" to "Nicht zustimmen", + "no_data" to "Keine Daten", + "choose_your_clean_data" to "Bitte wählen Sie die zu löschenden Daten aus", + "remove_songs_data" to "Songdaten löschen", + "remove_user_data" to "Benutzerdaten löschen", + "explain_songs_data" to "Songdaten: Song-Cache-Daten", + "explain_user_data" to "Benutzerdaten: Playerkonfiguration, Favoriten, Songliste", + "resetting" to "zurücksetzen...", + "warning_choose" to "Achtung: Wählen Sie sorgfältig aus", + "confirm_full_trans" to "Sind Sie sicher, dass Sie die Daten vollständig überschreiben?", + "cover_full_data" to "Vollständige Datenabdeckung", + "file_path_error" to "Dateipfadfehler", + "crop_background_image" to "Hintergrundbild zuschneiden", + "search_lyric" to "Songtexte noch mal downloaden", + "search_lyric_success" to "Songtexte erfolgreich abrufen", + "search_lyric_failed" to "Liedtext konnte nicht abgerufen werden", + "share_app" to "Er/Sie hat LLMP mit Ihnen geteilt", + "outer_share_music" to "Er/Sie hat ein Lied mit dir geteilt", + "outer_share_menu" to "Er/Sie hat eine Playlist mit dir geteilt", + "share_music" to "Lied teilen", + "share_menu" to "Wiedergabeliste teilen", + "no_qq" to "TIM/QQ ist nicht installiert", + "version_incompatible" to "Die APP-Version stimmt nicht mit der PC-Version überein, bitte gehen Sie zum Aktualisieren zum Blog", + "no_found_music1" to "Lied wurde nicht gefunden", + "need_play_share_music" to "Ob geteilte Songs abgespielt werden sollen", + "no_found_music2" to "Keine verfügbaren Songs gefunden", + "need_import_share_menu" to "Ob freigegebene Wiedergabelisten importiert werden sollen", + "delete_from_menu" to "Aus Wiedergabeliste löschen", + "confirm_delete_from_menu" to "Möchten Sie den ausgewählten Titel wirklich aus der Wiedergabeliste löschen?", + "cancel_i_like" to "Abbrechen gefällt mir", + "confirm_to_delete_music" to "Möchten Sie den ausgewählten Song wirklich aus „Meine Favoriten“ löschen?", + "share" to "App teilen", + "skip" to "überspringen", + "net_error" to "Netzwerkfehler", + "hint" to "Tipp.", + "use_http_music" to "Spielen Sie Musik von Http", + "input_http_url" to "Geben Sie die http-Adresse ein", + "support_http_characters" to "Unterstützen Sie Charaktere: a-z A-Z 0-9 .:/_-", + "now_can_not_play" to "Kann nicht spielen", + "moe_girl_wiki" to "Moe-girl Wiki", + "need_delete_menu" to "Möchten Sie die Songliste löschen?", + "select_time" to "Bitte wählen Sie die Zeit aus (Einheit: Minuten)", + "upgrading" to "Upgrade durchführen...", + "upgrade_on_next_time" to "Das Update wird beim nächsten Start wirksam", + "end_time" to "Endzeit: ", + "timed_to_stop" to "Zeit abgelaufen", + "timed_to_stop_remain" to "Auszeit, verbleibend", + "minutes" to "Minuten", + "please_give_install_permission_manual" to "Bitte erteilen Sie manuell die Berechtigung zur Installation von APK", + "please_give_install_permission" to "Für das Upgrade ist eine Berechtigung zur Installation von APK erforderlich. Ist diese autorisiert?", + "privacy_detail1" to "Lieber LLer:\n\nBitte stellen Sie sicher, dass Sie alle Rechte und Einschränkungen, die in der „Softwarelizenzvereinbarung“ (im Folgenden als diese Vereinbarung bezeichnet) festgelegt sind, sorgfältig lesen und verstehen, es sei denn, Sie akzeptieren die Bedingungen dieser Vereinbarung, andernfalls haben Sie kein Recht, die Software und damit verbundene zu verwenden Dienstleistungen. Sobald Sie diese Software verwenden, wird davon ausgegangen, dass Sie diese Vereinbarung akzeptiert haben.\n\n1.Erklärung der Rechte\nDiese Software ist ein Open-Source-Projekt, alle Rechte sind dem Autor \"zhushenwudi\" vorbehalten.\nEin Teil des Informationsinhalts stammt aus Netzwerkressourcen, einschließlich, aber nicht beschränkt auf: Textausdrücke und ihre Kombinationen, Symbole, Bilder, Farben und zugehörige Daten. Bei Konflikten und Streitigkeiten, die durch Urheberrechtsprobleme verursacht werden, wenden Sie sich bitte an den Autor unter \"55681140@163.com\", um ihn zu löschen oder zu ersetzen.\nWenn Sie sich entscheiden, die Software zu verteilen und die zugehörige Dokumentation zu vervielfältigen, wenden Sie sich bitte an den Autor, um eine Genehmigung einzuholen. Wenn Sie bei der Verwendung auf Probleme stoßen, können Sie sich an den Autor wenden, um Hilfe zu erhalten. Der Autor behält sich das Recht vor, Benutzern modifizierte und aktualisierte Versionen dieser Software zur Verfügung zu stellen.\n\n2.Umfang der Lizenz\nHerunterladen, Installieren und Verwenden: Benutzer können diese Software uneingeschränkt für nicht-kommerzielle Zwecke herunterladen, installieren und verwenden.\nKopieren, Verteilen und Verbreiten: Benutzer dürfen dieses Softwareprodukt unbegrenzt zu nichtkommerziellen Zwecken kopieren, verteilen und verteilen, müssen jedoch sicherstellen, dass alle kopierten, verteilten und verteilten Inhalte vollständig und authentisch sind, einschließlich jeglicher Software, die sich auf diese Software bezieht. Elektronische Dateien, Urheberrechte und Warenzeichen, einschließlich Vereinbarungen.\n\n3.Privatsphäre und Sicherheit\nDiese Software enthält keine bösartigen Codes, die dazu bestimmt sind, Benutzerterminaldaten zu zerstören und Benutzerdaten zu erhalten, mit Ausnahme von Absturzinformationen und Hochladedaten, die in der Software verwendet werden.\nEs enthält keine Codes zum Verfolgen und Überwachen des Verhaltens von Benutzerterminals, überwacht kein Online- und Offline-Verhalten von Benutzern, sammelt keine persönlichen Informationen wie Benutzer, die andere Software und Dokumente verwenden, und gibt keine Benutzerdaten weiter.\nDie gesammelten Daten werden verwendet, um die Software besser zu verbessern, indem Fehlermeldungen bei Abstürzen analysiert werden.\nVerwenden Sie die folgenden statistischen Entwicklungstools:\n Ⅰ.SDK-Namen verwenden: U-App SDK\n Sammlung personenbezogener informationen: ausrüstung informationen (IMEI/Mac/android-plattform ausweis/IDFA/OPENUDID/GUID/sim-karte IMSI/geografischer)\n Art der Dienstleistung: Datenerhebung und statistische Analyse", + "privacy_umeng" to "《Link zur Datenschutzrichtlinie》", + "privacy_detail2" to "\n\nⅡ.SDK-Namen verwenden: MobTech ShareSDK\n Art der Dienstleistung: Sharing realisieren und pushen", + "privacy_share" to "《Link zur Datenschutzerklärung》", + "privacy_detail3" to "\n\nⅢ.SDK-Namen verwenden: 360 gehärtetes SDK\n Geräteberechtigungen: Keine\n Verwirklichte funktionale Zwecke: Wird verwendet, um App-Fehler zu erkennen und zu diagnostizieren, App-Sicherheitsverstärkungsschutz, Piraterieerkennungsdienste, Erkennungsdienste für Bedrohungsumgebungen, um Benutzern zu helfen, anormale Situationen schnell zu lösen\n Berechtigungsautorisierungsmethode: Die Autorisierungsmethode wird vom App-Entwickler bestimmt und aktiviert, wenn der Endbenutzer zustimmt, die Berechtigung für die Entwickler-App zu autorisieren", + "privacy_360" to "《Link zur Datenschutzerklärung》", + "privacy_detail4" to "\n\n\n4.Hinweis für Benutzer\nDiese Software wird spontan von LoveLive!-Fans verwendet und enthält keine urheberrechtlich geschützten Songs. Copyright-Songs werden vom Benutzer extrahiert und von der CD importiert. Die Importverzeichnisstruktur ist in der Github-Projektdokumentation dargestellt.\nBenutzer sollten die Software unter der Prämisse verwenden, Gesetze und Vereinbarungen einzuhalten, und Benutzer haben kein Recht, Maßnahmen zu ergreifen, einschließlich, aber nicht beschränkt auf die folgenden:\nⅠ. Löschen oder ändern Sie nicht alle elektronischen Informationen zur Rechteverwaltung in der Software;\nⅡ. Verwenden Sie diese Software nicht, um andere irrezuführen oder zu täuschen;\nⅢ. Löschen, Ändern, Erweitern oder Reduzieren der Funktionen des Computerinformationssystems unter Verstoß gegen behördliche Vorschriften, wodurch eine Fehlfunktion des Computerinformationssystems verursacht wird;;\nⅣ. Unbefugter Zugriff auf oder Nutzung von Computerinformationsnetzressourcen;\nⅤ. Löschen, Ändern oder Hinzufügen von Computerinformationen zu Netzwerkfunktionen ohne Erlaubnis。\n ", + "now_playing" to "Läuft gerade: ", + "group_list" to "Gruppenliste", + "back" to "Zurück", + "phone" to "Telefon", + "pc" to "Computer", + "add_favorite_menu" to "Lieblings-Playlist", + "music_blind_box" to "Musik-Blindbox", + "throbbing" to "Ich weiß, Sie werden aufgeregt sein, aber Sie sollten auch auf die Verkehrssicherheit achten", + "exit" to "aufhören", + "finish" to "Beenden", + "search_in_moe_girl" to "Suche auf Moegirlpedia", + "share_error" to "Teilen fehlgeschlagen, Fehlercode: ", + "share_success" to "Teilen erfolge", + "share_cancel" to "Teilen Abbrechen", + "daily_news" to "Daily news", + "recent_trends" to "Die jüngsten trends", + "live_notice" to "Live-Mitteilung", + "today_in_previous_years" to "Heute in den vergangenen Jahren", + "input_song_main_char" to "Bitte geben Sie das Schlüsselwort für den Songnamen ein", + "playing" to "Spielen", + "paused" to "Pausierte", + "storage_settings" to "Speichereinstellungen", + "change_storage_warning" to "Wählen Sie das zu verwendende Speichergerät aus.\nBeim Wechseln des Geräts werden alle Daten gelöscht.", + "internal_storage" to "Interner Speicher", + "storage_not_found" to "Das Speichergerät existiert nicht", + "need_play_music_first" to "Bitte spielen Sie zuerst das Lied", + "desktop_lyric" to "Desktop-Songtext", + "choose" to "Wählen", + "choose_album_category" to "Albumkategorie auswählen", + "people_unit" to "Person(en)", + "menu_unit" to "Stück", + "confirm_unlove" to "Aus Favoriten entfernen?", + "got_it" to "OK", + "close" to "Schließen", + "check_update" to "Updates prüfen…", + "data_update_done" to "Datenupdate abgeschlossen", + "no_download_url" to "Keine Download-URL", + "scan_pair" to "Scannen zum Koppeln", + "tap_to_scan" to "Tippen zum Scannen", + "or_input_pc_ip" to "Oder PC-IP eingeben", + "confirm_pair" to "Kopplung bestätigen", + "disconnect" to "Trennen", + "data_parsing_short" to "Daten werden analysiert", + "history_short" to "Verlauf", + "today" to "Heute", + "cannot_load" to "Laden fehlgeschlagen: ", + "daily_timezone_hint" to "Hinweis: Sofern nicht anders angegeben, gilt Peking-Zeit (UTC+8).", + "select_storage" to "Musikspeicherort wählen", + "no_usb" to "Kein Wechseldatenträger (USB) gefunden.", + "http_url_example" to "z. B. https://host/path", + "timer_cancelled" to "Timer abgebrochen", + "will_stop_in" to "Wiedergabe stoppt in %s Minute(n)", + "exiting" to "Beenden…", + "user_data_cleared" to "Benutzerdaten gelöscht", + "new_version" to "Neue Version v%s", + "upgrade_prompt" to "Neue Version verfügbar. Jetzt aktualisieren?", + "add_to_playlist_short" to "Zur Wiedergabeliste", + "new_menu" to "Neue Playlist", + "menu_name" to "Playlist-Name", + "unknown_song" to "Unbekanntes Lied", + "input_song_keyword" to "Song-Schlüsselwort", + "not_playing" to "Keine Wiedergabe", + "collapse" to "Einklappen", + "more" to "Mehr", + "jump_play" to "Zum Abspielen", + "lyric_orig" to "Orig", + "lyric_trans" to "Übers", + "lyric_roma" to "Roma", + "play_all" to "Alle abspielen", + "no_album_info" to "Keine Albuminfo", + "song_label" to "Lied: ", + "album_label" to "Album: ", + "opening" to "Öffnen: ", + "tachie" to "Tachie", + "opening_tachie" to "Tachie wird geöffnet…", + "dark_mode" to "Dunkler Modus", + "web_page" to "Webseite", + "scan_qr_prompt" to "QR-Code scannen", + "opening_app" to "App wird geöffnet...", + "no_permission" to "Berechtigung nicht erteilt", + "ready_to_play" to "Bereit zum Abspielen", + "forward" to "Vor", + "refresh" to "Aktualisieren", + "backward" to "Zurück", + "play" to "Play", + "pause" to "Pause", + "playlist" to "Wiedergabeliste", + "cannot_open_page" to "Seite kann nicht geöffnet werden", + "invalid_link" to "Ungültiger Link", + "share_chooser" to "Teilen", + "no_activity" to "no activity", + "logs_error" to "Fehler", + "logs_warning" to "Warnung", + "logs_info" to "Info", + "logs_debug" to "Debug", + "logs_verbose" to "Ausführlich", + "logs_monitor" to "Monitor", + "logs_settings" to "Einstellungen", + "logs_actions" to "Aktionen", + "logs_copied_one" to "Protokoll kopiert", + "logs_copied_all" to "Alle Protokolle kopiert", + "logs_copied_filter" to "Gefilterte Protokolle kopiert", + "logs_search" to "Suchen…", + "logs_copy" to "Kopieren", + "logs_actions_title" to "Protokollaktionen", + "logs_reverse" to "Protokolle umkehren", + "logs_copy_all" to "Alle kopieren", + "logs_copy_filtered" to "Gefilterte kopieren", + "logs_expand" to "Protokolle ausklappen", + "logs_collapse" to "Protokolle einklappen", + "logs_clear" to "Verlauf löschen", + "logs_share_file" to "Protokolldatei teilen", + "logs_settings_title" to "Protokolleinstellungen", + "logs_on" to "An", + "logs_off" to "Aus", + "logs_monitor_title" to "Protokollmonitor", + "logs_unhandled_errors" to "App hat %s unbehandelte Fehler", + "logs_warning_count" to "App hat %s Warnungen", + "logs_info_count" to "Info-Protokolle: %s", + "logs_debug_count" to "Ausführliche/Debug-Protokolle: %s", + "logs_verbose_debug" to "Ausführlich & Debug", + "selected_count" to "%s ausgewählt", + "count_songs" to "%s Lied(er)", + "count_albums" to "%s Album(Alben)", + "count_artists" to "%s Person(en)", + "count_menus" to "%s Stück", + "menu_meta_phone" to "%s Lied(er) · Telefon", + "menu_meta_pc" to "%s Lied(er) · PC", + "loop_songs" to "%s - %s Lied(er)", + "skip_n" to "Überspringen %ss", + "end_time_value" to "Endzeit: %s", + "timed_remain_n" to "Auszeit, verbleibend %s Minuten", + "keep_screen_scan_qr" to "Bildschirm anlassen und QR-Code scannen", + "now_playing_empty" to "Läuft gerade: Keine Lieder", + "now_playing_song" to "Läuft gerade: %s", + "fetch_songs_fail_or_net" to "Datenfehler oder Netzwerkfehler", + "explain_songs_data_kmp" to "Songdaten: Bibliotheksmetadaten, Version", + "explain_user_data_kmp" to "Benutzerdaten: Likes/Verlauf/Playlists/Cache, dann beenden", + "remove_songs_data_kmp" to "Songdaten löschen", + "remove_user_data_kmp" to "Benutzerdaten löschen", + "confirm_delete_from_menu_short" to "Aus Playlist löschen?", + "play_text" to "Spielen,Pausierte", + "connected" to "Getrennt", + "connecting" to "Verbinden…", + "handshaking" to "Handshake…", + "disconnected" to "Getrennt", + "version_mismatch" to "Versionskonflikt", + "verified" to "Verifiziert", + "transfer_done" to "Übertragung abgeschlossen", + "peer_abort" to "Gegenstelle abgebrochen", + "cannot_connect_host" to "Verbindung zu %s fehlgeschlagen", + "failed" to "Fehlgeschlagen", + "handshake_incomplete" to "Handshake unvollständig", + "sent_phone2pc" to "phone2pc gesendet", + "requested_pc2phone" to "pc2phone angefordert", + "applied_pc_love" to "PC-Favoriten übernommen", + "applied_pc_menus" to "PC-Playlists/Likes übernommen", + "session_end" to "Sitzung beendet", + "pending_n" to "%s Lied(er) ausstehend", + "ready_n" to "Bereit %s", + "downloading_song" to "Lädt %s", + "http_port" to "HTTP-Port %s", + "widget_white" to "LLMP Musik-Widget (Weiß)", + "widget_black" to "LLMP Musik-Widget (Schwarz)", + "widget_desc" to "LoveLive! Musikplayer", + "duration_label" to "Dauer: ", + "position_label" to "Position: ", + "release_date_label" to "Erscheinungsdatum: ", + "classification_label" to "Kategorie: ", + "data_update_done_ver" to "Datenupdate fertig v%s · %s Lied(er)", + "downloading_short" to "Lädt…", + "share_error_n" to "Teilen fehlgeschlagen, Fehler: %s", + "album_abbr" to "A", + "music_abbr" to "L", + "menu_abbr" to "P", + "lyric_close" to "Desktop-Lyrics schließen", + "lyric_pip" to "Bild-in-Bild", + "lyric_translate" to "Übersetzte Lyrics", + "playback_channel" to "Wird abgespielt", + "camera_permission" to "Kamerazugriff ist zum Scannen von QR-Codes erforderlich", + "photo_permission" to "Fotobibliothek-Zugriff ist zum Auswählen von Bildern erforderlich", +) diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt new file mode 100644 index 0000000..40aee29 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/EnStrings.kt @@ -0,0 +1,331 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +internal val EnStrings: Map = mapOf( + "music" to "Music", + "album" to "Album", + "music_name" to "Music name", + "singer" to "Singer", + "iLove" to "Love", + "songMenu" to "Menu", + "history" to "History", + "update_fail" to "Update failed", + "update" to "Update", + "update_yes" to "Yes", + "update_no" to "No", + "no_need_update" to "The current version is the latest", + "music_trans" to "Transfer songs", + "data_sync" to "Data sync", + "theme_with_system" to "Theme with os", + "colorful_mode" to "Colorful mode", + "splash_photo" to "Splash photo", + "enable_background_photo" to "Enable background photo", + "choose_background_photo" to "Choose background photo", + "fetch_songs" to "Fetch songs", + "clear_database" to "Clear databases", + "will_shutdown" to "App will shutdown, please turn it on by yourself", + "clean_success" to "Clean up successfully", + "view_log" to "View logs", + "privacy_agreement" to "Privacy agreement", + "system_settings" to "System settings", + "drive_mode" to "Drive mode", + "night_mode" to "Night mode", + "downloading" to "Downloading data", + "now_is_latest" to "It is the latest version. Do you want to overwrite it?", + "data_error" to "Data error", + "fetch_songs_fail" to "Fetch songs error", + "importing" to "Importing...", + "transferring" to "Transferring...", + "loading" to "Loading...", + "song_lib" to "SongLib", + "mine" to "Mine", + "requesting" to "Requesting...", + "please_check_network" to "Please check network", + "keep_same_lan" to "Keep your phone and computer on the same LAN", + "keep_screen_and_scan_qr" to "Keep the screen steady on and scan the QR code on the PC", + "phone2pc" to "Phone >> PC", + "pc2phone" to "PC >> Phone", + "device_pair" to "Device pairing", + "terminate_trans" to "The connection and transmission will be interrupted. Do you want to continue?", + "confirm" to "Confirm", + "connect_fail" to "Connection failed", + "album_info" to "Album info", + "add_success" to "Add successfully", + "add_fail" to "Add failed", + "add_to_playlist" to "Add to a playlist", + "add_to_menu" to "Add to a song menu", + "create_menu" to "Create a song menu", + "create_success" to "Create successfully", + "create_over_max" to "Exceeding the maximum quantity", + "add_to_iLove" to "Add to i love successfully", + "remove_from_iLove" to "Remove from i love successfully", + "rename_menu" to "Rename menu", + "delete_menu" to "Delete menu", + "music_info" to "Music info", + "view_album" to "View album", + "remove_music" to "remove this song", + "shuffle_play" to "Shuffle play", + "order_play" to "Order play", + "single_play" to "Single play", + "total_number_unit" to "song(s)", + "total_album_number_unit" to "album(s)", + "duration" to "Duration", + "position" to "Position", + "release_date" to "Release date", + "classification" to "Classification", + "unknown" to "unknown", + "select_items" to "Selected", + "cancel" to "Cancel", + "click_again_to_back" to "Click again to return to the desktop", + "data_parsing" to "Data parsinging...", + "github_url" to "Open source project link of this software", + "no_songs" to "No songs", + "no_lyrics" to "No lyrics", + "title" to "Title", + "message" to "Message", + "input_menu_name" to "Please input menu name", + "privacy_policy" to "Privacy policy", + "permission_show_full" to "View full version", + "copy_success" to "Copy success", + "need_enable_bg" to "Please enable background images first", + "search_lyric" to "Search lyric again", + "search_lyric_success" to "search lyric success", + "search_lyric_failed" to "search lyric failed", + "share_app" to "(S)He shared a LLMP to you", + "outer_share_music" to "(S)He shared a song to you", + "outer_share_menu" to "(S)He shared a playlist to you", + "share_music" to "Share a song", + "share_menu" to "Share a playlist", + "no_qq" to "TIM/QQ is not installed", + "version_incompatible" to "The APP version does not match the PC version, please go to the blog to update", + "no_found_music1" to "Song not found", + "need_play_share_music" to "Whether to play shared songs", + "no_found_music2" to "No available songs found", + "need_import_share_menu" to "Whether to import shared playlist", + "permission_main_message" to "Welcome to LoveLiveMusicPlayer!\n\nWe will use the User Agreement and Privacy Policy to help you understand the services provided by the software for you and the way you collect and process your personal data.\nClick the \"Agree and Continue\" button to indicate that you have agreed.", + "permission_external_link" to "User Agreement and Privacy Policy", + "permission_agree" to "Agree and continue", + "permission_disagree" to "Disagree and quit", + "no_data" to "No data", + "choose_your_clean_data" to "Please select the data to be cleaned", + "remove_songs_data" to "Remove songs data", + "remove_user_data" to "Remove user data", + "explain_songs_data" to "Song data: cached songs data", + "explain_user_data" to "User data: player configuration, love, playlist", + "resetting" to "Resetting", + "warning_choose" to "Warning: Please choose carefully!", + "confirm_full_trans" to "Are you sure to completely cover the data?", + "cover_full_data" to "Completely cover the data", + "file_path_error" to "File path error", + "crop_background_image" to "Crop background image", + "delete_from_menu" to "Delete from playlist", + "confirm_delete_from_menu" to "Are you sure you want to delete the selected song from playlist?", + "cancel_i_like" to "Cancel I like", + "confirm_to_delete_music" to "Are you sure you want to delete the selected song from My Likes?", + "share" to "Share application", + "skip" to "Skip", + "net_error" to "Network error", + "hint" to "Hint", + "use_http_music" to "Play music by Http", + "input_http_url" to "Enter Http address", + "support_http_characters" to "Support characters: a-z A-Z 0-9 .:/_-", + "now_can_not_play" to "Currently unplayable", + "moe_girl_wiki" to "Moe-girl Wiki", + "need_delete_menu" to "Delete the menu?", + "select_time" to "Please select time(unit:minutes)", + "upgrading" to "Upgrading...", + "upgrade_on_next_time" to "The update will take effect on the next startup", + "end_time" to "End time: ", + "timed_to_stop" to "Timed off", + "timed_to_stop_remain" to "Timed off, remaining", + "minutes" to "minute(s)", + "please_give_install_permission_manual" to "Please manually grant permission to install APK", + "please_give_install_permission" to "Upgrading requires permission to install APK, is it authorized?", + "privacy_detail1" to "Dear LLer,\n\nPlease be sure to carefully read and understand all rights and restrictions set forth in the Software License Agreement (hereinafter referred to as the Agreement). Unless you accept the terms of the Agreement, you are not entitled to use the Software and related Services. Once you use the software, you will be deemed to accept the agreement.\n\n1. Statement of Rights\nThis software is an open source project, owned by author zhushenwudi.\nSome of the information content is obtained from the network resources, including but not limited to: text expression and combination, ICONS, images, colors, and related data. If there is any contradiction or dispute due to copyright issues, please contact the author's mailbox 55681140@163.com to delete or replace it.\nIf the software publicity, related documents reprint please contact the author himself for authorization. Please contact the author for support if you have any problem. The author reserves the right to provide users with modified or upgraded versions of the software.\n\n2. Scope of license\nDownload, Install and Use: Users may download, install and use the Software in non-commercial and unlimited quantities.\nReproduction, Distribution and Dissemination: User may non-commercially reproduce, distribute and disseminate the Software Products in unlimited quantities, provided that each copy, distribution and dissemination is complete and true, including all software, electronic documents, Copyrights and trademarks relating to the Software, as well as agreements.\n\n3. Privacy security\nThe software does not contain any malicious code aimed at destroying user terminal data and obtaining user privacy information except reporting crash information and uploading the use data in the software.\nIt does not contain any function code for tracking and monitoring user terminal behaviors, monitoring users' online and offline behaviors, collecting users' personal information such as other software and documents, and disclosing users' privacy.\nThe collected data can be used to better improve the software by analyzing the error information in the crash.\nHere are the statistical development tools used:\n Ⅰ. Use the SDK name: U-App SDK\n Personal information collection types: equipment information (IMEI/Mac/android ID/IDFA OPENUDID/GUID/SIM card IMSI/location)\n Service type: Data collection and statistical analysis", + "privacy_umeng" to "《Umeng privacy policy links》", + "privacy_detail2" to "\n\nⅡ. Use SDK name: ShareSDK of MobTech\n Service type: Share and push", + "privacy_share" to "《MobTech privacy policy links》", + "privacy_detail3" to "\n\nⅢ. Use SDK name: 360 Hardening SDK (Applicable to APK/HAP/APK plug-in /SDK hardening)\n Device permission: None\n Function purpose: Used for App fault detection and diagnosis,App security reinforcement protection, piracy detection services, threat environment detection services, so as to help users quickly solve abnormal situations\n Authorization mode: The authorization mode is determined by the App developer and is enabled when the end user agrees to authorize the permission to the developer App", + "privacy_360" to "《360 privacy policy links》", + "privacy_detail4" to "\n\n\n4. User instructions\nThis software is LoveLive! Users in the circle spontaneously use, not including any copyrighted songs. Copyright songs are extracted and imported from CDS by users themselves. See the Github project documentation for details about the import directory structure.\nUsers shall use the Software in compliance with laws and agreements. Users are not entitled to perform any of the following actions, including but not limited to:\nⅠ. All rights management electronic information on the software shall not be deleted or changed;\nⅡ. shall not use this software to mislead and deceive others;\nⅢ. In violation of national regulations, delete, modify, increase or interfere with the functions of the computer information system, resulting in the failure of the computer information system to operate normally;\nⅣ. accessing computer information networks or using computer information network resources without permission;\nⅤ. Deleting, modifying or adding functions of computer information networks without permission.\n ", + "now_playing" to "Now Playing: ", + "group_list" to "Group list", + "back" to "Back", + "phone" to "Phone", + "pc" to "Computer", + "add_favorite_menu" to "Favorite playlist", + "music_blind_box" to "Music blind box", + "throbbing" to "I know you will be throbbing, but pay attention to traffic safety", + "exit" to "Exit", + "finish" to "Finish", + "search_in_moe_girl" to "Search in MoeGirl", + "share_error" to "Share failed, error code: ", + "share_success" to "Share success", + "share_cancel" to "Cancel share", + "daily_news" to "Daily news", + "recent_trends" to "Recent trends", + "live_notice" to "Live notice", + "today_in_previous_years" to "Today in previous years", + "input_song_main_char" to "The song name keyword", + "playing" to "Playing", + "paused" to "Paused", + "storage_settings" to "Storage Settings", + "change_storage_warning" to "Select the storage device you want to use.\nSwitching devices will clear all data", + "internal_storage" to "Internal storage", + "storage_not_found" to "Storage not found", + "need_play_music_first" to "Need play music first", + "desktop_lyric" to "Desktop lyric", + "choose" to "Choose", + "choose_album_category" to "Choose album category", + "people_unit" to "artist(s)", + "menu_unit" to "menu(s)", + "confirm_unlove" to "Remove from My Likes?", + "got_it" to "OK", + "close" to "Close", + "check_update" to "Checking updates…", + "data_update_done" to "Data update complete", + "no_download_url" to "No download URL", + "scan_pair" to "Scan to pair", + "tap_to_scan" to "Tap to scan QR code", + "or_input_pc_ip" to "Or enter PC IP", + "confirm_pair" to "Confirm pairing", + "disconnect" to "Disconnect", + "data_parsing_short" to "Parsing data", + "history_short" to "History", + "today" to "Today", + "cannot_load" to "Failed to load: ", + "daily_timezone_hint" to "Note: Unless otherwise specified, times on this page are Beijing time (UTC+8).", + "select_storage" to "Select music storage location", + "no_usb" to "No removable storage (USB) found.", + "http_url_example" to "e.g. https://host/path", + "timer_cancelled" to "Sleep timer cancelled", + "will_stop_in" to "Playback will stop in %s minute(s)", + "exiting" to "Exiting…", + "user_data_cleared" to "User data cleared", + "new_version" to "New version v%s", + "upgrade_prompt" to "A new version is available. Upgrade now?", + "add_to_playlist_short" to "Add to playlist", + "new_menu" to "New playlist", + "menu_name" to "Playlist name", + "unknown_song" to "Unknown song", + "input_song_keyword" to "Enter song keyword", + "not_playing" to "Not playing", + "collapse" to "Collapse", + "more" to "More", + "jump_play" to "Jump to play", + "lyric_orig" to "Orig", + "lyric_trans" to "Trans", + "lyric_roma" to "Roma", + "play_all" to "Play all", + "no_album_info" to "No album info", + "song_label" to "Song: ", + "album_label" to "Album: ", + "opening" to "Opening: ", + "tachie" to "Tachie", + "opening_tachie" to "Opening tachie…", + "dark_mode" to "Dark mode", + "web_page" to "Web page", + "scan_qr_prompt" to "Scan QR code", + "opening_app" to "Opening app...", + "no_permission" to "Permission not granted", + "ready_to_play" to "Ready to play", + "forward" to "Forward", + "refresh" to "Refresh", + "backward" to "Back", + "play" to "Play", + "pause" to "Pause", + "playlist" to "Playlist", + "cannot_open_page" to "Cannot open page", + "invalid_link" to "Invalid link", + "share_chooser" to "Share", + "no_activity" to "no activity", + "logs_error" to "Error", + "logs_warning" to "Warning", + "logs_info" to "Info", + "logs_debug" to "Debug", + "logs_verbose" to "Verbose", + "logs_monitor" to "Monitor", + "logs_settings" to "Settings", + "logs_actions" to "Actions", + "logs_copied_one" to "Log copied", + "logs_copied_all" to "All logs copied", + "logs_copied_filter" to "Filtered logs copied", + "logs_search" to "Search…", + "logs_copy" to "Copy", + "logs_actions_title" to "Log actions", + "logs_reverse" to "Reverse logs", + "logs_copy_all" to "Copy all logs", + "logs_copy_filtered" to "Copy filtered logs", + "logs_expand" to "Expand logs", + "logs_collapse" to "Collapse logs", + "logs_clear" to "Clear history", + "logs_share_file" to "Share log file", + "logs_settings_title" to "Log settings", + "logs_on" to "On", + "logs_off" to "Off", + "logs_monitor_title" to "Log monitor", + "logs_unhandled_errors" to "App has %s unhandled error(s)", + "logs_warning_count" to "App has %s warning(s)", + "logs_info_count" to "Info logs: %s", + "logs_debug_count" to "Verbose/debug logs: %s", + "logs_verbose_debug" to "Verbose & debug", + "selected_count" to "Selected %s", + "count_songs" to "%s song(s)", + "count_albums" to "%s album(s)", + "count_artists" to "%s artist(s)", + "count_menus" to "%s menu(s)", + "menu_meta_phone" to "%s song(s) · Phone", + "menu_meta_pc" to "%s song(s) · PC", + "loop_songs" to "%s - %s song(s)", + "skip_n" to "Skip %ss", + "end_time_value" to "End time: %s", + "timed_remain_n" to "Timed off, remaining %s minute(s)", + "keep_screen_scan_qr" to "Keep the screen on and scan the QR code", + "now_playing_empty" to "Now Playing: No songs", + "now_playing_song" to "Now Playing: %s", + "fetch_songs_fail_or_net" to "Data error or network failure", + "explain_songs_data_kmp" to "Song data: library metadata, version", + "explain_user_data_kmp" to "User data: likes/history/playlists/cache, then quit", + "remove_songs_data_kmp" to "Clear song data", + "remove_user_data_kmp" to "Clear user data", + "confirm_delete_from_menu_short" to "Delete from playlist?", + "play_text" to "Playing,Paused", + "connected" to "Disconnected", + "connecting" to "Connecting…", + "handshaking" to "Handshaking…", + "disconnected" to "Disconnected", + "version_mismatch" to "Version mismatch", + "verified" to "Verified", + "transfer_done" to "Transfer complete", + "peer_abort" to "Peer aborted", + "cannot_connect_host" to "Cannot connect %s", + "failed" to "Failed", + "handshake_incomplete" to "Handshake incomplete", + "sent_phone2pc" to "Sent phone2pc", + "requested_pc2phone" to "Requested pc2phone", + "applied_pc_love" to "Applied PC love list", + "applied_pc_menus" to "Applied PC playlists/likes", + "session_end" to "Session ended", + "pending_n" to "%s song(s) pending", + "ready_n" to "Ready %s", + "downloading_song" to "Downloading %s", + "http_port" to "HTTP port %s", + "widget_white" to "LLMP Music Widget (White)", + "widget_black" to "LLMP Music Widget (Black)", + "widget_desc" to "LoveLive! music player", + "duration_label" to "Duration: ", + "position_label" to "Position: ", + "release_date_label" to "Release date: ", + "classification_label" to "Category: ", + "data_update_done_ver" to "Data update complete v%s · %s song(s)", + "downloading_short" to "Downloading…", + "share_error_n" to "Share failed, error: %s", + "album_abbr" to "A", + "music_abbr" to "S", + "menu_abbr" to "P", + "lyric_close" to "Close desktop lyrics", + "lyric_pip" to "Picture in Picture", + "lyric_translate" to "Translated lyrics", + "playback_channel" to "Now Playing", + "camera_permission" to "Camera access is required to scan QR codes", + "photo_permission" to "Photo library access is required to choose images", +) diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/I18n.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/I18n.kt new file mode 100644 index 0000000..2db059d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/I18n.kt @@ -0,0 +1,62 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class AppLang { ZH, EN, JA, DE } + +/** + * 对齐 Flutter GetX `Translation`:zh_CN / en_US / ja_JP / de_DE,系统语言,缺省中文。 + */ +object I18n { + private val _lang = MutableStateFlow(detectSystemLang()) + val lang: StateFlow = _lang.asStateFlow() + + fun setLanguage(lang: AppLang) { + if (_lang.value != lang) _lang.value = lang + } + + fun syncFromSystem() { + setLanguage(detectSystemLang()) + } + + fun setLanguageTag(tag: String) { + setLanguage(langFromTag(tag)) + } + + /** 无占位:Swift 侧 `I18n.shared.t(key:)`,vararg 在 ObjC/Swift 必须传 args。 */ + fun t(key: String): String { + val table = tableFor(_lang.value) + return table[key] ?: ZhStrings[key] ?: key + } + + fun t(key: String, vararg args: Any?): String { + var value = t(key) + for (arg in args) { + value = value.replaceFirst("%s", arg?.toString().orEmpty()) + } + return value + } + + private fun tableFor(lang: AppLang) = when (lang) { + AppLang.ZH -> ZhStrings + AppLang.EN -> EnStrings + AppLang.JA -> JaStrings + AppLang.DE -> DeStrings + } +} + +fun String.tr(vararg args: Any?): String = I18n.t(this, *args) + +fun langFromTag(tag: String): AppLang { + val lower = tag.lowercase() + return when { + lower.startsWith("ja") -> AppLang.JA + lower.startsWith("de") -> AppLang.DE + lower.startsWith("en") -> AppLang.EN + else -> AppLang.ZH + } +} + +expect fun detectSystemLang(): AppLang diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt new file mode 100644 index 0000000..2d74844 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/JaStrings.kt @@ -0,0 +1,331 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +internal val JaStrings: Map = mapOf( + "music" to "曲", + "album" to "アルバム", + "music_name" to "曲名", + "singer" to "歌手", + "iLove" to "私の好き", + "songMenu" to "プレイリスト", + "history" to "最近の再生", + "update_fail" to "更新に失敗しました", + "update" to "バージョンアップ", + "update_yes" to "アップグレード", + "update_no" to "アップグレードしない", + "no_need_update" to "現在最新バージョンです", + "music_trans" to "曲の転送", + "data_sync" to "データ同期", + "theme_with_system" to "システムに従う", + "colorful_mode" to "カラフルモード", + "splash_photo" to "スプラッシュ画像", + "enable_background_photo" to "背景画像を有効にする", + "choose_background_photo" to "背景画像を選択", + "fetch_songs" to "データ更新", + "clear_database" to "データをクリア", + "will_shutdown" to "アプリがもうすぐ終了します。手動で再起動してください", + "clean_success" to "クリア成功", + "view_log" to "ログを見る", + "privacy_agreement" to "プライバシー契約", + "system_settings" to "システム設定", + "drive_mode" to "ドライブモード", + "night_mode" to "ナイトモード", + "downloading" to "データをダウンロード中", + "now_is_latest" to "最新バージョンです。上書きしますか?", + "data_error" to "データ異常", + "fetch_songs_fail" to "データソースの取得に失敗しました", + "importing" to "インポート中...", + "transferring" to "転送中...", + "loading" to "読み込み中...", + "song_lib" to "曲ライブラリ", + "mine" to "マイ", + "requesting" to "ネットワークをリクエスト中...", + "please_check_network" to "ネットワークを確認してください", + "keep_same_lan" to "スマートフォンとPCが同じローカルネットワークにいることを確認してください", + "keep_screen_and_scan_qr" to "画面を常にオンにして、PCのQRコードをスキャンしてください", + "phone2pc" to "スマホ ≫ PC", + "pc2phone" to "PC ≫ スマホ", + "device_pair" to "デバイスペアリング", + "terminate_trans" to "終了すると接続と転送が中断されます。続行しますか?", + "confirm" to "確認", + "connect_fail" to "接続に失敗しました", + "album_info" to "アルバム情報", + "add_success" to "追加成功", + "add_fail" to "追加失敗", + "add_to_playlist" to "プレイリストに追加", + "add_to_menu" to "プレイリストに追加", + "create_menu" to "プレイリストを作成", + "create_success" to "作成成功", + "create_over_max" to "最大数を超えました", + "add_to_iLove" to "私の好きに追加しました", + "remove_from_iLove" to "私の好きから削除しました", + "rename_menu" to "プレイリストの名前を変更", + "delete_menu" to "プレイリストを削除", + "music_info" to "曲情報", + "view_album" to "アルバムを見る", + "remove_music" to "曲を削除", + "shuffle_play" to "シャッフル再生", + "order_play" to "順番再生", + "single_play" to "単曲リピート", + "total_number_unit" to "曲", + "total_album_number_unit" to "アルバム", + "duration" to "長さ", + "position" to "位置", + "release_date" to "発売日", + "classification" to "分類", + "unknown" to "不明", + "select_items" to "選択済み", + "cancel" to "キャンセル", + "click_again_to_back" to "もう一度クリックしてデスクトップに戻る", + "data_parsing" to "データ解析中...", + "github_url" to "このソフトウェアのオープンソースプロジェクトリンク", + "no_songs" to "曲がありません", + "no_lyrics" to "歌詞がありません", + "title" to "タイトル", + "message" to "メッセージ", + "input_menu_name" to "プレイリスト名を入力してください", + "privacy_policy" to "プライバシー保護方針", + "permission_show_full" to "完全版を表示", + "permission_main_message" to "LoveLiveMusicPlayerをご利用いただきありがとうございます!\n\nユーザー契約及びプライバシーポリシーを通じて、本ソフトウェアが提供するサービスや、個人データの収集・処理方法についてご理解いただけます。\n「同意して続行」ボタンをクリックすることで、同意したことになります。", + "permission_external_link" to "《ユーザー契約及びプライバシーポリシー》", + "permission_agree" to "同意して続行", + "permission_disagree" to "同意しない", + "no_data" to "データがありません", + "choose_your_clean_data" to "クリアするデータを選択してください", + "remove_songs_data" to "曲データを削除", + "remove_user_data" to "ユーザーデータを削除", + "explain_songs_data" to "曲データ:曲のキャッシュデータ", + "explain_user_data" to "ユーザーデータ:プレイヤー設定、私の好き、プレイリスト", + "resetting" to "リセット中...", + "warning_choose" to "警告: 注意して選択してください", + "confirm_full_trans" to "データを完全に上書きしますか", + "cover_full_data" to "データを完全に上書き", + "file_path_error" to "ファイルパスエラー", + "crop_background_image" to "背景画像をトリミング", + "copy_success" to "コピー成功", + "need_enable_bg" to "まず背景画像を有効にしてください", + "search_lyric" to "歌詞を再検索", + "search_lyric_success" to "歌詞取得成功", + "search_lyric_failed" to "歌詞取得失敗", + "share_app" to "あなたにLLMPを共有しました", + "outer_share_music" to "あなたに曲を共有しました", + "outer_share_menu" to "あなたにプレイリストを共有しました", + "share_music" to "曲を共有", + "share_menu" to "プレイリストを共有", + "no_qq" to "TIM/QQがインストールされていません", + "version_incompatible" to "APPとPCバージョンが互換性がありません。ブログに行って更新してください", + "no_found_music1" to "この曲が見つかりません", + "need_play_share_music" to "共有曲を再生しますか", + "no_found_music2" to "利用可能な曲が見つかりません", + "need_import_share_menu" to "共有プレイリストをインポートしますか", + "delete_from_menu" to "プレイリストから削除", + "confirm_delete_from_menu" to "選択した曲をプレイリストから削除しますか?", + "cancel_i_like" to "私の好きをキャンセル", + "confirm_to_delete_music" to "選択した曲を私の好きから削除しますか?", + "share" to "アプリを共有", + "skip" to "スキップ", + "net_error" to "ネットワークエラー", + "hint" to "ヒント", + "use_http_music" to "HTTP音楽ライブラリを使用", + "input_http_url" to "HTTPアドレスを入力", + "support_http_characters" to "サポートされている文字: a-z A-Z 0-9 .:/_-", + "now_can_not_play" to "現在再生できません", + "moe_girl_wiki" to "萌娘百科", + "need_delete_menu" to "プレイリストを削除しますか", + "select_time" to "時間を選択してください(単位:分)", + "upgrading" to "アップグレード中...", + "upgrade_on_next_time" to "更新は次回起動時に有効になります", + "end_time" to "終了時間:", + "timed_to_stop" to "定時停止", + "timed_to_stop_remain" to "定時停止まで残り", + "minutes" to "分", + "please_give_install_permission_manual" to "APKのインストール権限を手動で付与してください", + "please_give_install_permission" to "アップグレードにはAPKのインストール権限を付与する必要があります。権限を付与しますか?", + "privacy_detail1" to "親愛なるLLer:\n\n「ソフトウェア使用許諾契約」(以下「本契約」)に記載されたすべての権利および制限を必ずお読みください。契約条件に同意しない限り、本ソフトウェアおよびその関連サービスを使用する権利はありません。本ソフトウェアを使用することにより、契約に同意したものとみなされます。\n\n1. 権利声明\n本ソフトウェアはオープンソースプロジェクトであり、著作権は作者zhushenwudiに帰属します。\n一部の情報はネットワークリソースから取得されており、テキスト表現やその組み合わせ、アイコン、画像、色、関連データなどが含まれます。著作権の問題が発生した場合は、作者のメール55681140@163.comに連絡して削除または置き換えを依頼してください。\n本ソフトウェアの宣伝や関連ファイルの転載については、作者に連絡して許可を得てください。使用中に発生した問題については、作者に連絡してサポートを受けることができます。作者はユーザーに本ソフトウェアの修正やアップグレード版を提供する権利を留保します。\n\n2. 许可范围\nダウンロード、インストール、使用:ユーザーは非商業目的で無制限に本ソフトウェアをダウンロード、インストール、使用できます。\nコピー、配布、伝播:ユーザーは非商業目的で無制限に本ソフトウェアをコピー、配布、伝播できますが、各コピー、配布、伝播が本ソフトウェアのすべてのソフトウェア、電子文書、著作権、商標を含む完全かつ真実であることを保証しなければなりません。\n\n3. プライバシーセキュリティ\n本ソフトウェアには、クラッシュ情報の報告や本ソフトウェア内の使用データのアップロードを除いて、ユーザーの端末データを破壊したり、ユーザーのプライバシー情報を取得したりする悪意のあるコードは含まれていません。\nユーザーの端末の行動を追跡、監視する機能コードは含まれておらず、ユーザーのオンラインまたはオフラインの行動を監視することはありません。他のソフトウェアや文書などの個人情報を収集することはなく、ユーザーのプライバシーを漏洩することもありません。\n収集されたデータは、ユーザーがクラッシュ中のエラー情報を分析し、本ソフトウェアを改善するために使用されます。\n以下は使用している統計データ開発ツールです:\n Ⅰ. 使用SDK名:友盟SDK\n 収集される個人情報の種類:デバイス情報(IMEI/Mac/android ID/IDFA/OPENUDID/GUID/SIMカードIMSI/地理位置情報)\n サービスの種類:データ収集と統計分析", + "privacy_umeng" to "《友盟プライバシーポリシーリンク》", + "privacy_detail2" to "\n\nⅡ. 使用SDK名:MobTechのShareSDK\n サービスの種類:シェアとプッシュを実現", + "privacy_share" to "《MobTechプライバシーポリシーリンク》", + "privacy_detail3" to "\n\nⅢ. 使用SDK名:360加固SDK(APK/HAP/APKプラグイン/SDK加固に適用)\n デバイス権限:なし\n 実現する機能目的:アプリの故障検出と診断、アプリのセキュリティ強化保護、海賊版検出サービス、脅威環境検出サービスを提供し、ユーザーが異常な状況を迅速に解決できるようにする\n 権限付与方法:権限付与方法はアプリ開発者が決定します。端末ユーザーが開発者のアプリに権限を付与することに同意した場合に有効になります", + "privacy_360" to "《360加固プライバシーポリシーリンク》", + "privacy_detail4" to "\n\n\n4. ユーザーへの注意\n本ソフトウェアはLoveLive!ファンによって自主的に使用されており、著作権のある曲は含まれていません。著作権のある曲はユーザーが自らCDから抽出してインポートする必要があります。インポートのディレクトリ構造はGithubプロジェクトの文書を参照してください。\nユーザーは法律および契約に従って本ソフトウェアを使用する必要があります。ユーザーは以下の行為を行う権利を有しません:\nⅠ. 本ソフトウェアのすべての権利管理電子情報を削除または変更してはなりません;\nⅡ. 本ソフトウェアを利用して他人を誤解させ、欺いてはなりません;\nⅢ. 国家の規定に違反して、コンピュータ情報システムの機能を削除、変更、追加、干渉し、コンピュータ情報システムが正常に動作できなくなる原因を作ってはなりません;\nⅣ. 許可なくコンピュータ情報ネットワークにアクセスしたり、コンピュータ情報ネットワーク資源を使用したりしてはなりません;\nⅤ. 許可なくコンピュータ情報ネットワークの機能を削除、変更、または追加してはなりません。\n ", + "now_playing" to "再生中:", + "group_list" to "グループリスト", + "back" to "戻る", + "phone" to "スマートフォン", + "pc" to "PC", + "add_favorite_menu" to "お気に入りのプレイリスト", + "music_blind_box" to "音楽ブラインドボックス", + "throbbing" to "あなたがドキドキするのはわかりますが、交通安全にも注意してください", + "exit" to "終了", + "finish" to "完了", + "search_in_moe_girl" to "萌娘百科で検索", + "share_error" to "共有に失敗しました。エラーコード:", + "share_success" to "成功を分かち合います", + "share_cancel" to "キャンセルを共有します", + "daily_news" to "日々のニュース", + "recent_trends" to "最近のトレンド", + "live_notice" to "放送予告", + "today_in_previous_years" to "過去の今日", + "input_song_main_char" to "曲名のキーワードを入力してください", + "playing" to "放送中", + "paused" to "停止しました", + "storage_settings" to "記憶場所設定です", + "change_storage_warning" to "使用するストレージを選択します。\nデバイスを切り替えると、全てのデータが消去されます。", + "internal_storage" to "内部ストレージです", + "storage_not_found" to "記憶装置は存在しません", + "need_play_music_first" to "曲の再生をお願いします", + "desktop_lyric" to "デスクトップの歌詞", + "choose" to "選ぶ", + "choose_album_category" to "アルバムカテゴリーを選択", + "people_unit" to "人", + "menu_unit" to "個", + "confirm_unlove" to "私の好きから削除しますか?", + "got_it" to "了解", + "close" to "閉じる", + "check_update" to "更新を確認中…", + "data_update_done" to "データ更新完了", + "no_download_url" to "ダウンロード先がありません", + "scan_pair" to "QRでペアリング", + "tap_to_scan" to "カメラでQRをスキャン", + "or_input_pc_ip" to "またはPCのIPを入力", + "confirm_pair" to "ペアリングを確認", + "disconnect" to "切断", + "data_parsing_short" to "データ解析中", + "history_short" to "履歴", + "today" to "今日", + "cannot_load" to "読み込めません:", + "daily_timezone_hint" to "注:特記なき場合、このページの時刻は北京時間(UTC+8)です。", + "select_storage" to "音楽ファイルの保存先を選択", + "no_usb" to "リムーバブルストレージ(USB)が見つかりません。", + "http_url_example" to "例: https://host/path", + "timer_cancelled" to "定時停止をキャンセルしました", + "will_stop_in" to "%s 分後に再生を停止します", + "exiting" to "終了します…", + "user_data_cleared" to "ユーザーデータを削除しました", + "new_version" to "新しいバージョン v%s", + "upgrade_prompt" to "新しいバージョンがあります。今すぐアップグレードしますか?", + "add_to_playlist_short" to "再生リストに追加", + "new_menu" to "新しいプレイリスト", + "menu_name" to "プレイリスト名", + "unknown_song" to "不明な曲", + "input_song_keyword" to "曲名キーワードを入力", + "not_playing" to "未再生", + "collapse" to "折りたたむ", + "more" to "その他", + "jump_play" to "再生へ", + "lyric_orig" to "原", + "lyric_trans" to "訳", + "lyric_roma" to "音", + "play_all" to "すべて再生", + "no_album_info" to "アルバム情報がありません", + "song_label" to "曲: ", + "album_label" to "アルバム: ", + "opening" to "開いています:", + "tachie" to "立ち絵", + "opening_tachie" to "立ち絵を開いています…", + "dark_mode" to "ダークモード", + "web_page" to "ウェブ", + "scan_qr_prompt" to "QRコードをスキャン", + "opening_app" to "アプリを開いています...", + "no_permission" to "権限がありません", + "ready_to_play" to "再生準備", + "forward" to "進む", + "refresh" to "更新", + "backward" to "戻る", + "play" to "再生", + "pause" to "一時停止", + "playlist" to "再生リスト", + "cannot_open_page" to "ページを開けません", + "invalid_link" to "無効なリンク", + "share_chooser" to "共有", + "no_activity" to "no activity", + "logs_error" to "エラー", + "logs_warning" to "警告", + "logs_info" to "情報", + "logs_debug" to "デバッグ", + "logs_verbose" to "詳細", + "logs_monitor" to "モニター", + "logs_settings" to "設定", + "logs_actions" to "操作", + "logs_copied_one" to "このログをコピーしました", + "logs_copied_all" to "すべてのログをコピーしました", + "logs_copied_filter" to "絞り込んだログをコピーしました", + "logs_search" to "検索…", + "logs_copy" to "コピー", + "logs_actions_title" to "ログ操作", + "logs_reverse" to "ログを逆順", + "logs_copy_all" to "すべてのログをコピー", + "logs_copy_filtered" to "絞り込んだログをコピー", + "logs_expand" to "ログを展開", + "logs_collapse" to "ログを折りたたむ", + "logs_clear" to "履歴を消去", + "logs_share_file" to "ログファイルを共有", + "logs_settings_title" to "ログ設定", + "logs_on" to "オン", + "logs_off" to "オフ", + "logs_monitor_title" to "ログモニター", + "logs_unhandled_errors" to "未処理エラーが %s 件あります", + "logs_warning_count" to "警告が %s 件あります", + "logs_info_count" to "情報ログ数:%s", + "logs_debug_count" to "詳細/デバッグログ数:%s", + "logs_verbose_debug" to "詳細とデバッグ", + "selected_count" to "選択済み %s 曲", + "count_songs" to "%s 曲", + "count_albums" to "%s アルバム", + "count_artists" to "%s 人", + "count_menus" to "%s 個", + "menu_meta_phone" to "%s 曲 · スマホ", + "menu_meta_pc" to "%s 曲 · PC", + "loop_songs" to "%s - %s 曲", + "skip_n" to "スキップ %ss", + "end_time_value" to "終了時間:%s", + "timed_remain_n" to "定時停止まで残り%s分", + "keep_screen_scan_qr" to "画面をオンのままQRコードをスキャンしてください", + "now_playing_empty" to "再生中:曲がありません", + "now_playing_song" to "再生中:%s", + "fetch_songs_fail_or_net" to "データ異常またはネットワーク失敗", + "explain_songs_data_kmp" to "曲データ:ライブラリメタデータ、バージョン", + "explain_user_data_kmp" to "ユーザーデータ:好き/履歴/プレイリスト/キャッシュを削除して終了", + "remove_songs_data_kmp" to "曲データを削除", + "remove_user_data_kmp" to "ユーザーデータを削除", + "confirm_delete_from_menu_short" to "プレイリストから削除しますか?", + "play_text" to "放送中,停止しました", + "connected" to "未接続", + "connecting" to "接続中…", + "handshaking" to "ハンドシェイク中…", + "disconnected" to "切断しました", + "version_mismatch" to "バージョン不一致", + "verified" to "検証済み", + "transfer_done" to "転送完了", + "peer_abort" to "相手が中断", + "cannot_connect_host" to "%s に接続できません", + "failed" to "失敗", + "handshake_incomplete" to "ハンドシェイク未完了", + "sent_phone2pc" to "phone2pc を送信しました", + "requested_pc2phone" to "pc2phone を要求しました", + "applied_pc_love" to "PCの好きリストを適用しました", + "applied_pc_menus" to "PCのプレイリスト/好きを適用しました", + "session_end" to "セッション終了", + "pending_n" to "送信待ち %s 曲", + "ready_n" to "準備完了 %s", + "downloading_song" to "ダウンロード中 %s", + "http_port" to "HTTPポート %s", + "widget_white" to "LLMP 音楽ウィジェット (白)", + "widget_black" to "LLMP 音楽ウィジェット (黒)", + "widget_desc" to "LoveLive!メディアプレーヤー", + "duration_label" to "長さ: ", + "position_label" to "位置: ", + "release_date_label" to "発売日: ", + "classification_label" to "分類: ", + "data_update_done_ver" to "データ更新完了 v%s · %s曲", + "downloading_short" to "ダウンロード中…", + "share_error_n" to "共有失敗、エラーコード: %s", + "album_abbr" to "ア", + "music_abbr" to "曲", + "menu_abbr" to "リ", + "lyric_close" to "デスクトップ歌詞を閉じる", + "lyric_pip" to "ピクチャインピクチャ", + "lyric_translate" to "翻訳歌詞", + "playback_channel" to "再生中", + "camera_permission" to "QRコードをスキャンするためにカメラへのアクセスが必要です", + "photo_permission" to "画像を取得するために写真へのアクセスが必要です", +) diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt new file mode 100644 index 0000000..a6803ef --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/ZhStrings.kt @@ -0,0 +1,331 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +internal val ZhStrings: Map = mapOf( + "music" to "歌曲", + "album" to "专辑", + "music_name" to "歌名", + "singer" to "歌手", + "iLove" to "我喜欢", + "songMenu" to "歌单", + "history" to "最近播放", + "update_fail" to "更新失败", + "update" to "版本升级", + "update_yes" to "升级", + "update_no" to "不升级", + "no_need_update" to "当前已是最新版本", + "music_trans" to "歌曲快传", + "data_sync" to "数据同步", + "theme_with_system" to "跟随系统", + "colorful_mode" to "炫彩模式", + "splash_photo" to "开屏图片", + "enable_background_photo" to "启用背景图片", + "choose_background_photo" to "选择背景图片", + "fetch_songs" to "数据更新", + "clear_database" to "清理数据", + "will_shutdown" to "应用即将关闭,请手动重启", + "clean_success" to "清理成功", + "view_log" to "查看日志", + "privacy_agreement" to "隐私协议", + "system_settings" to "系统设置", + "drive_mode" to "驾驶模式", + "night_mode" to "夜间模式", + "downloading" to "下载数据中", + "now_is_latest" to "已是最新版本,是否覆盖?", + "data_error" to "数据异常", + "fetch_songs_fail" to "获取数据源失败", + "importing" to "导入中...", + "transferring" to "正在传输中...", + "loading" to "加载中...", + "song_lib" to "歌库", + "mine" to "我的", + "requesting" to "请求网络中...", + "please_check_network" to "请检查网络", + "keep_same_lan" to "请保持手机和电脑处于同一局域网内", + "keep_screen_and_scan_qr" to "保持屏幕常亮,并扫描PC端二维码", + "phone2pc" to "手机 ≫ 电脑", + "pc2phone" to "电脑 ≫ 手机", + "device_pair" to "设备配对", + "terminate_trans" to "退出后会中断连接及传输,是否继续?", + "confirm" to "确定", + "connect_fail" to "连接失败", + "album_info" to "专辑详情", + "add_success" to "添加成功", + "add_fail" to "添加失败", + "add_to_playlist" to "加入播放列表", + "add_to_menu" to "添加到歌单", + "create_menu" to "创建歌单", + "create_success" to "创建成功", + "create_over_max" to "超出最大数量", + "add_to_iLove" to "已加入我喜欢", + "remove_from_iLove" to "已取消我喜欢", + "rename_menu" to "重命名歌单", + "delete_menu" to "删除歌单", + "music_info" to "歌曲信息", + "view_album" to "查看专辑", + "remove_music" to "删除歌曲", + "shuffle_play" to "随机播放", + "order_play" to "顺序播放", + "single_play" to "单曲循环", + "total_number_unit" to "首", + "total_album_number_unit" to "张", + "duration" to "时长", + "position" to "位置", + "release_date" to "发行日期", + "classification" to "分类", + "unknown" to "未知", + "select_items" to "已选", + "cancel" to "取消", + "click_again_to_back" to "再次点击回到桌面", + "data_parsing" to "数据解析中...", + "github_url" to "本软件开源项目链接", + "no_songs" to "暂无歌曲", + "no_lyrics" to "暂无歌词", + "title" to "标题", + "message" to "消息", + "input_menu_name" to "请输入歌单名", + "privacy_policy" to "隐私保护政策", + "permission_show_full" to "查看完整版", + "permission_main_message" to "欢迎使用LoveLiveMusicPlayer!\n\n我们将通过《用户协议及隐私政策》帮助您了解本软件为您提供的服务,及收集、处理您个人数据的方式。\n点击【同意并继续】按钮代表您已同意。", + "permission_external_link" to "《用户协议及隐私政策》", + "permission_agree" to "同意并继续", + "permission_disagree" to "不同意", + "no_data" to "暂无数据", + "choose_your_clean_data" to "请选择要清理的数据", + "remove_songs_data" to "删除歌曲数据", + "remove_user_data" to "删除用户数据", + "explain_songs_data" to "歌曲数据:歌曲缓存数据", + "explain_user_data" to "用户数据:播放器配置、我喜欢、歌单", + "resetting" to "重置中...", + "warning_choose" to "警告: 请谨慎选择", + "confirm_full_trans" to "是否确定完全覆盖数据", + "cover_full_data" to "完全覆盖数据", + "file_path_error" to "文件路径错误", + "crop_background_image" to "裁切背景图片", + "copy_success" to "复制成功", + "need_enable_bg" to "请先启用背景图片", + "search_lyric" to "重新搜索歌词", + "search_lyric_success" to "获取歌词成功", + "search_lyric_failed" to "获取歌词失败", + "share_app" to "Ta向你分享了LLMP", + "outer_share_music" to "Ta向你分享了一首歌曲", + "outer_share_menu" to "Ta向你分享了一个歌单", + "share_music" to "分享歌曲", + "share_menu" to "分享歌单", + "no_qq" to "TIM/QQ未安装", + "version_incompatible" to "APP与PC版本不匹配,请前往博客更新", + "no_found_music1" to "未找到此歌曲", + "need_play_share_music" to "是否播放分享歌曲", + "no_found_music2" to "未找到可用歌曲", + "need_import_share_menu" to "是否导入分享歌单", + "delete_from_menu" to "从歌单中删除", + "confirm_delete_from_menu" to "确认要从歌单删除所选歌曲?", + "cancel_i_like" to "取消我喜欢", + "confirm_to_delete_music" to "确认要从我喜欢删除所选歌曲?", + "share" to "应用分享", + "skip" to "跳过", + "net_error" to "网络错误", + "hint" to "提示", + "use_http_music" to "使用HTTP曲库", + "input_http_url" to "输入HTTP地址", + "support_http_characters" to "支持字符: a-z A-Z 0-9 .:/_-", + "now_can_not_play" to "当前无法播放", + "moe_girl_wiki" to "萌娘百科", + "need_delete_menu" to "是否刪除歌单", + "select_time" to "请选择时间(单位:分钟)", + "upgrading" to "升级中...", + "upgrade_on_next_time" to "更新将于下次启动生效", + "end_time" to "结束时间:", + "timed_to_stop" to "定时关闭", + "timed_to_stop_remain" to "定时关闭,还剩", + "minutes" to "分钟", + "please_give_install_permission_manual" to "请手动赋予安装APK权限", + "please_give_install_permission" to "升级需要授予安装APK权限,是否授权?", + "privacy_detail1" to "亲爱的LLer:\n\n请务必认真阅读和理解《软件许可使用协议》(以下简称:本协议)中规定的所有权利和限制,除非您接受协议条款,否则您无权使用本软件及其相关服务。您一旦使用本软件,将视为对协议的接受。\n\n1.权利声明\n本软件为开源项目,由作者zhushenwudi权利所有。\n其中部分信息内容从网络资源获取,包含但不限于:文字表述及其组合、图标、图像、色彩、有关数据,若因版权问题产生任何矛盾和纠纷,请联系作者本人邮箱55681140@163.com予以删除或替换。\n若对软件的宣传、相关文件的转载请联系作者本人进行授权。使用中出现的任何问题均可联系作者获取支持。作者保留为用户提供本软件的修改、升级版本的权利。\n\n2.许可范围\n下载、安装和使用:用户可以非商业性、无限制数量的下载、安装及使用本软件。\n复制、分发和传播:用户可以非商业性、无限制数量的复制、分发和传播本软件产品,但必须保证每一份复制、分发和传播都是完整和真实的,包括所有有关本软件的软件、电子文档、版权和商标,亦包括协议。\n\n3.隐私安全\n本软件不含有除上报崩溃信息、上传本软件内使用数据外的任何旨在破坏用户终端数据和获取用户隐私信息的恶意代码。\n不含有任何跟踪、监视用户终端行为的功能代码,不会监控用户网上、网下的行为,不会收集用户使用其他软件、文档等个人信息,不会泄露用户隐私。\n收集到的数据将用户分析崩溃中的错误信息用于更好的完善本软件。\n下面是使用的统计数据开发工具:\n Ⅰ.使用SDK名称:友盟SDK\n 收集个人信息类型:设备信息(IMEI/Mac/android ID/IDFA/OPENUDID/GUID/SIM卡IMSI/地理位置信息)\n 服务类型:数据采集与统计分析", + "privacy_umeng" to "《友盟隐私权政策链接》", + "privacy_detail2" to "\n\nⅡ.使用SDK名称:MobTech的ShareSDK\n 服务类型:实现分享和推送", + "privacy_share" to "《MobTech隐私权政策链接》", + "privacy_detail3" to "\n\nⅢ.使用SDK名称:360加固SDK (适用于APK/HAP/APK插件/SDK加固)\n 设备权限: 无\n 实现的功能目的: 用于检测App故障和诊断,App安全加固保护,盗版检测服务,威胁环境检测服务,以便帮助用户快速解决异常情况\n 权限授权方式: 授权方式由App开发者决定,当终端用户同意向开发者App授权该权限时开启", + "privacy_360" to "《360加固隐私权政策链接》", + "privacy_detail4" to "\n\n\n4.用户须知\n本软件为LoveLive!圈内用户自发使用,不包含任何版权歌曲在内。版权歌曲均由用户本人自行从CD提取导入,导入目录结构详见Github项目文档。\n用户应在遵守法律及协议的前提下使用本软件,用户无权实施包括但不限于下列行为:\nⅠ. 不得删除或改变本软件上的所有权利管理电子信息;\nⅡ. 不得利用本软件误导、欺骗他人;\nⅢ. 违反国家规定,对计算机信息系统功能进行删除、修改、增加、干扰,造成计算机信息系统不能正常运行;\nⅣ. 未经允许,进入计算机信息网络或者使用计算机信息网络资源;\nⅤ. 未经允许,对计算机信息网络功能进行删除、修改或者增加的。\n ", + "now_playing" to "正在播放:", + "group_list" to "团组列表", + "back" to "返回", + "phone" to "手机", + "pc" to "电脑", + "add_favorite_menu" to "收藏歌单", + "music_blind_box" to "音乐盲盒", + "throbbing" to "我知道你会悸动,但也要注意交通安全", + "exit" to "退出", + "finish" to "完成", + "search_in_moe_girl" to "在萌娘百科搜索", + "share_error" to "分享失败,错误代码:", + "share_success" to "分享成功", + "share_cancel" to "分享取消", + "daily_news" to "每日资讯", + "recent_trends" to "近期动态", + "live_notice" to "放送预告", + "today_in_previous_years" to "往年今日", + "input_song_main_char" to "请输入歌曲名关键字", + "playing" to "播放中", + "paused" to "已暂停", + "storage_settings" to "存储设置", + "change_storage_warning" to "选择要使用的存储设备\n切换设备将会清空全部数据", + "internal_storage" to "内部存储", + "storage_not_found" to "存储设备不存在", + "need_play_music_first" to "请先播放歌曲", + "desktop_lyric" to "桌面歌词", + "choose" to "选择", + "choose_album_category" to "选择专辑分类", + "people_unit" to "位", + "menu_unit" to "个", + "confirm_unlove" to "确认取消喜欢?", + "got_it" to "知道了", + "close" to "关闭", + "check_update" to "检查更新…", + "data_update_done" to "数据更新完成", + "no_download_url" to "未提供下载地址", + "scan_pair" to "扫码配对", + "tap_to_scan" to "点击打开相机扫码", + "or_input_pc_ip" to "或手动输入 PC IP", + "confirm_pair" to "确认配对", + "disconnect" to "断开连接", + "data_parsing_short" to "数据解析中", + "history_short" to "历史", + "today" to "今天", + "cannot_load" to "无法加载:", + "daily_timezone_hint" to "提示:除特别注明外,此页面或章节的时间均以北京时间(UTC+8)为准。", + "select_storage" to "请选择音乐文件存储位置", + "no_usb" to "未检测到可移动存储(USB)。", + "http_url_example" to "如 https://host/path", + "timer_cancelled" to "已取消定时关闭", + "will_stop_in" to "将在 %s 分钟后停止播放", + "exiting" to "即将退出…", + "user_data_cleared" to "用户数据已清理", + "new_version" to "发现新版本 v%s", + "upgrade_prompt" to "检测到新版本,是否立即升级?", + "add_to_playlist_short" to "添加到播放列表", + "new_menu" to "新建歌单", + "menu_name" to "歌单名称", + "unknown_song" to "未知歌曲", + "input_song_keyword" to "输入歌曲关键字", + "not_playing" to "未播放", + "collapse" to "收起", + "more" to "更多", + "jump_play" to "跳转播放", + "lyric_orig" to "原", + "lyric_trans" to "译", + "lyric_roma" to "音", + "play_all" to "播放全部", + "no_album_info" to "暂无专辑信息", + "song_label" to "歌曲: ", + "album_label" to "专辑: ", + "opening" to "正在打开:", + "tachie" to "立绘", + "opening_tachie" to "正在打开立绘…", + "dark_mode" to "深色模式", + "web_page" to "网页", + "scan_qr_prompt" to "扫描二维码", + "opening_app" to "正在打开应用...", + "no_permission" to "权限未被授予", + "ready_to_play" to "准备播放", + "forward" to "前进", + "refresh" to "刷新", + "backward" to "后退", + "play" to "播放", + "pause" to "暂停", + "playlist" to "播放列表", + "cannot_open_page" to "无法打开页面", + "invalid_link" to "链接无效", + "share_chooser" to "分享", + "no_activity" to "no activity", + "logs_error" to "错误", + "logs_warning" to "警告", + "logs_info" to "信息", + "logs_debug" to "调试", + "logs_verbose" to "详细", + "logs_monitor" to "监控", + "logs_settings" to "设置", + "logs_actions" to "操作", + "logs_copied_one" to "已复制该条日志", + "logs_copied_all" to "已复制全部日志", + "logs_copied_filter" to "已复制筛选后的日志", + "logs_search" to "搜索…", + "logs_copy" to "复制", + "logs_actions_title" to "日志操作", + "logs_reverse" to "倒序日志", + "logs_copy_all" to "复制全部日志", + "logs_copy_filtered" to "复制筛选日志", + "logs_expand" to "展开日志", + "logs_collapse" to "收起日志", + "logs_clear" to "清空历史", + "logs_share_file" to "分享日志文件", + "logs_settings_title" to "日志设置", + "logs_on" to "开", + "logs_off" to "关", + "logs_monitor_title" to "日志监控", + "logs_unhandled_errors" to "应用有 %s 条未处理错误", + "logs_warning_count" to "应用有 %s 条警告", + "logs_info_count" to "信息日志数量:%s", + "logs_debug_count" to "详细/调试日志数量:%s", + "logs_verbose_debug" to "详细与调试", + "selected_count" to "已选择 %s 首", + "count_songs" to "%s 首", + "count_albums" to "%s 张", + "count_artists" to "%s 位", + "count_menus" to "%s 个", + "menu_meta_phone" to "%s 首 · 手机", + "menu_meta_pc" to "%s 首 · PC", + "loop_songs" to "%s - %s 首", + "skip_n" to "跳过 %ss", + "end_time_value" to "结束时间:%s", + "timed_remain_n" to "定时关闭,还剩%s分钟", + "keep_screen_scan_qr" to "请保持屏幕常亮并扫描二维码", + "now_playing_empty" to "正在播放:暂无歌曲", + "now_playing_song" to "正在播放:%s", + "fetch_songs_fail_or_net" to "数据异常或网络失败", + "explain_songs_data_kmp" to "歌曲数据:曲库元数据、版本号", + "explain_user_data_kmp" to "用户数据:喜欢/历史/歌单/缓存,并退出应用", + "remove_songs_data_kmp" to "清除歌曲数据", + "remove_user_data_kmp" to "清除用户数据", + "confirm_delete_from_menu_short" to "确认从歌单删除?", + "play_text" to "播放中,已暂停", + "connected" to "未连接", + "connecting" to "连接中…", + "handshaking" to "握手中…", + "disconnected" to "已断开", + "version_mismatch" to "版本不匹配", + "verified" to "已验证", + "transfer_done" to "传输完成", + "peer_abort" to "对方中断", + "cannot_connect_host" to "无法连接 %s", + "failed" to "失败", + "handshake_incomplete" to "未完成握手", + "sent_phone2pc" to "已发送 phone2pc", + "requested_pc2phone" to "已请求 pc2phone", + "applied_pc_love" to "已应用 PC 侧喜欢列表", + "applied_pc_menus" to "已应用 PC 歌单/喜欢", + "session_end" to "会话结束", + "pending_n" to "待传 %s 首", + "ready_n" to "就绪 %s", + "downloading_song" to "下载中 %s", + "http_port" to "HTTP 端口 %s", + "widget_white" to "LLMP 音乐组件 (白)", + "widget_black" to "LLMP 音乐组件 (黑)", + "widget_desc" to "LoveLive!媒体播放器", + "duration_label" to "时长: ", + "position_label" to "位置: ", + "release_date_label" to "发行日期: ", + "classification_label" to "分类: ", + "data_update_done_ver" to "数据更新完成 v%s · %s首", + "downloading_short" to "下载中…", + "share_error_n" to "分享失败,错误代码: %s", + "album_abbr" to "专", + "music_abbr" to "歌", + "menu_abbr" to "单", + "lyric_close" to "关闭桌面歌词", + "lyric_pip" to "画中画", + "lyric_translate" to "翻译歌词", + "playback_channel" to "正在播放", + "camera_permission" to "需要获取摄像头权限扫描二维码", + "photo_permission" to "需要读取相册获取图片", +) diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LrcParser.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt similarity index 96% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LrcParser.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt index 0757c56..0156c8a 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LrcParser.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParser.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.lyric +package com.zhushenwudi.lovelivemusicplayer.lyric data class LyricLine( val timeMs: Long, diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LyricService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt similarity index 63% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LyricService.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt index 53fff8c..8b74ef9 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/lyric/LyricService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LyricService.kt @@ -1,49 +1,51 @@ -package top.zhushenwudi.llmp.lyric +package com.zhushenwudi.lovelivemusicplayer.lyric -import top.zhushenwudi.llmp.db.LibraryRepository -import top.zhushenwudi.llmp.domain.Lyric -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.network.OssApi -import top.zhushenwudi.llmp.platform.AppLog +import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository +import com.zhushenwudi.lovelivemusicplayer.domain.Lyric +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog /** - * Port of Flutter [LyricLogic]: getLrc + handleLRC (DB secondary cache). + * 歌词加载服务:getLrc + handleLRC(数据库二级缓存)。 * - * Cache semantics (Flutter comments): - * - row missing → storageLrc = null → fetch → insertLyric - * - row exists, field null/"" → storageLrc = "" → fetch → updateLrc - * - row exists, field non-empty → return DB (unless forceRefresh) + * 缓存语义: + * - 行不存在 → storageLrc = null → 拉取 → insertLyric + * - 行存在但字段为 null/"" → storageLrc = "" → 拉取 → updateLrc + * - 行存在且字段非空 → 返回数据库内容(除非 forceRefresh) */ class LyricService( private val oss: OssApi, private val library: LibraryRepository, ) { - /** Flutter: SDUtils.allowEULA ? 0 : 1 */ + /** EULA 允许时为 0,否则为 1 */ var lrcType: Int = if (PlatformInfo.allowEulaLyric) 0 else 1 - /** Last loaded full lyric row (Flutter LyricLogic.fullLrc). */ + /** 上次加载的完整歌词行。 */ var fullLrc: Lyric = Lyric() private set /** - * Flutter [LyricLogic.getLrc]. - * Uses playing music's relative [Music.baseUrl] + [Music.musicPath]. + * 加载歌词,使用曲目的相对 `Music.baseUrl` + `Music.musicPath`。 */ - suspend fun getLrc(music: Music, forceRefresh: Boolean = false): LoadedLyric { - val uid = music.musicId ?: return LoadedLyric().also { fullLrc = Lyric() } - // Prefer DB music so relative paths survive playback URI resolution. + suspend fun getLrc(music: Music, forceRefresh: Boolean = false): LoadedLyric = + fetchLrc(music, forceRefresh).loaded + + /** + * 对齐原 Flutter `LyricLogic.getLrc`:任一路 handleLRC 为 null 则搜索失败。 + */ + suspend fun fetchLrc(music: Music, forceRefresh: Boolean = false): LyricFetchResult { + val uid = music.musicId ?: return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric() }, false) val source = library.getMusicById(uid) ?: music val baseUrl = source.baseUrl val musicPath = source.musicPath if (baseUrl.isNullOrBlank() || musicPath.isNullOrBlank()) { - return LoadedLyric().also { fullLrc = Lyric(uid = uid) } + return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric(uid = uid) }, false) } - // Reject absolute / playback URIs — same as needing relative cloud keys. if (musicPath.contains("://") || musicPath.startsWith("/")) { - return LoadedLyric().also { fullLrc = Lyric(uid = uid) } + return LyricFetchResult(LoadedLyric().also { fullLrc = Lyric(uid = uid) }, false) } - // Flutter: musicPath.replaceAll("flac","lrc").replaceAll("wav","lrc") val lyricPath = musicPath .replace("flac", "lrc") .replace("wav", "lrc") @@ -51,29 +53,29 @@ class LyricService( var jpLrc = "" var zhLrc = "" var romaLrc = "" + var ok = true - // Flutter always loads ZH first val zh = handleLrc("zh", "ZH/$baseUrl$lyricPath", uid, forceRefresh) - if (zh != null) zhLrc = zh + if (zh == null) ok = false else zhLrc = zh if (PlatformInfo.allowEulaLyric) { val jp = handleLrc("jp", "JP/$baseUrl$lyricPath", uid, forceRefresh) - if (jp != null) jpLrc = jp + if (jp == null) ok = false else jpLrc = jp val roma = handleLrc("roma", "ROMA/$baseUrl$lyricPath", uid, forceRefresh) - if (roma != null) romaLrc = roma + if (roma == null) ok = false else romaLrc = roma } fullLrc = Lyric(uid = uid, jp = jpLrc, zh = zhLrc, roma = romaLrc) - return toLoaded(fullLrc) + return LyricFetchResult(toLoaded(fullLrc), ok) } - /** @deprecated Use [getLrc] — kept for call-site migration. */ + /** @deprecated 请使用 `getLrc`,保留用于迁移旧调用点。 */ suspend fun loadFor(music: Music, forceRefresh: Boolean = false): LoadedLyric = getLrc(music, forceRefresh) /** - * Flutter [LyricLogic.handleLRC] — per-language DB cache then network. - * @return lyric text, or null on failure / missing + * 按语言先查数据库缓存再请求网络。 + * @return 歌词文本,失败或缺失时返回 null */ private suspend fun handleLrc( type: String, @@ -84,7 +86,7 @@ class LyricService( if (lrcUrl.isEmpty() || uid.isEmpty()) return null var row = library.getLyric(uid) - // Flutter: null = never inserted; "" = inserted but empty + // null 表示从未插入;"" 表示已插入但为空 val storageLrc: String? = when { row == null -> null type == "jp" -> row.jp ?: "" @@ -94,7 +96,6 @@ class LyricService( } if (storageLrc.isNullOrEmpty() || forceRefresh) { - AppLog.d(TAG, "handleLrc($type) miss uid=$uid path=$lrcUrl force=$forceRefresh") return try { val netLrc = oss.fetchLyricRelativePath(lrcUrl) if (netLrc.isBlank()) { @@ -111,13 +112,11 @@ class LyricService( "roma" -> row.copy(roma = netLrc) else -> row } - // Flutter: insert when storageLrc == null, else update + // storageLrc 为 null 时插入,否则更新 if (storageLrc == null) { library.insertLyricNew(row) - AppLog.d(TAG, "handleLrc($type) insertLyric uid=$uid len=${netLrc.length}") } else { library.updateLyric(row) - AppLog.d(TAG, "handleLrc($type) updateLrc uid=$uid len=${netLrc.length}") } netLrc } catch (t: Throwable) { @@ -125,7 +124,6 @@ class LyricService( null } } - AppLog.d(TAG, "handleLrc($type) cache hit uid=$uid len=${storageLrc.length}") return storageLrc } @@ -133,7 +131,7 @@ class LyricService( lrcType = (lrcType + 1) % 3 } - /** Flutter [LyricLogic.reloadLyricToController] → parse for UI. */ + /** 将 fullLrc 解析为 UI 可用的 LoadedLyric。 */ fun toLoaded(lyric: Lyric = fullLrc): LoadedLyric { val main: String? val translation: String? @@ -170,8 +168,17 @@ data class LoadedLyric( val raw: Lyric? = null, ) +data class LyricFetchResult( + val loaded: LoadedLyric, + val ok: Boolean, +) + expect object PlatformInfo { val allowEulaLyric: Boolean + /** 是否为 iOS 平台,本地音频使用 wav 而非 flac。 */ + val isIos: Boolean + /** 对外展示版本号(iOS `CFBundleShortVersionString`)。 */ + val appVersion: String } private const val TAG = "LLMP-Lyric" diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.kt similarity index 61% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.kt index 04f821a..d687e73 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.kt @@ -1,25 +1,19 @@ -package top.zhushenwudi.llmp.network +package com.zhushenwudi.lovelivemusicplayer.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.header import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.platform.AppLog expect fun createPlatformHttpClient(): HttpClient -private const val HTTP_LOG_TAG = "LLMP-HTTP" - fun createHttpClient(): HttpClient = createPlatformHttpClient().config { - // Align with Flutter Network headers; OSS returns 403 without User-Agent. + // 设置 User-Agent 请求头;OSS 无此头会返回 403。 defaultRequest { header( HttpHeaders.UserAgent, @@ -38,13 +32,4 @@ fun createHttpClient(): HttpClient = ) } install(WebSockets) - // Log request + response (headers & body) to Logcat / NSLog. - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - AppLog.d(HTTP_LOG_TAG, message) - } - } - level = LogLevel.ALL - } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt new file mode 100644 index 0000000..be52c8a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/OssApi.kt @@ -0,0 +1,100 @@ +package com.zhushenwudi.lovelivemusicplayer.network + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsBytes +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.utils.io.exhausted +import io.ktor.utils.io.readAvailable +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem + +class OssApi( + private val client: HttpClient, + private val env: String = "prod", +) { + suspend fun fetchDataJson(): String = + client.get(Const.dataUrl(env)).bodyAsText() + + suspend fun fetchVersionJson(): String = + client.get(Const.updateUrl(env)).bodyAsText() + + suspend fun fetchArtistJson(): String = + client.get(Const.artistModelUrl(env)).bodyAsText() + + /** + * 请求歌词:Const.LYRIC_URL + 整段路径 URL 编码 + * `relativePath` 如 `JP/$baseUrl$file.lrc`(整段编码,`/` → `%2F`)。 + */ + suspend fun fetchLyricRelativePath(relativePath: String): String { + val url = Const.LYRIC_URL + encodeComponent(relativePath) + val response = client.get(url) { + header(HttpHeaders.Accept, "*/*") + } + return response.bodyAsText() + } + + suspend fun fetchLyric(url: String): String { + val response = client.get(url) { + header(HttpHeaders.Accept, "*/*") + } + return response.bodyAsText() + } + + suspend fun fetchText(url: String): String = + client.get(url).bodyAsText() + + suspend fun postJson(url: String, jsonBody: String): String = + client.post(url) { + contentType(ContentType.Application.Json) + setBody(jsonBody) + }.bodyAsText() + + suspend fun downloadBytes(url: String): ByteArray = + client.get(url).bodyAsBytes() + + /** 流式落盘,避免大 flac 整包进内存 OOM。 */ + suspend fun downloadToFile(url: String, dest: String, fs: FileSystem): Boolean { + val response = client.get(url) { + header(HttpHeaders.Accept, "*/*") + } + if (response.status.value !in 200..299) return false + if (!fs.writeBegin(dest)) return false + val channel = response.bodyAsChannel() + val buf = ByteArray(64 * 1024) + while (!channel.exhausted()) { + val n = channel.readAvailable(buf) + if (n <= 0) break + if (!fs.writeAppend(dest, buf, 0, n)) return false + } + return fs.exists(dest) + } + + /** + * 整段字符串 UTF-8 百分号编码。 + * 旧实现用 `Char.code` 作 `%XX`(如 ♡→`%2661`)无效, + * 且通过 `Char.isLetterOrDigit` 未转义 CJK。 + */ + private fun encodeComponent(value: String): String { + val bytes = value.encodeToByteArray() + return buildString(bytes.size * 3) { + for (b in bytes) { + val ub = b.toInt() and 0xFF + val c = ub.toChar() + if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "-._~") { + append(c) + } else { + append('%') + append(ub.toString(16).uppercase().padStart(2, '0')) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/WsClient.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt similarity index 86% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/WsClient.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt index 094c491..4c9fb46 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/WsClient.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/WsClient.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.network +package com.zhushenwudi.lovelivemusicplayer.network import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.webSocketSession @@ -11,10 +11,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.FtpCmd -import top.zhushenwudi.llmp.protocol.Handshake -import top.zhushenwudi.llmp.protocol.TransferChannel +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd +import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake +import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel class WsClient( private val client: HttpClient, diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.kt similarity index 63% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.kt index 8e66d7e..becf953 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.kt @@ -1,6 +1,6 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform -/** Platform logger — Android → Logcat, iOS → NSLog/println. */ +/** 平台日志 — Android → Logcat,iOS → NSLog/println。 */ expect object AppLog { fun d(tag: String, message: String) fun i(tag: String, message: String) diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLogStore.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLogStore.kt new file mode 100644 index 0000000..fd5f6ba --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLogStore.kt @@ -0,0 +1,56 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs + +data class AppLogEntry( + val level: String, + val tag: String, + val message: String, + val timeMs: Long, +) { + val key: String + get() = when (level) { + "E" -> "error" + "W" -> "warning" + "I" -> "info" + "D" -> "debug" + else -> "verbose" + } + + fun asLine(): String = "$level/$tag: $message" +} + +/** + * 内存环形缓冲区,保存最近 [AppLog] 行,供应用内「查看日志」页面 + * 对齐原项目 TalkerScreen。 + */ +object AppLogStore { + private const val MAX = 800 + private val entries = ArrayDeque(MAX) + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + fun append(level: String, tag: String, message: String) { + val entry = AppLogEntry( + level = level, + tag = tag, + message = message, + timeMs = currentTimeMs(), + ) + if (entries.size >= MAX) entries.removeFirst() + entries.addLast(entry) + _version.value = _version.value + 1 + } + + fun snapshot(): List = entries.toList() + + fun snapshotLines(): List = entries.map { it.asLine() } + + fun clear() { + entries.clear() + _version.value = _version.value + 1 + } +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/CarPlayCatalog.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/CarPlayCatalog.kt new file mode 100644 index 0000000..d373b71 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/CarPlayCatalog.kt @@ -0,0 +1,259 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import kotlinx.serialization.Serializable +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.domain.GroupKey +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode + +/** + * CarPlay 曲库目录。 + * UI 由原生 Swift `CPTemplate` 渲染;此模型提供快照数据与播放回调。 + */ +@Serializable +data class CarPlayGroupNode( + val group: String, + val displayName: String, + val detail: String = "", + val logoAsset: String = "", +) + +@Serializable +data class CarPlaySongItem( + val musicId: String, + val title: String, + val artist: String? = null, + val coverPath: String? = null, +) + +@Serializable +data class CarPlayAlbumItem( + val albumId: String, + val title: String, + val date: String? = null, + val group: String? = null, + val coverPath: String? = null, +) + +@Serializable +data class CarPlayMenuItem( + val menuId: Long, + val name: String, + val count: Int, + val isPhone: Boolean = true, + val coverPath: String? = null, +) + +@Serializable +data class CarPlaySnapshot( + val nowPlayingTitle: String, + val nowPlayingCover: String? = null, + val nowPlayingId: String? = null, + val isPlaying: Boolean = false, + val isFavorite: Boolean = false, + val loopMode: String = "LIST", + val groups: List, + val songsByGroup: Map>, + val albumsByGroup: Map>, + val songsByAlbum: Map>, + val loveSongs: List, + val recentSongs: List = emptyList(), + val menus: List, + val songsByMenu: Map>, + val allSongIds: List, +) + +class CarPlayCatalog { + var nowPlayingTitle: String = I18n.t("now_playing_empty") + private set + var nowPlayingCover: String? = null + private set + var nowPlayingId: String? = null + private set + var isPlaying: Boolean = false + private set + var isFavorite: Boolean = false + private set + var loopMode: String = LoopMode.LIST.name + private set + + var groups: List = defaultGroups() + private set + var loveSongs: List = emptyList() + private set + var recentSongs: List = emptyList() + private set + var menus: List = emptyList() + private set + + private var songsByGroup: Map> = emptyMap() + private var albumsByGroup: Map> = emptyMap() + private var songsByAlbum: Map> = emptyMap() + private var songsByMenu: Map> = emptyMap() + private var allSongIds: List = emptyList() + + var onPlay: ((musicId: String, queue: List) -> Unit)? = null + + fun updateNowPlaying( + music: Music?, + coverUri: String? = null, + playing: Boolean = false, + favorite: Boolean = false, + loopMode: LoopMode = LoopMode.LIST, + ) { + val name = music?.musicName + nowPlayingTitle = if (name.isNullOrBlank()) { + I18n.t("now_playing_empty") + } else { + I18n.t("now_playing_song", name) + } + nowPlayingCover = coverUri ?: music?.coverPath + nowPlayingId = music?.musicId + isPlaying = playing && music != null + isFavorite = favorite + this.loopMode = loopMode.name + } + + fun updateRecent(recent: List, musicCover: (Music) -> String? = { it.coverPath }) { + recentSongs = recent.map { it.toItem(musicCover(it)) } + } + + fun setLibrary( + allMusic: List, + albums: List, + love: List, + recent: List = emptyList(), + menuList: List, + musicCover: (Music) -> String? = { it.coverPath }, + albumCover: (Album) -> String? = { it.coverPath }, + ) { + groups = defaultGroups() + val songItems = allMusic.mapNotNull { music -> + val id = music.musicId ?: return@mapNotNull null + if (id.isBlank()) return@mapNotNull null + music.toItem(musicCover(music)) + } + allSongIds = songItems.map { it.musicId } + songsByGroup = allMusic + .filter { !it.musicId.isNullOrBlank() } + .groupBy { it.group.orEmpty() } + .mapValues { (_, list) -> list.map { it.toItem(musicCover(it)) } } + albumsByGroup = albums + .filter { !it.albumId.isNullOrBlank() } + .groupBy { it.group.orEmpty() } + .mapValues { (_, list) -> + list.map { + CarPlayAlbumItem( + albumId = it.albumId!!, + title = it.albumName.orEmpty(), + date = it.date, + group = it.group, + coverPath = albumCover(it), + ) + } + } + songsByAlbum = allMusic + .filter { !it.albumId.isNullOrBlank() && !it.musicId.isNullOrBlank() } + .groupBy { it.albumId!! } + .mapValues { (_, list) -> list.map { it.toItem(musicCover(it)) } } + loveSongs = love.map { it.toItem(musicCover(it)) } + recentSongs = recent.map { it.toItem(musicCover(it)) } + val musicById = allMusic.associateBy { it.musicId } + menus = menuList.map { menu -> + val first = menu.music.firstOrNull()?.let { musicById[it] } + CarPlayMenuItem( + menuId = menu.id, + name = menu.name, + count = menu.music.size, + isPhone = menu.isPhone, + coverPath = first?.let { musicCover(it) }, + ) + } + songsByMenu = menuList.associate { menu -> + menu.id.toString() to menu.music.mapNotNull { id -> + val music = musicById[id] ?: return@mapNotNull null + music.toItem(musicCover(music)) + } + } + } + + fun snapshot(): CarPlaySnapshot = CarPlaySnapshot( + nowPlayingTitle = nowPlayingTitle, + nowPlayingCover = nowPlayingCover, + nowPlayingId = nowPlayingId, + isPlaying = isPlaying, + isFavorite = isFavorite, + loopMode = loopMode, + groups = groups, + songsByGroup = songsByGroup, + albumsByGroup = albumsByGroup, + songsByAlbum = songsByAlbum, + loveSongs = loveSongs, + recentSongs = recentSongs, + menus = menus, + songsByMenu = songsByMenu, + allSongIds = allSongIds, + ) + + fun songsForGroup(group: String): List = songsByGroup[group].orEmpty() + fun albumsForGroup(group: String): List = albumsByGroup[group].orEmpty() + fun songsForAlbum(albumId: String): List = songsByAlbum[albumId].orEmpty() + fun songsForMenu(menuId: Long): List = songsByMenu[menuId.toString()].orEmpty() + + fun play(item: CarPlaySongItem, queue: List) { + onPlay?.invoke(item.musicId, queue) + } + + private fun Music.toItem(coverUri: String?) = CarPlaySongItem( + musicId = musicId.orEmpty(), + title = musicName.orEmpty(), + artist = artist, + coverPath = coverUri, + ) + + companion object { + fun defaultGroups(): List = listOf( + CarPlayGroupNode(GroupKey.GROUP_US.displayName, "μ's", "ラブライブ!", "LogoUs"), + CarPlayGroupNode(GroupKey.GROUP_AQOURS.displayName, "Aqours", "ラブライブ!サンシャイン!!", "LogoAqours"), + CarPlayGroupNode( + GroupKey.GROUP_NIJIGASAKI.displayName, + "虹咲学园学园偶像同好会", + "虹ヶ咲学園スクールアイドル同好会", + "LogoNiji", + ), + CarPlayGroupNode( + GroupKey.GROUP_LIELLA.displayName, + "Liella!", + "ラブライブ!スーパースター!!", + "LogoLiella", + ), + CarPlayGroupNode( + GroupKey.GROUP_HASUNOSORA.displayName, + "莲之空女学院", + "蓮ノ空女学院スクールアイドルクラブ", + "LogoHasunosora", + ), + CarPlayGroupNode( + GroupKey.GROUP_YOHANE.displayName, + "幻日夜羽", + "幻日のヨハネ -SUNSHINE in the MIRROR-", + "LogoYohane", + ), + CarPlayGroupNode( + GroupKey.GROUP_MUSICAL.displayName, + "学园偶像音乐剧", + "スクールアイドルミュージカル", + "LogoMusical", + ), + CarPlayGroupNode( + GroupKey.GROUP_BLUEBIRD.displayName, + "BlueBird", + "イキヅライブ!LOVELIVE! BLUEBIRD", + "LogoBluebird", + ), + CarPlayGroupNode(GroupKey.GROUP_COMBINE.displayName, "其他", "u咩", "LogoCombine"), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.kt new file mode 100644 index 0000000..4c5a64a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.kt @@ -0,0 +1,40 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +/** + * 本地存储根路径与文件辅助方法。 + */ +expect class FileSystem { + /** + * 应用文件/文档根目录(以分隔符结尾)。 + * 默认音乐根路径,如 Android `…/files/`、iOS Documents。 + */ + val filesRoot: String + + /** + * 音乐/媒体根目录(以分隔符结尾)。 + * 云端 `base_url` 已以 `LoveLive/…` 开头,此处不得再追加 `LoveLive/`。 + */ + val musicRoot: String + + fun exists(absolutePath: String): Boolean + + fun ensureDir(absolutePath: String): Boolean + + fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean + + /** 创建/清空文件,供分块写入(大 flac 不能整包进内存)。 */ + fun writeBegin(absolutePath: String): Boolean + + fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean + + /** `absoluteDir` 下文件的非递归绝对路径(不含目录)。 */ + fun listFiles(absoluteDir: String): List + + /** + * Android:`getExternalFilesDirs` 得到的应用私有目录(含内部存储 + SD/USB)。 + * iOS:空列表。对齐 Flutter `SDUtils.getUsbPathList`。 + */ + fun listUsbRoots(): List +} + +expect fun createFileSystem(): FileSystem diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformHooks.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformHooks.kt similarity index 62% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformHooks.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformHooks.kt index 6e01005..079dcc7 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformHooks.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformHooks.kt @@ -1,29 +1,39 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform /** - * Platform system integrations used by AppContainer / UI. - * Android & iOS wire real implementations; NoOp is compile-time fallback only. + * AppContainer / UI 使用的平台系统集成。 + * Android 与 iOS 接入真实实现;NoOp 仅为编译期回退。 */ -/** Desktop / floating lyric overlay (Android WindowManager; iOS PiP lyric). */ +/** 桌面/悬浮歌词叠加层(Android WindowManager;iOS PiP 歌词)。 */ interface DesktopLyricController { + /** 是否允许后台自动弹出(对齐 Flutter `isAutoPip` / `isOpenPicInPic`)。 */ var enabled: Boolean + + /** + * 对齐 Flutter `DesktopLyricUtil.pipAutoOpen`。 + * Android 开启时若无悬浮窗权限会跳转设置并返回 false(调用方应回滚开关)。 + */ + fun pipAutoOpen(enable: Boolean): Boolean + + /** Android:挂上悬浮窗。iOS:后台时尝试弹出 PiP。 */ fun show() + + /** Android:卸下悬浮窗。iOS:关掉当前 PiP,不改变开关。 */ fun hide() + fun updateLines(line1: String?, line2: String?, currentLine: Int) fun setPlaying(playing: Boolean) + fun isOverlayShowing(): Boolean } -/** Home screen widget data sync. */ +/** 主屏幕小组件数据同步。 */ interface HomeWidgetController { fun update( songName: String?, artist: String?, isPlaying: Boolean, favorite: Boolean, - lyricLine1: String? = null, - lyricLine2: String? = null, - currentLine: Int = -1, coverPath: String? = null, playText: String = "Playing,Paused", ) @@ -32,7 +42,7 @@ interface HomeWidgetController { fun refresh() } -/** Deep link parser for llmp:// share URLs. */ +/** llmp:// 分享链接深链解析。 */ interface DeepLinkHandler { fun parse(url: String): DeepLinkPayload? fun handle(url: String) @@ -45,17 +55,27 @@ data class DeepLinkPayload( val data: String? = null, ) -/** USB mount / unmount monitoring. */ +/** USB 挂载/卸载监听。对齐 Flutter `usb_broadcast` / `SDUtils.setUsbMountListener`。 */ interface UsbMountMonitor { fun start() fun stop() fun listMountedVolumes(): List + fun setOnChanged(listener: (() -> Unit)?) } -/** System share (Android Intent / iOS share sheet). */ +/** 系统分享(Android Intent / iOS 分享面板)与 QQ 网页卡片分享。 */ interface ShareBridge { fun shareText(text: String, title: String? = null) fun shareUrl(url: String, title: String? = null) + + /** 直接唤起 QQ/TIM 分享网页卡片(对齐原项目 ShareSDK `SSDKContentTypes.webpage`)。 */ + fun shareToQq( + title: String, + text: String, + url: String, + imageUrl: String? = null, + imagePath: String? = null, + ) } interface MediaSessionHook { @@ -73,17 +93,23 @@ interface PictureInPictureHook { interface CarPlayHook { fun refreshCatalog() + fun refreshNowPlaying() fun bind(catalog: CarPlayCatalog) } -// --- NoOp fallbacks (last resort; not default once platform actuals register) --- +// --- NoOp 回退(最后手段;平台 actual 注册后非默认) --- object NoOpDesktopLyric : DesktopLyricController { override var enabled: Boolean = false + override fun pipAutoOpen(enable: Boolean): Boolean { + enabled = enable + return true + } override fun show() = Unit override fun hide() = Unit override fun updateLines(line1: String?, line2: String?, currentLine: Int) = Unit override fun setPlaying(playing: Boolean) = Unit + override fun isOverlayShowing(): Boolean = false } object NoOpHomeWidget : HomeWidgetController { @@ -92,9 +118,6 @@ object NoOpHomeWidget : HomeWidgetController { artist: String?, isPlaying: Boolean, favorite: Boolean, - lyricLine1: String?, - lyricLine2: String?, - currentLine: Int, coverPath: String?, playText: String, ) = Unit @@ -112,11 +135,19 @@ object NoOpUsbMount : UsbMountMonitor { override fun start() = Unit override fun stop() = Unit override fun listMountedVolumes(): List = emptyList() + override fun setOnChanged(listener: (() -> Unit)?) = Unit } object NoOpShare : ShareBridge { override fun shareText(text: String, title: String?) = Unit override fun shareUrl(url: String, title: String?) = Unit + override fun shareToQq( + title: String, + text: String, + url: String, + imageUrl: String?, + imagePath: String?, + ) = Unit } object NoOpMediaSession : MediaSessionHook { @@ -130,6 +161,7 @@ object NoOpAnalytics : AnalyticsHook { object NoOpCarPlay : CarPlayHook { override fun refreshCatalog() = Unit + override fun refreshNowPlaying() = Unit override fun bind(catalog: CarPlayCatalog) = Unit } @@ -137,7 +169,7 @@ object NoOpPiP : PictureInPictureHook { override fun enterPiP() = Unit } -/** Shared deep-link parsing (no platform deps). */ +/** 共享深链解析(无平台依赖)。 */ object DeepLinkParser { private const val PREFIX = "llmp://" @@ -159,8 +191,8 @@ object DeepLinkParser { } /** - * Singleton registry filled by platform entry (MainActivity / MainViewController). - * Defaults to NoOp until [install] is called. + * 由平台入口(MainActivity / MainViewController)填充的单例注册表。 + * 调用 `PlatformServices.install` 前默认为 NoOp。 */ object PlatformServices { var desktopLyric: DesktopLyricController = NoOpDesktopLyric @@ -201,30 +233,30 @@ object PlatformServices { } /** - * Module index for full-feature checklist. + * 全功能清单模块索引。 */ object FeatureModuleIndex { val modules: List = listOf( - "[done] OSS metadata ingest", - "[done] Library browse (songs/albums/artists/love/menu/recent)", - "[done] Playback queue + Android MediaSession service (single ExoPlayer)", - "[done] Lyric parse + scrolling UI", - "[done] Love / Menu / History + sync merge", - "[done] IP connect transfer + QR camera (Android)", - "[done] Data sync client", - "[done] Settings (theme/HTTP/sleep/update/desktop lyric)", - "[done] Drive mode", - "[done] SD/USB list + USB mount monitor", - "[done] App update check", - "[done] Home widget (Android Glance; iOS WidgetKit sources)", - "[done] Desktop / floating lyric (Android overlay; iOS PiP sources)", - "[done] CarPlay catalog model + iOS SceneDelegate sources", - "[done] Share bridge + deep link llmp://", - "[done] Moegirl / Tachie WebView", - "[done] Daily push text", - "[done] Module index / logs", - "[done] Permissions screen", - "[done] Umeng analytics bridge (privacy-gated)", - "[done] iOS AVPlayer + FileSystem.writeBytes", + "[完成] OSS 元数据导入", + "[完成] 曲库浏览(歌曲/专辑/艺人/收藏/歌单/最近)", + "[完成] 播放队列 + Android MediaSession 服务(单一 ExoPlayer)", + "[完成] 歌词解析 + 滚动 UI", + "[完成] 收藏/歌单/历史 + 同步合并", + "[完成] IP 连接传输 + 扫码(Android)", + "[完成] 数据同步客户端", + "[完成] 设置(主题/HTTP/定时/更新/桌面歌词)", + "[完成] 驾驶模式", + "[完成] SD/USB 列表 + USB 挂载监听", + "[完成] 应用更新检查", + "[完成] 主屏小组件(Android Glance;iOS WidgetKit 源码)", + "[完成] 桌面/悬浮歌词(Android 叠加层;iOS PiP 源码)", + "[完成] CarPlay 目录模型 + iOS SceneDelegate 源码", + "[完成] 分享桥接 + 深链 llmp://", + "[完成] 萌娘百科/立绘 WebView", + "[完成] 每日推送文本", + "[完成] 模块索引/日志", + "[完成] 权限页面", + "[完成] 友盟统计桥接(隐私门控)", + "[完成] iOS AVPlayer + FileSystem.writeBytes", ) } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt new file mode 100644 index 0000000..39a61f7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.kt @@ -0,0 +1,22 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +/** + * 面向 UI 的平台操作(扫描/webview/叠加层/toast/选图)。 + */ +expect object PlatformUi { + fun openQrScanner(onResult: (String) -> Unit) + fun openWebView(url: String, title: String? = null) + fun requestOverlayPermission() + fun moveTaskToBack() + fun showToast(message: String) + fun openExternalUrl(url: String) + fun exitApp() + /** 传输/同步页对齐 Flutter `WakelockPlus`,禁止自动息屏。 */ + fun setKeepScreenOn(enabled: Boolean) +} + +/** 强制按 LTR 显示,避免宿主窗口/抽屉 RTL 把中文标点甩到句首。 */ +internal fun String.asLtrDisplay(): String { + if (isEmpty()) return this + return "\u202A$this\u202C" +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt new file mode 100644 index 0000000..57db617 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/WebViewAdBlock.kt @@ -0,0 +1,235 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * 应用内 WebView 广告过滤。 + * Android:拦截请求 + 页面脚本;iOS:WKContentRuleList + UserScript。 + */ +object WebViewAdBlock { + val blockedHostSuffixes: List = listOf( + "doubleclick.net", + "googlesyndication.com", + "googleadservices.com", + "google-analytics.com", + "googletagmanager.com", + "googletagservices.com", + "adservice.google.com", + "pagead2.googlesyndication.com", + "ads.youtube.com", + "cpro.baidu.com", + "pos.baidu.com", + "hm.baidu.com", + "tanx.com", + "alimama.com", + "mmstat.com", + "cnzz.com", + "irs01.com", + "irs01.net", + "admaster.com.cn", + "gdt.qq.com", + "e.qq.com", + "ad.qq.com", + "adsrvr.org", + "adnxs.com", + "criteo.com", + "criteo.net", + "taboola.com", + "outbrain.com", + "media.net", + "a-mo.net", + "moatads.com", + "scorecardresearch.com", + "quantserve.com", + "adform.net", + "adform.com", + "rubiconproject.com", + "pubmatic.com", + "openx.net", + "casalemedia.com", + "contextweb.com", + "lijit.com", + "amazon-adsystem.com", + "fundingchoicesmessages.google.com", + "fondrea.com", + "fundeary.com", + "grfz.laogu.wang", + "laogu.wang", + "intellitxt.com", + "exoclick.com", + "juicyads.com", + "propellerads.com", + "popads.net", + "mgid.com", + "revcontent.com", + "zergnet.com", + "yieldmo.com", + "sharethrough.com", + "spotxchange.com", + "teads.tv", + "smartadserver.com", + "serving-sys.com", + "2mdn.net", + "inmobi.com", + "chartbeat.com", + "hotjar.com", + "clarity.ms", + ) + + private val cssHideSelectors: List = listOf( + "ins.adsbygoogle", + ".adsbygoogle", + ".google-auto-placed", + "[id*='google_ads']", + "[class*='google-ad']", + "[data-ad-slot]", + "[data-ad-client]", + "iframe[src*='doubleclick']", + "iframe[src*='googlesyndication']", + "iframe[src*='adservice']", + "iframe[src*='fondrea']", + "iframe[src*='fundeary']", + ".fc-ab-root", + ) + + fun isMoegirlUrl(url: String): Boolean = + url.contains("moegirl.org", ignoreCase = true) + + fun isBlockedHost(host: String): Boolean { + val normalized = host.trim().lowercase().removePrefix("www.") + if (normalized.isEmpty()) return false + return blockedHostSuffixes.any { suffix -> + normalized == suffix || normalized.endsWith(".$suffix") + } + } + + fun injectionScript(url: String): String = + if (isMoegirlUrl(url)) moegirlCleanupScript() else cssHideScript() + + /** + * 萌百广告清理:装一次后每秒自跑。 + * 不要在 Kotlin raw string 里写 `\\.`,会变成 JS 里的字面反斜杠。 + */ + fun moegirlCleanupScript(): String = """ + (function() { + if ((location.host || '').indexOf('moegirl.org') === -1) return; + if (window.__llmpAdblock) { + window.__llmpAdblock(); + return; + } + + var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions']; + + function compact(text) { + return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase(); + } + + function isBadge(el) { + var text = (el.innerText || el.textContent || '').trim(); + return text.length <= 16 && KEYWORDS.indexOf(compact(text)) >= 0; + } + + function isStop(node) { + if (!node || node === document.body || node === document.documentElement) return true; + var id = node.id || ''; + return id === 'moe-article' || id === 'moe-main-container' || id === 'app' || id === 'content'; + } + + function pickRoot(badge) { + var last = null; + for (var node = badge; node && !isStop(node); node = node.parentElement) { + if (node.classList && node.classList.contains('mw-parser-output')) break; + if (node.classList && ( + node.classList.contains('moe-card') || + node.classList.contains('fc-ab-root') || + node.classList.contains('adsbygoogle') + )) return node; + var style = getComputedStyle(node); + var box = node.getBoundingClientRect(); + if ((style.position === 'fixed' || style.position === 'sticky') && + box.height >= 36 && box.height < innerHeight * 0.6 && + box.width >= innerWidth * 0.35) return node; + if (node.querySelector && node.querySelector('img,iframe,video,canvas') && + box.height >= 64 && box.height <= innerHeight * 0.55 && box.width >= 180) { + last = node; + } + } + return last; + } + + function run() { + try { + document.querySelectorAll('.adsbygoogle,ins.adsbygoogle,.fc-ab-root,#mw-data-after-content') + .forEach(function(node) { node.remove(); }); + document.querySelectorAll('a,span,div,small,label,em,i,button').forEach(function(el) { + if (!isBadge(el)) return; + var root = pickRoot(el); + if (root && !root.closest('#footer-moegirl,footer.mw-footer')) root.remove(); + }); + } catch (e) {} + } + + window.__llmpAdblock = run; + run(); + setInterval(run, 1000); + })(); + """.trimIndent() + + fun cssHideScript(): String { + val selectors = cssHideSelectors.joinToString(",") { it.replace("'", "\\'") } + return """ + (function() { + if (window.__llmpCssAdblock) return; + window.__llmpCssAdblock = true; + var selectors = '$selectors'; + function hide() { + try { + selectors.split(',').forEach(function(sel) { + document.querySelectorAll(sel).forEach(function(node) { + node.style.setProperty('display', 'none', 'important'); + }); + }); + } catch (e) {} + } + hide(); + setInterval(hide, 1000); + })(); + """.trimIndent() + } + + fun buildContentBlockerJson(): String { + val rules = blockedHostSuffixes.map { host -> + ContentBlockerRule( + trigger = ContentBlockerTrigger(urlFilter = ".*$host"), + action = ContentBlockerAction(type = "block"), + ) + } + ContentBlockerRule( + trigger = ContentBlockerTrigger(urlFilter = ".*"), + action = ContentBlockerAction( + type = "css-display-none", + selector = cssHideSelectors.joinToString(", "), + ), + ) + return Json.encodeToString(rules) + } +} + +@Serializable +private data class ContentBlockerRule( + val trigger: ContentBlockerTrigger, + val action: ContentBlockerAction, +) + +@Serializable +private data class ContentBlockerTrigger( + @SerialName("url-filter") val urlFilter: String, +) + +@Serializable +private data class ContentBlockerAction( + val type: String, + val selector: String? = null, +) diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/LoopMode.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/LoopMode.kt similarity index 54% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/LoopMode.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/LoopMode.kt index 7dd4149..f73b43e 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/LoopMode.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/LoopMode.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player enum class LoopMode { LIST, diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.kt new file mode 100644 index 0000000..8119cfe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.kt @@ -0,0 +1,54 @@ +package com.zhushenwudi.lovelivemusicplayer.player + +import kotlinx.coroutines.flow.StateFlow +import com.zhushenwudi.lovelivemusicplayer.domain.Music + +/** + * 平台播放器抽象,UI 仅依赖此接口。 + */ +expect class PlayerController { + val state: StateFlow + + /** + * 从曲库 [com.zhushenwudi.lovelivemusicplayer.db.Music] 解析可播放 URI(相对 musicPath + baseUrl)。 + * 不得修改 `Music.musicPath` — 歌词需要相对路径。 + */ + var resolvePlaybackUri: ((Music) -> String?)? + + /** + * 解析媒体通知 / Now Playing 封面 URI/路径。 + * Android:file:// 或 https:// URI。iOS:优先本地绝对路径。 + */ + var resolveCoverUri: ((Music) -> String?)? + + fun setQueue(items: List, startIndex: Int = 0) + fun play() + fun pause() + fun toggle() + fun seekTo(positionMs: Long) + fun next() + fun previous() + /** 跳转到队列指定索引,已在播放时保持播放状态。 */ + fun playAtIndex(index: Int) + fun setLoopMode(mode: LoopMode) + + /** 移除单曲,尽可能不重建整个引擎队列。 */ + fun removeAt(index: Int) + + /** 同步收藏状态到播放队列与当前曲目(小组件/应用内收藏需一致)。 */ + fun syncLoveState(musicId: String, isLove: Boolean) + + /** + * 将当前歌词行写入系统 Now Playing 标题(CarPlay / 锁屏 / 控制中心)。 + * 传 null 或空白则恢复歌名。 + */ + fun setNowPlayingLyric(line: String?) + + /** 清空播放队列与播放状态。 */ + fun clearQueue() + + /** 播放本地文件路径或 content URI 字符串。 */ + fun playUri(uri: String, title: String? = null) + + fun release() +} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerState.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerState.kt similarity index 73% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerState.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerState.kt index 2d17615..d207a88 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerState.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerState.kt @@ -1,6 +1,6 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player -import top.zhushenwudi.llmp.domain.Music +import com.zhushenwudi.lovelivemusicplayer.domain.Music data class PlayerState( val current: Music? = null, diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/SleepTimer.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt similarity index 95% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/SleepTimer.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt index 5ab0305..237723e 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/SleepTimer.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/SleepTimer.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.player +package com.zhushenwudi.lovelivemusicplayer.player import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/Handshake.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/Handshake.kt similarity index 80% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/Handshake.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/Handshake.kt index 81f0083..bae8025 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/Handshake.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/Handshake.kt @@ -1,13 +1,13 @@ -package top.zhushenwudi.llmp.protocol +package com.zhushenwudi.lovelivemusicplayer.protocol -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.FtpCmd +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd /** - * Pure handshake logic for `transVer` negotiation. + * `transVer` 协商的纯握手逻辑。 * - * Mobile initiates with `{cmd:"version", body:""}`; - * peer replies with the same shape. Mismatch → disconnect (no silent continue). + * 移动端发起 `{cmd:"version", body:""}`; + * 对端同形回复。不匹配 → 断开(不静默继续)。 */ object Handshake { data class Result( diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/ProtocolCmd.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/ProtocolCmd.kt similarity index 76% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/ProtocolCmd.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/ProtocolCmd.kt index aab109c..0b4664f 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/protocol/ProtocolCmd.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/ProtocolCmd.kt @@ -1,7 +1,7 @@ -package top.zhushenwudi.llmp.protocol +package com.zhushenwudi.lovelivemusicplayer.protocol /** - * WiFi transfer / sync command words from Docs/03. + * WiFi 传输/同步命令字(来自 Docs/03)。 */ enum class ProtocolCmd(val wire: String) { VERSION("version"), @@ -28,9 +28,9 @@ enum class ProtocolCmd(val wire: String) { } enum class TransferChannel { - /** Port 4388 – song file transfer. */ + /** 端口 4388 — 歌曲文件传输。 */ MUSIC, - /** Port 4389 – love/menu data sync. */ + /** 端口 4389 — 收藏/歌单数据同步。 */ DATA, } diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/AppSettings.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/AppSettings.kt similarity index 70% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/AppSettings.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/AppSettings.kt index 85904a6..d55085e 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/AppSettings.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/AppSettings.kt @@ -1,18 +1,14 @@ -package top.zhushenwudi.llmp.settings +package com.zhushenwudi.lovelivemusicplayer.settings import com.russhwolf.settings.Settings import com.russhwolf.settings.set -import top.zhushenwudi.llmp.player.LoopMode +import com.zhushenwudi.lovelivemusicplayer.player.LoopMode class AppSettings(private val settings: Settings) { var isDark: Boolean get() = settings.getBoolean(SettingsKeys.DARK, false) set(value) = settings.set(SettingsKeys.DARK, value) - var isColorful: Boolean - get() = settings.getBoolean(SettingsKeys.COLORFUL, false) - set(value) = settings.set(SettingsKeys.COLORFUL, value) - var followSystemTheme: Boolean get() = settings.getBoolean(SettingsKeys.WITH_SYSTEM_THEME, true) set(value) = settings.set(SettingsKeys.WITH_SYSTEM_THEME, value) @@ -44,10 +40,23 @@ class AppSettings(private val settings: Settings) { get() = settings.getBoolean(SettingsKeys.AI_PICTURE, true) set(value) = settings.set(SettingsKeys.AI_PICTURE, value) + /** 歌曲页正倒序(兼容旧版全局 `SP_SORT_ORDER`)。 */ var sortOrder: String get() = settings.getString(SettingsKeys.SORT_ORDER, "ASC") set(value) = settings.set(SettingsKeys.SORT_ORDER, value) + var albumsSortOrder: String + get() = settings.getString(SettingsKeys.SORT_ORDER_ALBUMS, "ASC") + set(value) = settings.set(SettingsKeys.SORT_ORDER_ALBUMS, value) + + var loveSortOrder: String + get() = settings.getString(SettingsKeys.SORT_ORDER_LOVE, "ASC") + set(value) = settings.set(SettingsKeys.SORT_ORDER_LOVE, value) + + var recentSortOrder: String + get() = settings.getString(SettingsKeys.SORT_ORDER_RECENT, "ASC") + set(value) = settings.set(SettingsKeys.SORT_ORDER_RECENT, value) + var sdPath: String get() = settings.getString(SettingsKeys.SD_PATH, "") set(value) = settings.set(SettingsKeys.SD_PATH, value) @@ -55,12 +64,8 @@ class AppSettings(private val settings: Settings) { var lastPcHost: String get() = settings.getString("SP_LAST_PC_HOST", "") set(value) = settings.set("SP_LAST_PC_HOST", value) - - var enableBackground: Boolean - get() = settings.getBoolean(SettingsKeys.ENABLE_BG, false) - set(value) = settings.set(SettingsKeys.ENABLE_BG, value) - - var backgroundPhoto: String - get() = settings.getString(SettingsKeys.BG_PHOTO, "") - set(value) = settings.set(SettingsKeys.BG_PHOTO, value) } + +/** 规范列表按页独立套用正倒序。 */ +fun List.withSortOrder(order: String): List = + if (order == "DESC") reversed() else this diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.kt similarity index 59% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.kt index a05afe0..a73f6e7 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.settings +package com.zhushenwudi.lovelivemusicplayer.settings import com.russhwolf.settings.Settings diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsKeys.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsKeys.kt similarity index 68% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsKeys.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsKeys.kt index cac5c53..05d291f 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/settings/SettingsKeys.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsKeys.kt @@ -1,8 +1,7 @@ -package top.zhushenwudi.llmp.settings +package com.zhushenwudi.lovelivemusicplayer.settings object SettingsKeys { const val DARK = "SP_IS_DARK" - const val COLORFUL = "SP_IS_COLORFUL" const val WITH_SYSTEM_THEME = "SP_With_System_Theme" const val LOOP_MODE = "SP_LOOP_MODE" const val DATA_VERSION = "SP_DATA_VERSION" @@ -11,7 +10,8 @@ object SettingsKeys { const val ENABLE_DESKTOP_LYRIC = "SP_OPEN_DESKTOP_LYRIC" const val AI_PICTURE = "SP_AI_PICTURE" const val SORT_ORDER = "SP_SORT_ORDER" + const val SORT_ORDER_ALBUMS = "SP_SORT_ORDER_ALBUMS" + const val SORT_ORDER_LOVE = "SP_SORT_ORDER_LOVE" + const val SORT_ORDER_RECENT = "SP_SORT_ORDER_RECENT" const val SD_PATH = "SP_SD_PATH" - const val ENABLE_BG = "SP_ENABLE_BACKGROUND" - const val BG_PHOTO = "SP_BACKGROUND_PHOTO" } diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/splash/SplashPhotoService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/splash/SplashPhotoService.kt similarity index 80% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/splash/SplashPhotoService.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/splash/SplashPhotoService.kt index 8063b3b..f0268d2 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/splash/SplashPhotoService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/splash/SplashPhotoService.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.splash +package com.zhushenwudi.lovelivemusicplayer.splash import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,18 +11,18 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.db.currentTimeMs -import top.zhushenwudi.llmp.network.OssApi -import top.zhushenwudi.llmp.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.db.currentTimeMs +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem import kotlin.random.Random /** - * Flutter `SplashPhoto` / `SDUtils` splash cache. - * Cache dir = `{filesRoot}splash/` (NOT under LoveLive), matching Flutter SDUtils.path + splash/. + * 启动图缓存服务。 + * 缓存目录 = `{filesRoot}splash/`(不在 LoveLive 子目录下)。 * - * Splash UI only reads local cache; config fetch + missing downloads run after splash - * via [syncInBackground]. + * 启动页 UI 仅读本地缓存;配置拉取与缺失下载在启动页之后 + * 通过 `syncInBackground` 执行。 */ class SplashPhotoService( private val fs: FileSystem, @@ -35,8 +35,8 @@ class SplashPhotoService( get() = fs.filesRoot.trimEnd('/', '\\') + "/splash/" /** - * Random local cached splash path, or null if cache is empty. - * Does **not** hit the network. + * 随机本地缓存启动图路径,缓存为空时返回 null。 + * **不**访问网络。 */ suspend fun pickCachedDisplayPath(): String? = withContext(Dispatchers.Default) { fs.ensureDir(splashDir) @@ -44,7 +44,7 @@ class SplashPhotoService( if (cached.isEmpty()) null else cached[Random.nextInt(cached.size)] } - /** Fetch splash config and download missing images after the splash screen. */ + /** 启动页结束后拉取配置并下载缺失图片。 */ fun syncInBackground() { scope.launch(Dispatchers.Default) { runCatching { syncFromNetwork() } @@ -55,7 +55,7 @@ class SplashPhotoService( fs.ensureDir(splashDir) val text = oss.fetchText(Const.SPLASH_CONFIG_URL) val config = json.decodeFromString(text) - // forceChoose only affects next cold start once the file is already cached + // forceChoose 仅在文件已缓存后影响下次冷启动 enqueueMissingDownloads(config) } @@ -67,7 +67,7 @@ class SplashPhotoService( } private fun localPathFor(singer: String, index: Int): String = - // Flat cache names match Flutter download: splash/bg_{singer}_{index}.png + // 扁平缓存文件名:splash/bg_{singer}_{index}.png splashDir + "bg_${singer}_$index.png" private suspend fun enqueueMissingDownloads(config: SplashConfig) { @@ -81,7 +81,7 @@ class SplashPhotoService( } } } - // Also ensure forceChoose target is present when still active + // forceChoose 仍生效时确保目标文件存在 config.forceChoose?.let { force -> val endTime = (force["endTime"] as? JsonPrimitive)?.longOrNull if (endTime != null && endTime >= currentTimeMs()) { diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/DataSyncService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt similarity index 72% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/DataSyncService.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt index 8030669..7c95228 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/DataSyncService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/DataSyncService.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.transfer +package com.zhushenwudi.lovelivemusicplayer.transfer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -10,22 +10,23 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.db.LibraryRepository -import top.zhushenwudi.llmp.domain.FtpCmd -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.TransData -import top.zhushenwudi.llmp.domain.TransMenu -import top.zhushenwudi.llmp.network.WsClient -import top.zhushenwudi.llmp.protocol.Handshake -import top.zhushenwudi.llmp.protocol.ProtocolCmd -import top.zhushenwudi.llmp.protocol.TransferChannel +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository +import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd +import com.zhushenwudi.lovelivemusicplayer.domain.Menu +import com.zhushenwudi.lovelivemusicplayer.domain.TransData +import com.zhushenwudi.lovelivemusicplayer.domain.TransMenu +import com.zhushenwudi.lovelivemusicplayer.network.WsClient +import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake +import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd +import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel data class SyncUiState( val host: String = "", val connected: Boolean = false, val verified: Boolean = false, - val status: String = "未连接", + val status: String = I18n.t("connected"), val error: String? = null, ) @@ -41,13 +42,13 @@ class DataSyncService( fun connect(host: String) { job?.cancel() - _state.value = SyncUiState(host = host, status = "连接中…") + _state.value = SyncUiState(host = host, status = I18n.t("connecting")) job = scope.launch { if (!ws.connect(host, TransferChannel.DATA)) { - _state.update { it.copy(error = "连接失败", status = "失败") } + _state.update { it.copy(error = I18n.t("connect_fail"), status = I18n.t("failed")) } return@launch } - _state.update { it.copy(connected = true, status = "握手中…") } + _state.update { it.copy(connected = true, status = I18n.t("handshaking")) } ws.sendVersion() ws.incoming().collect { handle(it) } } @@ -56,7 +57,7 @@ class DataSyncService( fun pushPhoneToPc(isCover: Boolean) { scope.launch { if (!_state.value.verified) { - _state.update { it.copy(error = "未完成握手") } + _state.update { it.copy(error = I18n.t("handshake_incomplete")) } return@launch } val menus = library.getAllMenus() @@ -75,7 +76,7 @@ class DataSyncService( isCover = isCover, ) ws.send(FtpCmd(ProtocolCmd.PHONE2PC.wire, json.encodeToString(data))) - _state.update { it.copy(status = "已发送 phone2pc") } + _state.update { it.copy(status = I18n.t("sent_phone2pc")) } } } @@ -83,14 +84,14 @@ class DataSyncService( scope.launch { val data = TransData(love = library.getAllLove(), menu = emptyList(), isCover = false) ws.send(FtpCmd(ProtocolCmd.PC2PHONE.wire, json.encodeToString(data))) - _state.update { it.copy(status = "已请求 pc2phone") } + _state.update { it.copy(status = I18n.t("requested_pc2phone")) } } } fun stop() { scope.launch { ws.close() } job?.cancel() - _state.update { it.copy(connected = false, verified = false, status = "已断开") } + _state.update { it.copy(connected = false, verified = false, status = I18n.t("disconnected")) } } private suspend fun handle(cmd: FtpCmd) { @@ -98,17 +99,17 @@ class DataSyncService( ProtocolCmd.VERSION -> { val result = Handshake.verify(cmd, Const.TRANS_VER) if (!result.ok) { - _state.update { it.copy(error = result.reason, status = "版本不匹配") } + _state.update { it.copy(error = result.reason, status = I18n.t("version_mismatch")) } ws.close() return } - _state.update { it.copy(verified = true, status = "已验证") } + _state.update { it.copy(verified = true, status = I18n.t("verified")) } ws.send(FtpCmd(ProtocolCmd.CONNECTED.wire, "")) } ProtocolCmd.PHONE2PC -> { val data = json.decodeFromString(cmd.body) library.replaceLove(data.love) - _state.update { it.copy(status = "已应用 PC 侧喜欢列表") } + _state.update { it.copy(status = I18n.t("applied_pc_love")) } ws.close() } ProtocolCmd.PC2PHONE -> { @@ -127,10 +128,10 @@ class DataSyncService( ), ) } - _state.update { it.copy(status = "已应用 PC 歌单/喜欢") } + _state.update { it.copy(status = I18n.t("applied_pc_menus")) } } ProtocolCmd.BACK, ProtocolCmd.STOP, ProtocolCmd.FINISH -> { - _state.update { it.copy(status = "会话结束") } + _state.update { it.copy(status = I18n.t("session_end")) } ws.close() } else -> Unit diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/MusicTransferService.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt similarity index 58% rename from shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/MusicTransferService.kt rename to shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt index 1c8bd31..9b22e96 100644 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/transfer/MusicTransferService.kt +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/transfer/MusicTransferService.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.transfer +package com.zhushenwudi.lovelivemusicplayer.transfer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -7,37 +7,43 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import io.ktor.http.encodeURLPathPart import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.db.LibraryRepository -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.DownloadMusic -import top.zhushenwudi.llmp.domain.FtpCmd -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.network.OssApi -import top.zhushenwudi.llmp.network.WsClient -import top.zhushenwudi.llmp.platform.FileSystem -import top.zhushenwudi.llmp.protocol.Handshake -import top.zhushenwudi.llmp.protocol.ProtocolCmd -import top.zhushenwudi.llmp.protocol.TransferChannel -import top.zhushenwudi.llmp.util.PathResolver +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.i18n.I18n +import com.zhushenwudi.lovelivemusicplayer.db.LibraryRepository +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.DownloadMusic +import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.network.OssApi +import com.zhushenwudi.lovelivemusicplayer.network.WsClient +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.protocol.Handshake +import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd +import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel +import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils +import com.zhushenwudi.lovelivemusicplayer.util.PathResolver data class TransferUiState( val host: String = "", val connected: Boolean = false, val verified: Boolean = false, val httpPort: String = "", - val status: String = "未连接", + val status: String = I18n.t("connected"), val current: String = "", + val artist: String = "", + val coverUrl: String = "", val progress: Int = 0, val total: Int = 0, val error: String? = null, + val completed: Boolean = false, ) /** - * PC → phone WiFi transfer client (WS 4388 + HTTP files). + * PC → 手机 WiFi 传输客户端(WS 4388 + HTTP 文件)。 */ class MusicTransferService( private val ws: WsClient, @@ -46,6 +52,7 @@ class MusicTransferService( private val fs: FileSystem, private val paths: PathResolver, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default), + private val onImported: () -> Unit = {}, ) { private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val _state = MutableStateFlow(TransferUiState()) @@ -59,17 +66,38 @@ class MusicTransferService( systemBody = system } + /** 再次打开快传页时清掉上次完成标记,避免一进页就自动返回。 */ + fun resetForOpen() { + if (_state.value.connected) return + _state.value = TransferUiState() + } + fun connect(host: String) { job?.cancel() - _state.value = TransferUiState(host = host, status = "连接中…") + _state.value = TransferUiState(host = host, status = I18n.t("connecting")) job = scope.launch { if (!ws.connect(host, TransferChannel.MUSIC)) { - _state.update { it.copy(status = "连接失败", error = "无法连接 $host:${Const.WS_MUSIC_PORT}") } + _state.update { + it.copy( + status = I18n.t("connect_fail"), + error = I18n.t("cannot_connect_host", "$host:${Const.WS_MUSIC_PORT}"), + ) + } return@launch } - _state.update { it.copy(connected = true, status = "握手中…") } + _state.update { it.copy(connected = true, status = I18n.t("handshaking")) } ws.sendVersion() - ws.incoming().collect { cmd -> handle(cmd) } + try { + ws.incoming().collect { cmd -> handle(cmd) } + } finally { + _state.update { + it.copy( + connected = false, + verified = false, + status = if (it.completed) it.status else I18n.t("disconnected"), + ) + } + } } } @@ -77,7 +105,7 @@ class MusicTransferService( scope.launch { runCatching { ws.send(FtpCmd(ProtocolCmd.FINISH.wire, "")) } ws.close() - _state.update { it.copy(connected = false, verified = false, status = "已断开") } + _state.update { it.copy(connected = false, verified = false, status = I18n.t("disconnected")) } } job?.cancel() job = null @@ -89,16 +117,16 @@ class MusicTransferService( val result = Handshake.verify(cmd, Const.TRANS_VER) if (!result.ok) { _state.update { - it.copy(error = result.reason, status = "版本不匹配", verified = false) + it.copy(error = result.reason, status = I18n.t("version_mismatch"), verified = false) } ws.close() return } - _state.update { it.copy(verified = true, status = "已验证") } + _state.update { it.copy(verified = true, status = I18n.t("verified")) } ws.send(FtpCmd(ProtocolCmd.SYSTEM.wire, systemBody)) } ProtocolCmd.PORT -> { - _state.update { it.copy(httpPort = cmd.body, status = "HTTP 端口 ${cmd.body}") } + _state.update { it.copy(httpPort = cmd.body, status = I18n.t("http_port", cmd.body)) } } ProtocolCmd.PREPARE -> { val parts = cmd.body.split(" === ") @@ -111,14 +139,15 @@ class MusicTransferService( all } else { all.filter { item -> + val localPath = AudioPathUtils.flac2wav(item.musicPath).orEmpty() val dest = paths.storageRoot() + item.baseUrl.trimStart('/') + - item.musicPath.trimStart('/') + localPath.trimStart('/') !fs.exists(dest) } } pending = need _state.update { - it.copy(total = need.size, progress = 0, status = "待传 ${need.size} 首") + it.copy(total = need.size, progress = 0, status = I18n.t("pending_n", need.size)) } val ids = json.encodeToString(need.map { it.musicUId }) ws.send(FtpCmd(ProtocolCmd.MUSIC_LIST.wire, ids)) @@ -128,7 +157,7 @@ class MusicTransferService( json.decodeFromString>(cmd.body) }.getOrDefault(emptyList()) _state.update { - it.copy(total = pending.size, progress = 0, status = "就绪 ${pending.size}") + it.copy(total = pending.size, progress = 0, status = I18n.t("ready_n", pending.size)) } } ProtocolCmd.DOWNLOAD -> { @@ -143,7 +172,9 @@ class MusicTransferService( _state.update { it.copy( current = item.musicName, - status = "下载中 ${item.musicName}", + artist = item.artist, + coverUrl = coverHttpUrl(item), + status = I18n.t("downloading_song", item.musicName), ) } val ok = downloadOne(item) @@ -158,11 +189,29 @@ class MusicTransferService( } if (isLast) { ws.send(FtpCmd(ProtocolCmd.FINISH.wire, "")) - _state.update { it.copy(status = "传输完成") } + if (ok) { + _state.update { + it.copy( + status = I18n.t("transfer_done"), + completed = true, + connected = false, + ) + } + } else { + _state.update { + it.copy( + status = I18n.t("failed"), + error = I18n.t("failed") + " " + item.musicName, + completed = false, + connected = false, + ) + } + } + ws.close() } } ProtocolCmd.STOP, ProtocolCmd.BACK -> { - _state.update { it.copy(status = "对方中断") } + _state.update { it.copy(status = I18n.t("peer_abort"), connected = false) } ws.close() } else -> Unit @@ -170,24 +219,37 @@ class MusicTransferService( } private suspend fun downloadOne(item: DownloadMusic): Boolean { - val host = _state.value.host - val port = _state.value.httpPort.ifBlank { "10000" } - val baseHttp = "http://$host:$port/" - fun url(rel: String) = baseHttp + (item.baseUrl + rel).trimStart('/') - val root = paths.storageRoot() val coverDest = root + item.baseUrl.trimStart('/') + item.coverPath.trimStart('/') - val musicDest = root + item.baseUrl.trimStart('/') + item.musicPath.trimStart('/') + val musicRel = AudioPathUtils.flac2wav(item.musicPath).orEmpty() + val musicDest = root + item.baseUrl.trimStart('/') + musicRel.trimStart('/') return try { if (item.coverPath.isNotBlank()) { - fs.writeBytes(coverDest, http.downloadBytes(url(item.coverPath))) + runCatching { + fs.writeBytes(coverDest, http.downloadBytes(fileHttpUrl(item.baseUrl, item.coverPath))) + if (fs.exists(coverDest)) { + _state.update { it.copy(coverUrl = "file://$coverDest") } + } + } } - fs.writeBytes(musicDest, http.downloadBytes(url(item.musicPath))) + http.downloadToFile(fileHttpUrl(item.baseUrl, musicRel), musicDest, fs) } catch (_: Throwable) { false } } + private fun fileHttpUrl(vararg relativeParts: String): String { + val host = _state.value.host + val port = _state.value.httpPort.ifBlank { "10000" } + val encoded = relativeParts.joinToString("").trimStart('/') + .split('/').filter { it.isNotEmpty() } + .joinToString("/") { it.encodeURLPathPart() } + return "http://$host:$port/$encoded" + } + + private fun coverHttpUrl(item: DownloadMusic): String = + fileHttpUrl(item.baseUrl, item.coverPath) + private fun importOne(item: DownloadMusic) { library.upsertAlbum( Album( @@ -209,7 +271,7 @@ class MusicTransferService( albumId = item.albumUId, albumName = item.albumName, coverPath = item.coverPath, - musicPath = item.musicPath, + musicPath = AudioPathUtils.flac2wav(item.musicPath), time = item.totalTime, baseUrl = item.baseUrl, category = item.category, @@ -219,5 +281,6 @@ class MusicTransferService( existFile = true, ), ) + onImported() } } diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/update/UpdateChecker.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/update/UpdateChecker.kt new file mode 100644 index 0000000..669b921 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/update/UpdateChecker.kt @@ -0,0 +1,69 @@ +package com.zhushenwudi.lovelivemusicplayer.update + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.VersionInfo +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.network.OssApi + +class UpdateChecker(private val oss: OssApi) { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + suspend fun check(currentCode: Int = 1): VersionInfo? { + val raw = runCatching { oss.fetchVersionJson() }.getOrNull() ?: return null + val info = runCatching { json.decodeFromString(raw) }.getOrNull() + ?: return null + return if (info.versionCode > currentCode) info else null + } + + /** + * 对齐原 Flutter `UpdateLogic.checkUpdate` iOS 分支: + * 请求 App Store lookup,本地版本 < 商店版本则需要更新。 + */ + suspend fun checkIos(): Boolean { + val raw = oss.fetchText(Const.APPSTORE_LOOKUP_URL) + val lookup = json.decodeFromString(raw) + val remote = lookup.results.firstOrNull { it.bundleId == Const.APPSTORE_BUNDLE_ID } + ?.version + ?: return false + return isOlderVersion(PlatformInfo.appVersion, remote) + } +} + +/** `AppUtils.compareVersion(old, latest)`:old < latest 为 true。 */ +internal fun isOlderVersion(current: String, remote: String): Boolean { + var i = 0 + var j = 0 + while (i < current.length || j < remote.length) { + var num1 = 0 + while (i < current.length && current[i] != '.') { + val d = current[i] - '0' + if (d !in 0..9) break + num1 = num1 * 10 + d + i++ + } + var num2 = 0 + while (j < remote.length && remote[j] != '.') { + val d = remote[j] - '0' + if (d !in 0..9) break + num2 = num2 * 10 + d + j++ + } + if (num1 != num2) return num1 < num2 + i++ + j++ + } + return false +} + +@Serializable +private data class AppStoreLookup( + val results: List = emptyList(), +) + +@Serializable +private data class AppStoreApp( + val bundleId: String = "", + val version: String = "", +) diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/AudioPathUtils.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/AudioPathUtils.kt new file mode 100644 index 0000000..004f3ac --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/AudioPathUtils.kt @@ -0,0 +1,19 @@ +package com.zhushenwudi.lovelivemusicplayer.util + +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo + +/** + * flac 与 wav 扩展名互转。 + * iOS 本地文件存/播 `.wav`;云端/HTTP 曲库保持 `.flac`。 + */ +object AudioPathUtils { + fun flac2wav(path: String?): String? { + if (path == null) return null + return if (PlatformInfo.isIos) path.replace(".flac", ".wav") else path + } + + fun wav2flac(path: String?): String? { + if (path == null) return null + return path.replace(".wav", ".flac") + } +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt new file mode 100644 index 0000000..f1a42d7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt @@ -0,0 +1,161 @@ +package com.zhushenwudi.lovelivemusicplayer.util + +import io.ktor.http.encodeURLPathPart +import com.zhushenwudi.lovelivemusicplayer.domain.Album +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog +import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem +import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings + +class PathResolver( + private val fs: FileSystem, + private val settings: AppSettings, +) { + /** + * 存储根路径。 + * iOS Documents UUID 重装后会变,iOS 上不持久化 SP_SD_PATH; + * iOS 上始终使用 `FileSystem.musicRoot`。 + */ + fun storageRoot(): String { + if (PlatformInfo.isIos) return fs.musicRoot + val sd = settings.sdPath.trim() + if (sd.isNotEmpty()) { + return sd.trimEnd('/', '\\') + "/" + } + return fs.musicRoot + } + + fun absoluteMusicPath(music: Music): String? { + val base = music.baseUrl ?: return null + // iOS 本地路径存为 .wav;也兼容数据库中仍为 .flac 的行。 + val path = AudioPathUtils.flac2wav(music.musicPath) ?: return null + return storageRoot() + base.trimStart('/') + path.trimStart('/') + } + + /** + * 探测本地音频文件的绝对路径候选(iOS:优先 wav,回退 flac)。 + */ + fun absoluteMusicPathCandidates(music: Music): List { + val base = music.baseUrl ?: return emptyList() + val raw = music.musicPath?.trim().orEmpty() + if (raw.isEmpty()) return emptyList() + val root = storageRoot() + base.trimStart('/') + val primary = AudioPathUtils.flac2wav(raw) ?: raw + val candidates = linkedSetOf(root + primary.trimStart('/')) + if (PlatformInfo.isIos) { + val flac = AudioPathUtils.wav2flac(raw) ?: raw + if (flac != primary) { + candidates += root + flac.trimStart('/') + } + if (raw.endsWith(".flac", ignoreCase = true)) { + candidates += root + raw.trimStart('/') + } + } + return candidates.toList() + } + + fun absoluteCoverPath(music: Music): String? { + val base = music.baseUrl ?: return null + val path = music.coverPath ?: return null + return storageRoot() + base.trimStart('/') + path.trimStart('/') + } + + fun absoluteAlbumCoverPath(album: Album): String? { + val path = album.coverPath ?: return null + return storageRoot() + path.trimStart('/') + } + + /** + * 拼接 HTTP 根路径与相对路径,逐段编码。 + * 部分主机要求编码空格/Unicode(如 `μ's`、中文、`[]`)。 + */ + fun joinHttpUrl(vararg relativeParts: String): String? { + val http = settings.httpUrl.trim() + if (http.isBlank()) return null + val rel = relativeParts.joinToString("").trimStart('/') + if (rel.isBlank()) return null + val encoded = rel.split('/') + .filter { it.isNotEmpty() } + .joinToString("/") { it.encodeURLPathPart() } + return http.trimEnd('/') + "/" + encoded + } + + /** + * 歌曲封面 URI:本地 file://、HTTP URL,或 null(UI 显示默认 Logo)。 + */ + fun coverDisplayUri(music: Music): String? { + if (music.musicId == null) return null + if (music.existFile == true) { + val path = absoluteCoverPath(music) + if (path != null && fs.exists(path)) { + return "file://$path" + } + } + if (settings.enableHttp) { + return joinHttpUrl(music.baseUrl.orEmpty(), music.coverPath.orEmpty()) + } + return null + } + + /** 专辑封面 URI。 */ + fun coverDisplayUri(album: Album): String? { + val rel = album.coverPath ?: return null + if (album.existFile == true) { + val path = absoluteAlbumCoverPath(album) + if (path != null && fs.exists(path)) { + return "file://$path" + } + } + if (settings.enableHttp) { + return joinHttpUrl(rel) + } + return null + } + + fun playbackUri(music: Music): String? { + val id = music.musicId + val enableHttp = settings.enableHttp + val httpBlank = settings.httpUrl.isBlank() + if (enableHttp && !httpBlank) { + val base = music.baseUrl.orEmpty() + // HTTP 曲库为 flac;iOS 本地导入后数据库可能存 wav。 + val path = AudioPathUtils.wav2flac(music.musicPath).orEmpty() + val joined = joinHttpUrl(base, path) + if (joined == null) { + AppLog.w(PLAY_TAG, "playbackUri http joinNull id=$id") + } + return joined + } + val candidates = absoluteMusicPathCandidates(music) + val local = candidates.firstOrNull { fs.exists(it) } + if (local != null) { + return "file://$local" + } + val fallback = music.musicPath?.takeIf { + it.startsWith("http") || it.startsWith("asset") + } + if (fallback == null) { + AppLog.w( + PLAY_TAG, + "playbackUri null id=$id enableHttp=$enableHttp httpBlank=$httpBlank " + + "candidates=$candidates root=${storageRoot()}", + ) + } + return fallback + } + + fun refreshExistFile(music: Music): Music { + val exists = absoluteMusicPathCandidates(music).any { fs.exists(it) } + // 磁盘存在时,将 iOS 本地扩展名持久化到数据库(flac→wav)。 + val pathForDb = when { + !PlatformInfo.isIos -> music.musicPath + else -> AudioPathUtils.flac2wav(music.musicPath) + } + return music.copy(musicPath = pathForDb, existFile = exists) + } + + private companion object { + const val PLAY_TAG = "LLMP-Play" + } +} diff --git a/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.kt b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.kt new file mode 100644 index 0000000..9cc4200 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.kt @@ -0,0 +1,4 @@ +package com.zhushenwudi.lovelivemusicplayer.util + +/** SHA-512 hex(小写),对齐 Dart `sha512.convert(...).toString()`。 */ +expect fun sha512Hex(text: String): String diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/Const.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/Const.kt deleted file mode 100644 index c4b8a75..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/Const.kt +++ /dev/null @@ -1,45 +0,0 @@ -package top.zhushenwudi.llmp - -/** - * App-wide constants ported from Flutter `lib/global/const.dart`. - */ -object Const { - /** Dual-end transfer protocol version; must match PC Electron. */ - const val TRANS_VER: Int = 1 - - const val PACKAGE_ID: String = "top.zhushenwudi.llmp" - - // Cloudflare R2 OSS - const val R2_OSS_URL: String = "https://llmp-oss.zhushenwudi.top/" - - // AliYun OSS - const val ALI_OSS_URL: String = - "https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/" - - const val LYRIC_URL: String = "${R2_OSS_URL}lyric/" - const val SPLASH_URL: String = "${R2_OSS_URL}LLMP-M/splash_bg/" - const val SPLASH_CONFIG_URL: String = "${SPLASH_URL}splash_config.json" - const val SHARE_DEFAULT_LOGO: String = "${ALI_OSS_URL}ic_launcher.png" - const val MOE_GIRL_URL: String = "https://zh.moegirl.org.cn/" - const val PUSH_URL: String = "${ALI_OSS_URL}push/push.txt" - - const val BACKEND_URL: String = - "https://netease-backend.zhushenwudi.top/song/detail" - - /** WiFi transfer command WS port (PC). */ - const val WS_MUSIC_PORT: Int = 4388 - - /** Data sync command WS port (PC). */ - const val WS_DATA_PORT: Int = 4389 - - fun dataBaseUrl(env: String = "prod"): String = - "${R2_OSS_URL}LLMP-M/data/v2/$env/" - - fun dataUrl(env: String = "prod"): String = "${dataBaseUrl(env)}data.json" - - fun artistModelUrl(env: String = "prod"): String = - "${dataBaseUrl(env)}artist.json" - - fun updateUrl(env: String = "prod"): String = - "${dataBaseUrl(env)}version.json" -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/OssApi.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/OssApi.kt deleted file mode 100644 index 06d2f77..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/network/OssApi.kt +++ /dev/null @@ -1,65 +0,0 @@ -package top.zhushenwudi.llmp.network - -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.statement.bodyAsBytes -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpHeaders -import top.zhushenwudi.llmp.Const - -class OssApi( - private val client: HttpClient, - private val env: String = "prod", -) { - suspend fun fetchDataJson(): String = - client.get(Const.dataUrl(env)).bodyAsText() - - suspend fun fetchVersionJson(): String = - client.get(Const.updateUrl(env)).bodyAsText() - - suspend fun fetchArtistJson(): String = - client.get(Const.artistModelUrl(env)).bodyAsText() - - /** - * Flutter: `Network.getSync(Const.lyricUrl + Uri.encodeComponent(lrcUrl))` - * where [relativePath] is like `JP/$baseUrl$file.lrc`. - * - * Equivalent resolved path with per-segment encoding (avoids Ktor turning - * whole-string `%2F` into `%252F`). - */ - suspend fun fetchLyricRelativePath(relativePath: String): String { - val url = Const.LYRIC_URL.trimEnd('/') + "/" + - relativePath.split('/') - .filter { it.isNotEmpty() } - .joinToString("/") { encodeComponent(it) } - val response = client.get(url) { - header(HttpHeaders.Accept, "*/*") - } - return response.bodyAsText() - } - - suspend fun fetchLyric(url: String): String { - val response = client.get(url) { - header(HttpHeaders.Accept, "*/*") - } - return response.bodyAsText() - } - - suspend fun fetchText(url: String): String = - client.get(url).bodyAsText() - - suspend fun downloadBytes(url: String): ByteArray = - client.get(url).bodyAsBytes() - - /** Dart `Uri.encodeComponent` for one path segment. */ - private fun encodeComponent(value: String): String = buildString(value.length * 2) { - for (ch in value) { - when { - ch.isLetterOrDigit() || ch in "-_.!~*'()" -> append(ch) - else -> append('%') - .append(ch.code.toString(16).uppercase().padStart(2, '0')) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/CarPlayCatalog.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/CarPlayCatalog.kt deleted file mode 100644 index 1f739d2..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/CarPlayCatalog.kt +++ /dev/null @@ -1,137 +0,0 @@ -package top.zhushenwudi.llmp.platform - -import kotlinx.serialization.Serializable -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.GroupKey -import top.zhushenwudi.llmp.domain.Menu -import top.zhushenwudi.llmp.domain.Music - -/** - * CarPlay catalog mirroring Flutter carplay pages: - * Tab Music / Album / Mine with groups, love, menus, and play callbacks. - * - * UI is native (Swift CPTemplate); this model supplies data + handlers. - */ -@Serializable -data class CarPlayGroupNode( - val key: GroupKey, - val displayName: String, - val detail: String = "", - val logoPath: String? = null, -) - -@Serializable -data class CarPlaySongItem( - val musicId: String, - val title: String, - val artist: String? = null, - val coverPath: String? = null, -) - -@Serializable -data class CarPlayAlbumItem( - val albumId: String, - val title: String, - val group: String? = null, - val coverPath: String? = null, -) - -@Serializable -data class CarPlayMenuItem( - val menuId: Long, - val name: String, - val count: Int, -) - -class CarPlayCatalog { - /** Now-playing title for Music tab header. */ - var nowPlayingTitle: String = "正在播放" - private set - var nowPlayingCover: String? = null - private set - - var groups: List = defaultGroups() - private set - var loveSongs: List = emptyList() - private set - var menus: List = emptyList() - private set - - private var songsByGroup: Map> = emptyMap() - private var albumsByGroup: Map> = emptyMap() - private var songsByAlbum: Map> = emptyMap() - private var songsByMenu: Map> = emptyMap() - - var onPlay: ((musicId: String, queue: List) -> Unit)? = null - - fun updateNowPlaying(music: Music?) { - nowPlayingTitle = "正在播放:${music?.musicName ?: "无歌曲"}" - nowPlayingCover = music?.coverPath - } - - fun setLibrary( - allMusic: List, - albums: List, - love: List, - menuList: List, - ) { - songsByGroup = allMusic - .filter { !it.musicId.isNullOrBlank() } - .groupBy { it.group.orEmpty() } - .mapValues { (_, list) -> list.map { it.toItem() } } - albumsByGroup = albums - .filter { !it.albumId.isNullOrBlank() } - .groupBy { it.group.orEmpty() } - .mapValues { (_, list) -> - list.map { - CarPlayAlbumItem( - albumId = it.albumId!!, - title = it.albumName.orEmpty(), - group = it.group, - coverPath = it.coverPath, - ) - } - } - songsByAlbum = allMusic - .filter { !it.albumId.isNullOrBlank() && !it.musicId.isNullOrBlank() } - .groupBy { it.albumId!! } - .mapValues { (_, list) -> list.map { it.toItem() } } - loveSongs = love.map { it.toItem() } - menus = menuList.map { CarPlayMenuItem(it.id, it.name, it.music.size) } - songsByMenu = menuList.associate { menu -> - menu.id to menu.music.mapNotNull { id -> - allMusic.firstOrNull { it.musicId == id }?.toItem() - } - } - } - - fun songsForGroup(group: String): List = songsByGroup[group].orEmpty() - fun albumsForGroup(group: String): List = albumsByGroup[group].orEmpty() - fun songsForAlbum(albumId: String): List = songsByAlbum[albumId].orEmpty() - fun songsForMenu(menuId: Long): List = songsByMenu[menuId].orEmpty() - - fun play(item: CarPlaySongItem, queue: List) { - onPlay?.invoke(item.musicId, queue) - } - - private fun Music.toItem() = CarPlaySongItem( - musicId = musicId.orEmpty(), - title = musicName.orEmpty(), - artist = artist, - coverPath = coverPath, - ) - - companion object { - fun defaultGroups(): List = listOf( - CarPlayGroupNode(GroupKey.GROUP_US, "\u03bc's"), - CarPlayGroupNode(GroupKey.GROUP_AQOURS, "Aqours"), - CarPlayGroupNode(GroupKey.GROUP_NIJIGASAKI, "Nijigasaki"), - CarPlayGroupNode(GroupKey.GROUP_LIELLA, "Liella!"), - CarPlayGroupNode(GroupKey.GROUP_HASUNOSORA, "Hasunosora"), - CarPlayGroupNode(GroupKey.GROUP_YOHANE, "Yohane"), - CarPlayGroupNode(GroupKey.GROUP_MUSICAL, "Musical"), - CarPlayGroupNode(GroupKey.GROUP_BLUEBIRD, "BlueBird"), - CarPlayGroupNode(GroupKey.GROUP_COMBINE, "Combine"), - ) - } -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.kt deleted file mode 100644 index 69d7527..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.kt +++ /dev/null @@ -1,31 +0,0 @@ -package top.zhushenwudi.llmp.platform - -/** - * Local storage roots and file helpers. - */ -expect class FileSystem { - /** - * App files / documents root (ends with separator). - * Flutter default `SDUtils.path` — e.g. Android `…/files/`, iOS Documents. - */ - val filesRoot: String - - /** - * Music / media root (ends with separator). Same as Flutter `SDUtils.path`. - * Cloud `base_url` already starts with `LoveLive/…`, so this must NOT append another `LoveLive/`. - */ - val musicRoot: String - - fun exists(absolutePath: String): Boolean - - fun ensureDir(absolutePath: String): Boolean - - fun writeBytes(absolutePath: String, bytes: ByteArray): Boolean - - /** Non-recursive absolute paths of files (not directories) under [absoluteDir]. */ - fun listFiles(absoluteDir: String): List - - fun listUsbRoots(): List -} - -expect fun createFileSystem(): FileSystem diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.kt deleted file mode 100644 index f6c1a35..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.kt +++ /dev/null @@ -1,16 +0,0 @@ -package top.zhushenwudi.llmp.platform - -/** - * UI-facing platform actions (scan / webview / overlay / toast / image pick). - */ -expect object PlatformUi { - fun openQrScanner(onResult: (String) -> Unit) - fun openWebView(url: String, title: String? = null) - fun requestOverlayPermission() - fun moveTaskToBack() - fun showToast(message: String) - /** Pick an image from gallery; [onResult] receives absolute path or null. */ - fun pickImage(onResult: (String?) -> Unit) - fun openExternalUrl(url: String) - fun exitApp() -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.kt deleted file mode 100644 index 0a3048f..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.kt +++ /dev/null @@ -1,45 +0,0 @@ -package top.zhushenwudi.llmp.player - -import kotlinx.coroutines.flow.StateFlow -import top.zhushenwudi.llmp.domain.Music - -/** - * Platform player abstraction. UI depends only on this interface. - */ -expect class PlayerController { - val state: StateFlow - - /** - * Resolve a playable URI from library [Music] (relative musicPath + baseUrl). - * Must NOT mutate [Music.musicPath] — lyrics need the relative path. - */ - var resolvePlaybackUri: ((Music) -> String?)? - - /** - * Resolve cover URI/path for media notification / Now Playing artwork. - * Android: file:// or https:// URI. iOS: absolute local filesystem path preferred. - */ - var resolveCoverUri: ((Music) -> String?)? - - fun setQueue(items: List, startIndex: Int = 0) - fun play() - fun pause() - fun toggle() - fun seekTo(positionMs: Long) - fun next() - fun previous() - /** Jump to queue index (Flutter just_audio seek index). Keeps playing if already playing. */ - fun playAtIndex(index: Int) - fun setLoopMode(mode: LoopMode) - - /** Flutter removeMusic — remove one item without rebuilding the whole engine queue when possible. */ - fun removeAt(index: Int) - - /** Flutter removeAllMusics / clearPlayerStatus. */ - fun clearQueue() - - /** Play a local file path or content URI string. */ - fun playUri(uri: String, title: String? = null) - - fun release() -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/update/UpdateChecker.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/update/UpdateChecker.kt deleted file mode 100644 index cb5da23..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/update/UpdateChecker.kt +++ /dev/null @@ -1,16 +0,0 @@ -package top.zhushenwudi.llmp.update - -import kotlinx.serialization.json.Json -import top.zhushenwudi.llmp.domain.VersionInfo -import top.zhushenwudi.llmp.network.OssApi - -class UpdateChecker(private val oss: OssApi) { - private val json = Json { ignoreUnknownKeys = true; isLenient = true } - - suspend fun check(currentCode: Int = 1): VersionInfo? { - val raw = runCatching { oss.fetchVersionJson() }.getOrNull() ?: return null - val info = runCatching { json.decodeFromString(raw) }.getOrNull() - ?: return null - return if (info.versionCode > currentCode) info else null - } -} diff --git a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/util/PathResolver.kt b/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/util/PathResolver.kt deleted file mode 100644 index ce45cab..0000000 --- a/shared/src/commonMain/kotlin/top/zhushenwudi/llmp/util/PathResolver.kt +++ /dev/null @@ -1,91 +0,0 @@ -package top.zhushenwudi.llmp.util - -import top.zhushenwudi.llmp.domain.Album -import top.zhushenwudi.llmp.domain.Music -import top.zhushenwudi.llmp.platform.FileSystem -import top.zhushenwudi.llmp.settings.AppSettings - -class PathResolver( - private val fs: FileSystem, - private val settings: AppSettings, -) { - /** - * Flutter `SDUtils.path`: prefer user-selected [AppSettings.sdPath], else [FileSystem.musicRoot]. - */ - fun storageRoot(): String { - val sd = settings.sdPath.trim() - if (sd.isNotEmpty()) { - return sd.trimEnd('/', '\\') + "/" - } - return fs.musicRoot - } - - fun absoluteMusicPath(music: Music): String? { - val base = music.baseUrl ?: return null - val path = music.musicPath ?: return null - return storageRoot() + base.trimStart('/') + path.trimStart('/') - } - - fun absoluteCoverPath(music: Music): String? { - val base = music.baseUrl ?: return null - val path = music.coverPath ?: return null - return storageRoot() + base.trimStart('/') + path.trimStart('/') - } - - fun absoluteAlbumCoverPath(album: Album): String? { - val path = album.coverPath ?: return null - return storageRoot() + path.trimStart('/') - } - - /** - * Flutter [SDUtils.getImgPathFromMusic]: local file URI, HTTP URL, or null → UI shows logo. - */ - fun coverDisplayUri(music: Music): String? { - if (music.musicId == null) return null - if (music.existFile == true) { - val path = absoluteCoverPath(music) ?: return null - return if (fs.exists(path)) "file://$path" else null - } - if (settings.enableHttp && settings.httpUrl.isNotBlank()) { - val rel = (music.baseUrl.orEmpty() + music.coverPath.orEmpty()).trimStart('/') - if (rel.isBlank()) return null - return settings.httpUrl.trimEnd('/') + "/" + rel - } - return null - } - - /** Flutter [SDUtils.getImgPathFromAlbum]. */ - fun coverDisplayUri(album: Album): String? { - val rel = album.coverPath ?: return null - if (album.existFile == true) { - val path = absoluteAlbumCoverPath(album) ?: return null - return if (fs.exists(path)) "file://$path" else null - } - if (settings.enableHttp && settings.httpUrl.isNotBlank()) { - return settings.httpUrl.trimEnd('/') + "/" + rel.trimStart('/') - } - return null - } - - fun playbackUri(music: Music): String? { - if (settings.enableHttp && settings.httpUrl.isNotBlank()) { - val base = music.baseUrl.orEmpty() - var path = music.musicPath.orEmpty() - if (path.endsWith(".wav", ignoreCase = true)) { - path = path.dropLast(4) + ".flac" - } - return settings.httpUrl.trimEnd('/') + "/" + - (base + path).trimStart('/') - } - val local = absoluteMusicPath(music) ?: return null - if (fs.exists(local)) return "file://$local" - return music.musicPath?.takeIf { - it.startsWith("http") || it.startsWith("asset") - } - } - - fun refreshExistFile(music: Music): Music { - val path = absoluteMusicPath(music) - return music.copy(existFile = path != null && fs.exists(path)) - } -} diff --git a/shared/src/commonMain/sqldelight/top/zhushenwudi/llmp/db/LlmpDatabase.sq b/shared/src/commonMain/sqldelight/com/zhushenwudi/lovelivemusicplayer/db/LlmpDatabase.sq similarity index 100% rename from shared/src/commonMain/sqldelight/top/zhushenwudi/llmp/db/LlmpDatabase.sq rename to shared/src/commonMain/sqldelight/com/zhushenwudi/lovelivemusicplayer/db/LlmpDatabase.sq diff --git a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/lyric/LrcParserTest.kt b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParserTest.kt similarity index 94% rename from shared/src/commonTest/kotlin/top/zhushenwudi/llmp/lyric/LrcParserTest.kt rename to shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParserTest.kt index 9d194ef..f236bc5 100644 --- a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/lyric/LrcParserTest.kt +++ b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/LrcParserTest.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.lyric +package com.zhushenwudi.lovelivemusicplayer.lyric import kotlin.test.Test import kotlin.test.assertEquals diff --git a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/platform/DeepLinkParserTest.kt b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DeepLinkParserTest.kt similarity index 92% rename from shared/src/commonTest/kotlin/top/zhushenwudi/llmp/platform/DeepLinkParserTest.kt rename to shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DeepLinkParserTest.kt index 1937182..d394808 100644 --- a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/platform/DeepLinkParserTest.kt +++ b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/DeepLinkParserTest.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform import kotlin.test.Test import kotlin.test.assertEquals diff --git a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/protocol/HandshakeTest.kt b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/HandshakeTest.kt similarity index 89% rename from shared/src/commonTest/kotlin/top/zhushenwudi/llmp/protocol/HandshakeTest.kt rename to shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/HandshakeTest.kt index 6b7ec58..6475cbb 100644 --- a/shared/src/commonTest/kotlin/top/zhushenwudi/llmp/protocol/HandshakeTest.kt +++ b/shared/src/commonTest/kotlin/com/zhushenwudi/lovelivemusicplayer/protocol/HandshakeTest.kt @@ -1,7 +1,7 @@ -package top.zhushenwudi.llmp.protocol +package com.zhushenwudi.lovelivemusicplayer.protocol -import top.zhushenwudi.llmp.Const -import top.zhushenwudi.llmp.domain.FtpCmd +import com.zhushenwudi.lovelivemusicplayer.Const +import com.zhushenwudi.lovelivemusicplayer.domain.FtpCmd import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.ios.kt similarity index 83% rename from shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.ios.kt rename to shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.ios.kt index 5851495..681cce3 100644 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/DatabaseFactory.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/DatabaseFactory.ios.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.db +package com.zhushenwudi.lovelivemusicplayer.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.ios.kt new file mode 100644 index 0000000..0e21cab --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/db/Time.ios.kt @@ -0,0 +1,21 @@ +package com.zhushenwudi.lovelivemusicplayer.db + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.timeIntervalSince1970 + +actual fun currentTimeMs(): Long = + (NSDate().timeIntervalSince1970 * 1000.0).toLong() + +actual fun formatLocalHm(epochMs: Long): String { + val date = NSDate.dateWithTimeIntervalSince1970(epochMs / 1000.0) + val formatter = NSDateFormatter().apply { dateFormat = "HH:mm" } + return formatter.stringFromDate(date) +} + +actual fun formatLocalHms(epochMs: Long): String { + val date = NSDate.dateWithTimeIntervalSince1970(epochMs / 1000.0) + val formatter = NSDateFormatter().apply { dateFormat = "HH:mm:ss" } + return formatter.stringFromDate(date) +} diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.ios.kt new file mode 100644 index 0000000..f96c7c5 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/i18n/SystemLang.ios.kt @@ -0,0 +1,9 @@ +package com.zhushenwudi.lovelivemusicplayer.i18n + +import platform.Foundation.NSLocale +import platform.Foundation.preferredLanguages + +actual fun detectSystemLang(): AppLang { + val first = (NSLocale.preferredLanguages.firstOrNull() as? String) ?: "zh" + return langFromTag(first) +} diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.ios.kt new file mode 100644 index 0000000..5007c8e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/lyric/PlatformInfo.ios.kt @@ -0,0 +1,12 @@ +package com.zhushenwudi.lovelivemusicplayer.lyric + +import platform.Foundation.NSBundle + +actual object PlatformInfo { + // iOS 在 EULA 未接受前可能限制日文/罗马音歌词。 + actual val allowEulaLyric: Boolean = true + actual val isIos: Boolean = true + actual val appVersion: String = + NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String + ?: "" +} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.ios.kt similarity index 74% rename from shared/src/iosMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.ios.kt rename to shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.ios.kt index 908200d..48b068b 100644 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/network/HttpClientFactory.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/network/HttpClientFactory.ios.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.network +package com.zhushenwudi.lovelivemusicplayer.network import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.ios.kt similarity index 63% rename from shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.ios.kt rename to shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.ios.kt index 6039956..689dfec 100644 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/AppLog.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/AppLog.ios.kt @@ -1,22 +1,27 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform actual object AppLog { actual fun d(tag: String, message: String) { + AppLogStore.append("D", tag, message) println("D/$tag: $message") } actual fun i(tag: String, message: String) { + AppLogStore.append("I", tag, message) println("I/$tag: $message") } actual fun w(tag: String, message: String) { + AppLogStore.append("W", tag, message) println("W/$tag: $message") } actual fun e(tag: String, message: String, throwable: Throwable?) { if (throwable != null) { + AppLogStore.append("E", tag, "$message | ${throwable.message}") println("E/$tag: $message\n${throwable.stackTraceToString()}") } else { + AppLogStore.append("E", tag, message) println("E/$tag: $message") } } diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.ios.kt similarity index 65% rename from shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.ios.kt rename to shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.ios.kt index 7655982..5744910 100644 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/FileSystem.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/FileSystem.ios.kt @@ -1,5 +1,6 @@ -package top.zhushenwudi.llmp.platform +package com.zhushenwudi.lovelivemusicplayer.platform +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned @@ -8,8 +9,11 @@ import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSUserDomainMask +import platform.Foundation.NSFileHandle +import platform.Foundation.create +import platform.Foundation.writeToFile -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) actual class FileSystem { actual val filesRoot: String get() { @@ -51,6 +55,38 @@ actual class FileSystem { } } + actual fun writeBegin(absolutePath: String): Boolean { + val parent = absolutePath.substringBeforeLast('/', missingDelimiterValue = "") + if (parent.isNotEmpty()) { + ensureDir(parent) + } + return NSFileManager.defaultManager.createFileAtPath( + absolutePath, + contents = null, + attributes = null, + ) + } + + actual fun writeAppend(absolutePath: String, bytes: ByteArray, offset: Int, length: Int): Boolean { + if (length <= 0) return true + val handle = NSFileHandle.fileHandleForWritingAtPath(absolutePath) ?: return false + return try { + handle.seekToEndOfFile() + bytes.usePinned { pinned -> + val data = NSData.create( + bytes = pinned.addressOf(offset), + length = length.toULong(), + ) + handle.writeData(data) + } + true + } catch (_: Throwable) { + false + } finally { + handle.closeFile() + } + } + actual fun listFiles(absoluteDir: String): List { val fm = NSFileManager.defaultManager val contents = fm.contentsOfDirectoryAtPath(absoluteDir, error = null) ?: return emptyList() diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/IosPlatformHooks.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/IosPlatformHooks.kt new file mode 100644 index 0000000..81c2c45 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/IosPlatformHooks.kt @@ -0,0 +1,369 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSUserDefaults +import platform.Foundation.create +import platform.Foundation.writeToFile + +/** 与 WidgetKit 扩展及 Swift 宿主共享的 App Group。 */ +const val IOS_APP_GROUP = "group.com.zhushenwudi.lovelivemusicplayer" + +const val IOS_NOTIFICATION_DESKTOP_LYRIC = "llmpDesktopLyricUpdate" +const val IOS_NOTIFICATION_PIP = "llmpPiPRequest" +const val IOS_NOTIFICATION_SHARE = "llmpShareRequest" +const val IOS_NOTIFICATION_DEEP_LINK = "llmpDeepLink" +const val IOS_NOTIFICATION_WIDGET = "llmpWidgetAction" +const val IOS_NOTIFICATION_CARPLAY = "llmpCarPlayAction" +const val IOS_NOTIFICATION_OPEN_WEBVIEW = "llmpOpenWebView" + +@OptIn(ExperimentalForeignApi::class) +object WidgetDataWriter { + private val defaults: NSUserDefaults + get() = NSUserDefaults(suiteName = IOS_APP_GROUP) + + fun writeString(key: String, value: String?) { + defaults.setObject(value, forKey = key) + defaults.synchronize() + } + + fun writeBool(key: String, value: Boolean) { + defaults.setBool(value, forKey = key) + defaults.synchronize() + } + + fun writeInt(key: String, value: Int) { + defaults.setInteger(value.toLong(), forKey = key) + defaults.synchronize() + } + + fun readString(key: String): String? = defaults.stringForKey(key) + + fun readBool(key: String): Boolean = defaults.boolForKey(key) + + @OptIn(BetaInteropApi::class) + fun writeFile(name: String, bytes: ByteArray) { + val container = NSFileManager.defaultManager + .containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP) + ?: return + val path = container.URLByAppendingPathComponent(name)?.path ?: return + if (bytes.isEmpty()) return + bytes.usePinned { pinned -> + val data = NSData.create( + bytes = pinned.addressOf(0), + length = bytes.size.toULong(), + ) + data.writeToFile(path, atomically = true) + } + } + + fun writeWidgetState( + songName: String, + artist: String, + isPlaying: Boolean, + favorite: Boolean, + playText: String, + ) { + val shared = defaults + shared.setObject(songName, forKey = "songName") + shared.setObject(artist, forKey = "songArtist") + shared.setBool(favorite, forKey = "songFavorite") + shared.setBool(isPlaying, forKey = "isPlaying") + shared.setObject(playText, forKey = "playText") + if (songName.isNotBlank() || isPlaying) { + shared.setBool(false, forKey = "isShutdown") + } + shared.synchronize() + } + + fun writeShareImage(fromPath: String?) { + if (fromPath.isNullOrBlank()) { + clearShareImage() + return + } + post("llmpWidgetSaveCover", mapOf("path" to fromPath)) + } + + fun clearShareImage() { + val container = NSFileManager.defaultManager + .containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP) + ?: return + val dest = container.URLByAppendingPathComponent("sharedImage.png")?.path ?: return + if (NSFileManager.defaultManager.fileExistsAtPath(dest)) { + NSFileManager.defaultManager.removeItemAtPath(dest, error = null) + } + } + + fun reloadWidgets() { + // Swift 宿主/Widget 扩展监听此值并调用 WidgetCenter.reloadAllTimelines()。 + post("llmpReloadWidgets") + } + + fun post(name: String, userInfo: Map = emptyMap()) { + val info = userInfo.mapKeys { it.key as Any? }.mapValues { it.value as Any? } + NSNotificationCenter.defaultCenter.postNotificationName( + aName = name, + `object` = null, + userInfo = info, + ) + } +} + +class IosDesktopLyricController : DesktopLyricController { + override var enabled: Boolean = false + set(value) { + field = value + WidgetDataWriter.writeBool("desktopLyricEnabled", value) + WidgetDataWriter.post( + IOS_NOTIFICATION_PIP, + mapOf("action" to "pipAutoOpen", "enabled" to value.toString()), + ) + } + + override fun pipAutoOpen(enable: Boolean): Boolean { + enabled = enable + return true + } + + override fun show() { + WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "show")) + } + + override fun hide() { + WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "hide")) + } + + override fun updateLines(line1: String?, line2: String?, currentLine: Int) { + WidgetDataWriter.writeString("curJpLrc", line1) + WidgetDataWriter.writeString("nextJpLrc", line2) + WidgetDataWriter.writeInt("pipCurrentLine", currentLine) + WidgetDataWriter.post( + IOS_NOTIFICATION_DESKTOP_LYRIC, + mapOf( + "line1" to (line1 ?: ""), + "line2" to (line2 ?: ""), + "currentLine" to currentLine.toString(), + "line1Null" to (line1 == null).toString(), + "line2Null" to (line2 == null).toString(), + ), + ) + } + + override fun setPlaying(playing: Boolean) { + WidgetDataWriter.writeBool("pipIsPlaying", playing) + WidgetDataWriter.post( + IOS_NOTIFICATION_PIP, + mapOf("action" to "playing", "playing" to playing.toString()), + ) + } + + override fun isOverlayShowing(): Boolean = false +} + +class IosHomeWidgetController : HomeWidgetController { + private var lastContent: WidgetContent? = null + private var lastCoverPath: String? = null + + override fun update( + songName: String?, + artist: String?, + isPlaying: Boolean, + favorite: Boolean, + coverPath: String?, + playText: String, + ) { + val content = WidgetContent( + songName = songName.orEmpty(), + artist = artist.orEmpty(), + isPlaying = isPlaying, + favorite = favorite, + playText = playText, + ) + val contentChanged = content != lastContent + val newCoverPath = coverPath?.takeIf { it.isNotBlank() && it != lastCoverPath } + val coverChanged = newCoverPath != null + if (!contentChanged && !coverChanged) return + + if (contentChanged) { + lastContent = content + WidgetDataWriter.writeWidgetState( + songName = content.songName, + artist = content.artist, + isPlaying = content.isPlaying, + favorite = content.favorite, + playText = content.playText, + ) + WidgetDataWriter.reloadWidgets() + } + if (newCoverPath != null) { + lastCoverPath = newCoverPath + WidgetDataWriter.writeShareImage(newCoverPath) + } + } + + override fun setShutdown(shutdown: Boolean) { + lastContent = null + WidgetDataWriter.writeBool("isShutdown", shutdown) + if (shutdown) { + lastCoverPath = null + WidgetDataWriter.writeBool("isPlaying", false) + WidgetDataWriter.clearShareImage() + } + WidgetDataWriter.reloadWidgets() + } + + override fun refresh() { + WidgetDataWriter.reloadWidgets() + } + + private data class WidgetContent( + val songName: String, + val artist: String, + val isPlaying: Boolean, + val favorite: Boolean, + val playText: String, + ) +} + +class IosShareBridge : ShareBridge { + override fun shareText(text: String, title: String?) { + WidgetDataWriter.writeString("pendingShareText", text) + WidgetDataWriter.writeString("pendingShareTitle", title ?: "") + WidgetDataWriter.writeString("pendingShareUrl", "") + WidgetDataWriter.post( + IOS_NOTIFICATION_SHARE, + mapOf("type" to "text", "text" to text, "title" to (title ?: "")), + ) + } + + override fun shareUrl(url: String, title: String?) { + WidgetDataWriter.writeString("pendingShareUrl", url) + WidgetDataWriter.writeString("pendingShareTitle", title ?: "") + WidgetDataWriter.writeString("pendingShareText", "") + WidgetDataWriter.post( + IOS_NOTIFICATION_SHARE, + mapOf("type" to "url", "url" to url, "title" to (title ?: "")), + ) + } + + override fun shareToQq( + title: String, + text: String, + url: String, + imageUrl: String?, + imagePath: String?, + ) { + WidgetDataWriter.post( + IOS_NOTIFICATION_SHARE, + mapOf( + "type" to "qq", + "title" to title, + "text" to text, + "url" to url, + "imageUrl" to (imageUrl ?: ""), + "imagePath" to (imagePath ?: ""), + ), + ) + } +} + +class IosDeepLinkHandler : DeepLinkHandler { + var lastPayload: DeepLinkPayload? = null + private set + + override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url) + + override fun handle(url: String) { + val payload = parse(url) ?: return + lastPayload = payload + WidgetDataWriter.writeString("pendingDeepLink", url) + WidgetDataWriter.post(IOS_NOTIFICATION_DEEP_LINK, mapOf("url" to url)) + } +} + +class IosCarPlayHook : CarPlayHook { + private var catalog: CarPlayCatalog = CarPlayCatalog() + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + override fun bind(catalog: CarPlayCatalog) { + this.catalog = catalog + } + + override fun refreshCatalog() { + writeNowPlaying() + WidgetDataWriter.writeFile( + CARPLAY_CATALOG_FILE, + json.encodeToString(catalog.snapshot()).encodeToByteArray(), + ) + WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, nowPlayingUserInfo("refresh")) + } + + override fun refreshNowPlaying() { + writeNowPlaying() + WidgetDataWriter.writeFile( + CARPLAY_RECENT_FILE, + json.encodeToString(catalog.recentSongs).encodeToByteArray(), + ) + WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, nowPlayingUserInfo("nowPlaying")) + } + + private fun nowPlayingUserInfo(action: String) = mapOf( + "action" to action, + "isFavorite" to catalog.isFavorite.toString(), + "loopMode" to catalog.loopMode, + "isPlaying" to catalog.isPlaying.toString(), + ) + + private fun writeNowPlaying() { + WidgetDataWriter.writeString("carplay_nowPlaying", catalog.nowPlayingTitle) + WidgetDataWriter.writeString("carplay_nowPlayingCover", catalog.nowPlayingCover ?: "") + WidgetDataWriter.writeString("carplay_nowPlayingId", catalog.nowPlayingId ?: "") + WidgetDataWriter.writeBool("carplay_isPlaying", catalog.isPlaying) + WidgetDataWriter.writeBool("carplay_isFavorite", catalog.isFavorite) + WidgetDataWriter.writeString("carplay_loopMode", catalog.loopMode) + } + + companion object { + const val CARPLAY_CATALOG_FILE = "carplay_catalog.json" + const val CARPLAY_RECENT_FILE = "carplay_recent.json" + } +} + +class IosPiPHook : PictureInPictureHook { + override fun enterPiP() { + WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "enter")) + } +} + +class IosMediaSessionBridge : MediaSessionHook { + override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) { + WidgetDataWriter.writeString("songName", title ?: "") + WidgetDataWriter.writeString("songArtist", artist ?: "") + WidgetDataWriter.writeBool("isPlaying", isPlaying) + } +} + +fun installIosPlatformServices() { + PlatformServices.install( + desktopLyric = IosDesktopLyricController(), + homeWidget = IosHomeWidgetController(), + deepLink = IosDeepLinkHandler(), + usbMount = NoOpUsbMount, + share = IosShareBridge(), + mediaSession = IosMediaSessionBridge(), + analytics = NoOpAnalytics, + carPlay = IosCarPlayHook(), + pip = IosPiPHook(), + ) +} diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt new file mode 100644 index 0000000..219520c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/platform/PlatformUi.ios.kt @@ -0,0 +1,61 @@ +package com.zhushenwudi.lovelivemusicplayer.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL +import platform.Foundation.NSURLComponents +import platform.UIKit.UIApplication +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import kotlin.system.exitProcess + +@OptIn(ExperimentalForeignApi::class) +actual object PlatformUi { + actual fun openQrScanner(onResult: (String) -> Unit) { + // iOS 相机扫码由 Swift 承载;common UI 仍支持 IP 粘贴。 + onResult("") + } + + actual fun openWebView(url: String, title: String?) { + if (url.isBlank()) return + WidgetDataWriter.post( + IOS_NOTIFICATION_OPEN_WEBVIEW, + mapOf("url" to url, "title" to (title ?: "")), + ) + } + + actual fun requestOverlayPermission() = Unit + + actual fun moveTaskToBack() = Unit + + actual fun showToast(message: String) { + if (message.isBlank()) return + WidgetDataWriter.post("llmpShowToast", mapOf("message" to message.asLtrDisplay())) + } + + actual fun openExternalUrl(url: String) { + val nsUrl = parseUrl(url) ?: return + dispatch_async(dispatch_get_main_queue()) { + UIApplication.sharedApplication.openURL( + nsUrl, + options = emptyMap(), + completionHandler = null, + ) + } + } + + actual fun exitApp() { + exitProcess(0) + } + + actual fun setKeepScreenOn(enabled: Boolean) { + dispatch_async(dispatch_get_main_queue()) { + UIApplication.sharedApplication.idleTimerDisabled = enabled + } + } +} + +private fun parseUrl(url: String): NSURL? { + if (url.isBlank()) return null + return NSURL.URLWithString(url) + ?: NSURLComponents(string = url, encodingInvalidCharacters = true).URL +} diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/HttpAudioResourceLoader.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/HttpAudioResourceLoader.kt new file mode 100644 index 0000000..68ca69c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/HttpAudioResourceLoader.kt @@ -0,0 +1,191 @@ +package com.zhushenwudi.lovelivemusicplayer.player + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride +import platform.AVFoundation.AVAssetResourceLoader +import platform.AVFoundation.AVAssetResourceLoaderDelegateProtocol +import platform.AVFoundation.AVAssetResourceLoadingRequest +import platform.Foundation.NSError +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSLock +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSNumber +import platform.Foundation.NSURL +import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionTask +import platform.Foundation.dataTaskWithRequest +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue +import platform.darwin.NSObject +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog + +private const val LOADER_TAG = "LLMP-Player" +private const val LOADER_UA = "LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)" + +internal object HttpAudioResourceLoaderKeys { + const val SCHEME_HTTPS = "llmphttps" + const val SCHEME_HTTP = "llmphttp" + + fun toCustomUrl(httpUrl: String): String? = when { + httpUrl.startsWith("https://") -> SCHEME_HTTPS + "://" + httpUrl.removePrefix("https://") + httpUrl.startsWith("http://") -> SCHEME_HTTP + "://" + httpUrl.removePrefix("http://") + else -> null + } + + fun toRealHttpUrl(custom: NSURL?): NSURL? { + val abs = custom?.absoluteString ?: return null + val real = when { + abs.startsWith("$SCHEME_HTTPS://") -> + "https://" + abs.removePrefix("$SCHEME_HTTPS://") + + abs.startsWith("$SCHEME_HTTP://") -> + "http://" + abs.removePrefix("$SCHEME_HTTP://") + + else -> return null + } + return NSURL.URLWithString(real) + } + + fun parseTotalLength(http: NSHTTPURLResponse?): Long? { + if (http == null) return null + val headers = http.allHeaderFields + val contentRange = headers["Content-Range"] as? String + ?: headers["content-range"] as? String + if (contentRange != null) { + val total = contentRange.substringAfterLast('/', missingDelimiterValue = "") + .trim() + .toLongOrNull() + if (total != null && total > 0) return total + } + val contentLength = headers["Content-Length"] as? String + ?: headers["content-length"] as? String + return contentLength?.toLongOrNull()?.takeIf { it > 0 } + ?: (headers["Content-Length"] as? NSNumber)?.longLongValue?.takeIf { it > 0 } + } + + fun simpleError(message: String): NSError = + NSError.errorWithDomain( + domain = "LLMPHttpAudio", + code = -1, + userInfo = mapOf("NSLocalizedDescription" to message), + ) +} + +/** + * 通过自定义 URL scheme 为 AVPlayer 供流,以便: + * - 自行经 URLSession(GET)跟随 senluopan → CDN 重定向 + * - 忽略签名对象 URL 上的 Content-Disposition:attachment / HEAD-403 + * - 声明 AVPlayer 接受的 byte-range + audio/flac 内容信息 + * + * 资源 URL 须使用 `HttpAudioResourceLoaderKeys.SCHEME_HTTPS` / SCHEME_HTTP。 + */ +@OptIn(ExperimentalForeignApi::class) +class HttpAudioResourceLoader : NSObject(), AVAssetResourceLoaderDelegateProtocol { + private val lock = NSLock() + private val tasks = mutableMapOf() + + @ObjCSignatureOverride + override fun resourceLoader( + resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource: AVAssetResourceLoadingRequest, + ): Boolean { + val loadingRequest = shouldWaitForLoadingOfRequestedResource + val customUrl = loadingRequest.request.URL + val realUrl = HttpAudioResourceLoaderKeys.toRealHttpUrl(customUrl) + if (realUrl == null) { + AppLog.e(LOADER_TAG, "resourceLoader bad url=${customUrl?.absoluteString}") + loadingRequest.finishLoadingWithError(HttpAudioResourceLoaderKeys.simpleError("bad custom url")) + return false + } + fulfill(loadingRequest, realUrl) + return true + } + + @ObjCSignatureOverride + override fun resourceLoader( + resourceLoader: AVAssetResourceLoader, + didCancelLoadingRequest: AVAssetResourceLoadingRequest, + ) { + val key = didCancelLoadingRequest.hashCode() + lock.lock() + val task = tasks.remove(key) + lock.unlock() + task?.cancel() + } + + private fun fulfill(loadingRequest: AVAssetResourceLoadingRequest, realUrl: NSURL) { + val dataRequest = loadingRequest.dataRequest + val infoRequest = loadingRequest.contentInformationRequest + val offset: Long + val length: Long + val toEnd: Boolean + if (dataRequest != null) { + offset = dataRequest.requestedOffset + toEnd = dataRequest.requestsAllDataToEndOfResource + length = dataRequest.requestedLength + } else { + offset = 0L + length = 2L + toEnd = false + } + + val endInclusive = when { + toEnd || length <= 0L || length >= Int.MAX_VALUE.toLong() - 8 -> null + else -> offset + length - 1 + } + val rangeValue = if (endInclusive == null) { + "bytes=$offset-" + } else { + "bytes=$offset-$endInclusive" + } + + val request = NSMutableURLRequest.requestWithURL(realUrl) + request.setHTTPMethod("GET") + request.setCachePolicy(NSURLRequestReloadIgnoringLocalCacheData) + request.setValue(LOADER_UA, forHTTPHeaderField = "User-Agent") + request.setValue("*/*", forHTTPHeaderField = "Accept") + request.setValue(rangeValue, forHTTPHeaderField = "Range") + + val key = loadingRequest.hashCode() + val task = + NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error -> + lock.lock() + tasks.remove(key) + lock.unlock() + if (error != null) { + AppLog.e( + LOADER_TAG, + "resourceLoader net fail msg=${error.localizedDescription}" + ) + loadingRequest.finishLoadingWithError(error) + return@dataTaskWithRequest + } + val http = response as? NSHTTPURLResponse + val status = http?.statusCode?.toInt() ?: -1 + if (status != 200 && status != 206) { + AppLog.e(LOADER_TAG, "resourceLoader bad status=$status range=$rangeValue") + loadingRequest.finishLoadingWithError(HttpAudioResourceLoaderKeys.simpleError("HTTP $status")) + return@dataTaskWithRequest + } + + if (infoRequest != null) { + val total = HttpAudioResourceLoaderKeys.parseTotalLength(http) + ?: data?.length?.toLong() + ?: 0L + infoRequest.byteRangeAccessSupported = true + infoRequest.contentLength = total + infoRequest.contentType = "public.flac-audio" + } + + if (dataRequest != null && data != null) { + dataRequest.respondWithData(data) + } + loadingRequest.finishLoading() + } + lock.lock() + tasks[key] = task + lock.unlock() + task.resume() + } +} diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt new file mode 100644 index 0000000..5c8eecb --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/player/PlayerController.ios.kt @@ -0,0 +1,921 @@ +package com.zhushenwudi.lovelivemusicplayer.player + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFAudio.AVAudioSessionRouteChangeNotification +import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey +import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable +import platform.AVFAudio.setActive +import platform.AVFoundation.AVPlayer +import platform.AVFoundation.AVPlayerItem +import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification +import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeErrorKey +import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeNotification +import platform.AVFoundation.AVPlayerItemStatusFailed +import platform.AVFoundation.AVPlayerItemStatusReadyToPlay +import platform.AVFoundation.AVURLAsset +import platform.AVFoundation.currentItem +import platform.AVFoundation.currentTime +import platform.AVFoundation.duration +import platform.AVFoundation.pause +import platform.AVFoundation.play +import platform.AVFoundation.rate +import platform.AVFoundation.replaceCurrentItemWithPlayerItem +import platform.AVFoundation.resourceLoader +import platform.AVFoundation.seekToTime +import platform.CoreMedia.CMTimeGetSeconds +import platform.CoreMedia.CMTimeMake +import platform.Foundation.NSBundle +import platform.Foundation.NSError +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSNumber +import platform.Foundation.NSURL +import platform.Foundation.NSURLSession +import platform.Foundation.dataTaskWithRequest +import platform.Foundation.setValue +import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent +import platform.MediaPlayer.MPMediaItemArtwork +import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle +import platform.MediaPlayer.MPMediaItemPropertyArtist +import platform.MediaPlayer.MPMediaItemPropertyArtwork +import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration +import platform.MediaPlayer.MPMediaItemPropertyTitle +import platform.MediaPlayer.MPNowPlayingInfoCenter +import platform.MediaPlayer.MPNowPlayingInfoPropertyDefaultPlaybackRate +import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime +import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate +import platform.MediaPlayer.MPRemoteCommandCenter +import platform.MediaPlayer.MPRemoteCommandEvent +import platform.MediaPlayer.MPRemoteCommandHandlerStatus +import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed +import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess +import platform.MediaPlayer.MPSeekCommandEvent +import platform.UIKit.UIImage +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import com.zhushenwudi.lovelivemusicplayer.domain.Music +import com.zhushenwudi.lovelivemusicplayer.platform.AppLog +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +@OptIn(ExperimentalForeignApi::class) +actual class PlayerController { + private val player = AVPlayer() + private val scope = CoroutineScope(Dispatchers.Main.immediate) + private var positionJob: Job? = null + private var itemWatchJob: Job? = null + private var scrubJob: Job? = null + private var endObserver: Any? = null + private var failObserver: Any? = null + private var routeObserver: Any? = null + + private val _state = MutableStateFlow(PlayerState()) + actual val state: StateFlow = _state.asStateFlow() + + actual var resolvePlaybackUri: ((Music) -> String?)? = null + actual var resolveCoverUri: ((Music) -> String?)? = null + + private var queue: List = emptyList() + private var loopMode: LoopMode = LoopMode.LIST + private var cachedArtworkMusicId: String? = null + private var cachedArtwork: MPMediaItemArtwork? = null + + /** 避免同一 musicId 重复下载 HTTP 封面。 */ + private var coverFetchingMusicId: String? = null + + /** 实际交给 AVPlayer 的 URI(http/file),用于 play() 诊断。 */ + private var loadedUri: String? = null + + /** HTTP ResourceLoader 强引用(AVURLAsset delegate 为弱引用)。 */ + private var httpResourceLoader: HttpAudioResourceLoader? = null + + /** 本控制器创建的音乐 item,用于区分桌面歌词 dummy 视频的通知。 */ + private var musicItem: AVPlayerItem? = null + + private var lastEndMark: TimeSource.Monotonic.ValueTimeMark? = null + + private var audioReady = false + + /** CarPlay / 锁屏 Now Playing 标题用的当前歌词;null 表示显示歌名。 */ + private var nowPlayingLyric: String? = null + + init { + // 延迟到首次 play() 再设置 AVAudioSession / 远程命令。 + // 在 Compose 首次布局时调用 setCategory 会在模拟器上中止 + // (HAL 插件 / VolumeButtonMonitor)并抛出未捕获的 Kotlin 异常。 + observePlaybackEnd() + observePlaybackFailed() + observeAudioRouteChanges() + } + + private fun ensureAudioReady() { + if (audioReady) return + audioReady = true + runCatching { + val session = AVAudioSession.sharedInstance() + session.setCategory(AVAudioSessionCategoryPlayback, error = null) + session.setActive(true, error = null) + }.onFailure { AppLog.e(TAG, "ensureAudioReady session fail", it) } + runCatching { setupRemoteCommands() } + .onFailure { AppLog.e(TAG, "ensureAudioReady remoteCommands fail", it) } + } + + private fun setupRemoteCommands() { + val center = MPRemoteCommandCenter.sharedCommandCenter() + center.playCommand.setEnabled(true) + center.pauseCommand.setEnabled(true) + center.togglePlayPauseCommand.setEnabled(true) + center.nextTrackCommand.setEnabled(true) + center.previousTrackCommand.setEnabled(true) + center.changePlaybackPositionCommand.setEnabled(true) + center.seekForwardCommand.setEnabled(true) + center.seekBackwardCommand.setEnabled(true) + + center.playCommand.addTargetWithHandler { _ -> + play() + MPRemoteCommandHandlerStatusSuccess + } + center.pauseCommand.addTargetWithHandler { _ -> + pause() + MPRemoteCommandHandlerStatusSuccess + } + center.togglePlayPauseCommand.addTargetWithHandler { _ -> + toggle() + MPRemoteCommandHandlerStatusSuccess + } + center.nextTrackCommand.addTargetWithHandler { _ -> + next() + MPRemoteCommandHandlerStatusSuccess + } + center.previousTrackCommand.addTargetWithHandler { _ -> + previous() + MPRemoteCommandHandlerStatusSuccess + } + center.changePlaybackPositionCommand.addTargetWithHandler { event -> + val seconds = playbackPositionSeconds(event) + if (seconds == null || seconds.isNaN() || seconds < 0) { + MPRemoteCommandHandlerStatusCommandFailed + } else { + seekTo((seconds * 1000.0).toLong()) + MPRemoteCommandHandlerStatusSuccess + } + } + center.seekForwardCommand.addTargetWithHandler { event -> + handleSeekCommand(event, forward = true) + } + center.seekBackwardCommand.addTargetWithHandler { event -> + handleSeekCommand(event, forward = false) + } + } + + /** + * 长按上一曲/下一曲:系统发 seekForward/Backward(begin/end)。 + * HTTP FLAC 不一定支持 rate≠1,改为步进 seek。 + */ + private fun handleSeekCommand(event: MPRemoteCommandEvent?, forward: Boolean): MPRemoteCommandHandlerStatus { + val raw = (event as? MPSeekCommandEvent)?.type?.value + when (raw) { + 0uL -> beginScrub(forward) + 1uL -> endScrub() + else -> if (scrubJob != null) endScrub() else beginScrub(forward) + } + return MPRemoteCommandHandlerStatusSuccess + } + + private fun beginScrub(forward: Boolean) { + scrubJob?.cancel() + if (!_state.value.isPlaying) play() + scrubJob = scope.launch { + while (isActive) { + val pos = currentPositionMs() + val dur = currentDurationMs() + val next = if (forward) { + if (dur > 0) (pos + SCRUB_STEP_MS).coerceAtMost(dur) else pos + SCRUB_STEP_MS + } else { + (pos - SCRUB_STEP_MS).coerceAtLeast(0L) + } + seekTo(next) + if (forward && dur > 0 && next >= dur) { + endScrub() + onPlaybackEnded() + break + } + delay(SCRUB_TICK) + } + } + } + + private fun endScrub() { + scrubJob?.cancel() + scrubJob = null + } + + /** Kotlin/Native 对 MPChangePlaybackPositionCommandEvent 的 as? 经常失败,用 KVC 兜底。 */ + private fun playbackPositionSeconds(event: MPRemoteCommandEvent?): Double? { + val t = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime + return t?.takeIf { !it.isNaN() && it >= 0 } + } + + /** + * 判断通知里的 item 是否为本控制器正在播放的音乐(而非桌面歌词 dummy 视频)。 + * 用 isEqual 而不是 ===:Kotlin/Native 每次桥接 ObjC 对象可能给出不同的包装器实例。 + */ + private fun isMusicItem(item: AVPlayerItem): Boolean { + musicItem?.let { if (item.isEqual(it)) return true } + player.currentItem?.let { if (item.isEqual(it)) return true } + return false + } + + private fun observePlaybackEnd() { + endObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVPlayerItemDidPlayToEndTimeNotification, + `object` = null, + queue = null, + ) { notification: NSNotification? -> + val ended = notification?.`object` as? AVPlayerItem + // 桌面歌词 dummy black.mp4 也会发 DidPlayToEnd;只处理本控制器的音乐 item。 + // Kotlin/Native 的 === 对 ObjC 对象包装器不可靠,须用 isEqual 比较指针。 + if (ended == null || !isMusicItem(ended)) { + return@addObserverForName + } + dispatch_async(dispatch_get_main_queue()) { + onPlaybackEnded() + } + } + } + + private fun observePlaybackFailed() { + failObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVPlayerItemFailedToPlayToEndTimeNotification, + `object` = null, + queue = null, + ) { notification: NSNotification? -> + val failed = notification?.`object` as? AVPlayerItem + if (failed == null || !isMusicItem(failed)) return@addObserverForName + val error = + notification.userInfo?.get(AVPlayerItemFailedToPlayToEndTimeErrorKey) as? NSError + val current = _state.value.current + AppLog.e( + TAG, + "av fail domain=${error?.domain} code=${error?.code} " + + "msg=${error?.localizedDescription} uri=${current?.musicPath} " + + "id=${current?.musicId} name=${current?.musicName}", + ) + _state.update { + it.copy( + isPlaying = false, + error = error?.localizedDescription ?: "AVPlayerItem failed", + ) + } + } + } + + /** 耳机/蓝牙音频路由不可用时暂停(拔出/断开)。 */ + private fun observeAudioRouteChanges() { + routeObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVAudioSessionRouteChangeNotification, + `object` = null, + queue = null, + ) { notification: NSNotification? -> + val reason = + (notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber) + ?.unsignedLongValue + dispatch_async(dispatch_get_main_queue()) { + if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) { + pause() + } + // 蓝牙接入时立刻重推 Now Playing,外设才能读到当前歌词标题。 + updateNowPlayingInfo() + } + } + } + + private fun onPlaybackEnded() { + // 结束通知与"播放到尾部却停住"的兜底可能同时命中,去重。 + val previous = lastEndMark + if (previous != null && previous.elapsedNow() < END_DEDUPE) return + lastEndMark = TimeSource.Monotonic.markNow() + when (loopMode) { + LoopMode.SINGLE -> { + seekTo(0) + play() + } + + LoopMode.LIST -> { + if (_state.value.index < queue.lastIndex) { + loadIndex(_state.value.index + 1, autoPlay = true) + } else if (queue.isNotEmpty()) { + loadIndex(0, autoPlay = true) + } else { + _state.update { it.copy(isPlaying = false, positionMs = 0) } + } + } + + LoopMode.SHUFFLE -> { + if (queue.size <= 1) { + seekTo(0) + play() + } else { + val current = _state.value.index + var nextIndex = Random.nextInt(queue.size) + while (nextIndex == current) { + nextIndex = Random.nextInt(queue.size) + } + loadIndex(nextIndex, autoPlay = true) + } + } + } + } + + private fun startPositionUpdates() { + positionJob?.cancel() + positionJob = scope.launch { + while (isActive) { + val positionMs = currentPositionMs() + val durationMs = currentDurationMs() + val playing = player.rate > 0f + val wasPlaying = _state.value.isPlaying + _state.update { + it.copy( + positionMs = positionMs, + durationMs = durationMs, + isPlaying = playing, + ) + } + updateNowPlayingInfo() + // 兜底:HTTP 流有时不发 DidPlayToEnd,只是在尾部停住。 + if (wasPlaying && !playing && durationMs > 0 && + positionMs >= durationMs - END_TOLERANCE_MS + ) { + onPlaybackEnded() + } + delay(200.milliseconds) + } + } + } + + private fun currentPositionMs(): Long { + val seconds = CMTimeGetSeconds(player.currentTime()) + if (seconds.isNaN() || seconds < 0) return 0 + return (seconds * 1000).toLong() + } + + private fun currentDurationMs(): Long { + val item = player.currentItem + if (item != null) { + val seconds = CMTimeGetSeconds(item.duration) + if (!seconds.isNaN() && !seconds.isInfinite() && seconds > 0) { + return (seconds * 1000).toLong() + } + } + return (musicTimeSeconds(_state.value.current?.time) * 1000.0).toLong() + } + + private fun musicTimeSeconds(time: String?): Double { + if (time.isNullOrBlank()) return 0.0 + val parts = time.trim().split(':') + return runCatching { + when (parts.size) { + 1 -> parts[0].toDouble() + 2 -> parts[0].toDouble() * 60 + parts[1].toDouble() + 3 -> parts[0].toDouble() * 3600 + parts[1].toDouble() * 60 + parts[2].toDouble() + else -> 0.0 + } + }.getOrDefault(0.0) + } + + /** + * 系统 Now Playing / 灵动岛 / 控制中心 / 锁屏。 + * 封面 + 专辑信息启用灵动岛媒体胶囊。 + */ + private fun updateNowPlayingInfo() { + val s = _state.value + val current = s.current ?: return + val songName = current.musicName ?: return + val title = if (s.isPlaying) { + nowPlayingLyric?.takeIf { it.isNotBlank() } ?: songName + } else { + songName + } + val artist = current.artist.orEmpty() + val album = current.albumName.orEmpty() + val durationSec = when { + s.durationMs > 0 -> s.durationMs / 1000.0 + else -> musicTimeSeconds(current.time) + } + val elapsedSec = s.positionMs.coerceAtLeast(0) / 1000.0 + val rate = if (s.isPlaying) 1.0 else 0.0 + val info = mutableMapOf( + MPMediaItemPropertyTitle to title, + MPMediaItemPropertyArtist to artist, + MPMediaItemPropertyAlbumTitle to album, + MPMediaItemPropertyPlaybackDuration to NSNumber(double = durationSec), + MPNowPlayingInfoPropertyElapsedPlaybackTime to NSNumber(double = elapsedSec), + MPNowPlayingInfoPropertyPlaybackRate to NSNumber(double = rate), + MPNowPlayingInfoPropertyDefaultPlaybackRate to NSNumber(double = 1.0), + ) + artworkFor(current)?.let { art -> + info[MPMediaItemPropertyArtwork] = art + } + MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = info + } + + private fun artworkFor(music: Music): MPMediaItemArtwork? { + val id = music.musicId + if (id != null && id == cachedArtworkMusicId && cachedArtwork != null) { + return cachedArtwork + } + val raw = resolveCoverUri?.invoke(music) ?: return null + if (raw.startsWith("http://") || raw.startsWith("https://")) { + // HTTP 曲库:异步加载远程封面供灵动岛/锁屏。 + if (id != null) prefetchHttpArtwork(id, raw) + return cachedArtwork.takeIf { cachedArtworkMusicId == id } + } + val path = when { + raw.startsWith("file://") -> raw.removePrefix("file://") + else -> raw + } + val image = UIImage.imageWithContentsOfFile(path) ?: return null + val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image } + cachedArtworkMusicId = id + cachedArtwork = artwork + return artwork + } + + private fun prefetchHttpArtwork(musicId: String, url: String) { + if (musicId == coverFetchingMusicId) return + if (musicId == cachedArtworkMusicId && cachedArtwork != null) return + val nsUrl = NSURL.URLWithString(url) ?: return + coverFetchingMusicId = musicId + val request = NSMutableURLRequest.requestWithURL(nsUrl) + request.setValue( + "LoveLiveMusicPlayer/0.2.0 (KMP; Android/iOS)", + forHTTPHeaderField = "User-Agent", + ) + NSURLSession.sharedSession.dataTaskWithRequest(request) { data, _, error -> + if (error != null || data == null) { + AppLog.w( + TAG, + "cover http fetch fail id=$musicId msg=${error?.localizedDescription}" + ) + dispatch_async(dispatch_get_main_queue()) { + if (coverFetchingMusicId == musicId) coverFetchingMusicId = null + } + return@dataTaskWithRequest + } + val image = UIImage.imageWithData(data) + dispatch_async(dispatch_get_main_queue()) { + if (coverFetchingMusicId == musicId) coverFetchingMusicId = null + if (image == null) { + AppLog.w(TAG, "cover http decode fail id=$musicId") + return@dispatch_async + } + if (_state.value.current?.musicId != musicId) return@dispatch_async + val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image } + cachedArtworkMusicId = musicId + cachedArtwork = artwork + updateNowPlayingInfo() + } + }.resume() + } + + actual fun setQueue(items: List, startIndex: Int) { + val index = if (items.isEmpty()) -1 else startIndex.coerceIn(0, items.lastIndex) + val nextCurrent = items.getOrNull(index) + val sameCurrent = nextCurrent?.musicId != null && + nextCurrent.musicId == _state.value.current?.musicId && + loadedUri != null && + player.currentItem != null + queue = items + loopMode = _state.value.loopMode + if (items.isEmpty()) { + player.pause() + player.replaceCurrentItemWithPlayerItem(null) + loadedUri = null + httpResourceLoader = null + musicItem = null + _state.update { + it.copy(queue = emptyList(), index = -1, current = null, isPlaying = false) + } + return + } + // 插入下一首时不重建整个播放队列 + // 当前播放项 — 保持位置/解码器状态。 + if (sameCurrent) { + _state.update { + it.copy(queue = items, index = index, current = nextCurrent, loopMode = loopMode) + } + return + } + loadIndex(index, autoPlay = false) + _state.update { it.copy(queue = items, loopMode = loopMode) } + } + + actual fun play() { + ensureAudioReady() + val current = _state.value.current + if (loadedUri.isNullOrBlank() && current != null) { + AppLog.e(TAG, "play() abort reason=noLoadedUri id=${current.musicId}") + _state.update { it.copy(isPlaying = false, error = "No playable URI") } + return + } + player.play() + _state.update { it.copy(isPlaying = true, error = null) } + startPositionUpdates() + updateNowPlayingInfo() + } + + actual fun pause() { + endScrub() + player.pause() + positionJob?.cancel() + _state.update { it.copy(isPlaying = false) } + updateNowPlayingInfo() + } + + actual fun toggle() { + if (_state.value.isPlaying) pause() else play() + } + + actual fun seekTo(positionMs: Long) { + val time = CMTimeMake(value = positionMs, timescale = 1000) + player.seekToTime(time) + _state.update { it.copy(positionMs = positionMs) } + updateNowPlayingInfo() + } + + actual fun next() { + if (queue.isEmpty()) return + when (loopMode) { + LoopMode.SHUFFLE -> onPlaybackEnded() + else -> { + val next = if (_state.value.index < queue.lastIndex) { + _state.value.index + 1 + } else if (loopMode == LoopMode.LIST) { + 0 + } else { + return + } + loadIndex(next, autoPlay = _state.value.isPlaying) + } + } + } + + actual fun previous() { + if (queue.isEmpty()) return + val pos = _state.value.positionMs + if (pos > 3000) { + seekTo(0) + return + } + val prev = if (_state.value.index > 0) { + _state.value.index - 1 + } else if (loopMode == LoopMode.LIST) { + queue.lastIndex + } else { + 0 + } + loadIndex(prev, autoPlay = _state.value.isPlaying) + } + + actual fun playAtIndex(index: Int) { + if (index !in queue.indices) return + loadIndex(index, autoPlay = _state.value.isPlaying) + } + + actual fun setLoopMode(mode: LoopMode) { + loopMode = mode + _state.update { it.copy(loopMode = mode) } + } + + actual fun syncLoveState(musicId: String, isLove: Boolean) { + queue = queue.map { music -> + if (music.musicId == musicId) music.copy(isLove = isLove) else music + } + _state.update { st -> + val current = st.current?.let { music -> + if (music.musicId == musicId) music.copy(isLove = isLove) else music + } + st.copy(queue = queue, current = current) + } + } + + actual fun setNowPlayingLyric(line: String?) { + val next = line?.trim()?.takeIf { it.isNotEmpty() } + if (nowPlayingLyric == next) return + nowPlayingLyric = next + updateNowPlayingInfo() + } + + actual fun removeAt(index: Int) { + if (index !in queue.indices) return + if (queue.size == 1) { + clearQueue() + return + } + val wasPlaying = _state.value.isPlaying + val currentIndex = _state.value.index + val removingCurrent = index == currentIndex + val newQueue = queue.toMutableList().also { it.removeAt(index) } + queue = newQueue + if (!removingCurrent) { + val newIdx = when { + index < currentIndex -> currentIndex - 1 + else -> currentIndex + }.coerceIn(0, newQueue.lastIndex) + _state.update { + it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx)) + } + return + } + val nextIndex = when { + loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size) + index > newQueue.lastIndex -> 0 + else -> index + } + loadIndex(nextIndex.coerceIn(0, newQueue.lastIndex), autoPlay = wasPlaying) + _state.update { it.copy(queue = newQueue) } + } + + actual fun clearQueue() { + positionJob?.cancel() + itemWatchJob?.cancel() + player.pause() + player.replaceCurrentItemWithPlayerItem(null) + queue = emptyList() + loadedUri = null + httpResourceLoader = null + musicItem = null + cachedArtwork = null + cachedArtworkMusicId = null + coverFetchingMusicId = null + MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null + nowPlayingLyric = null + _state.update { + it.copy( + queue = emptyList(), + index = -1, + current = null, + isPlaying = false, + positionMs = 0, + durationMs = 0, + ) + } + } + + actual fun playUri(uri: String, title: String?) { + val music = Music( + musicId = "local", + musicName = title ?: uri.substringAfterLast('/'), + musicPath = uri, + ) + queue = listOf(music) + loopMode = _state.value.loopMode + val url = resolveUrl(uri) + if (url == null) { + AppLog.e(TAG, "playUri fail reason=badUrl uri=$uri") + _state.update { + it.copy( + current = music, + queue = queue, + index = 0, + isPlaying = false, + error = "Cannot resolve playback URL: $uri", + ) + } + return + } + val asset = AVURLAsset(uRL = url, options = null) + val item = AVPlayerItem(asset = asset) + musicItem = item + player.replaceCurrentItemWithPlayerItem(item) + player.play() + _state.update { + it.copy( + current = music, + queue = queue, + index = 0, + isPlaying = true, + error = null, + positionMs = 0, + ) + } + startPositionUpdates() + updateNowPlayingInfo() + } + + actual fun release() { + positionJob?.cancel() + itemWatchJob?.cancel() + player.pause() + player.replaceCurrentItemWithPlayerItem(null) + endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + endObserver = null + failObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + failObserver = null + routeObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + routeObserver = null + queue = emptyList() + loadedUri = null + httpResourceLoader = null + musicItem = null + _state.value = PlayerState() + } + + private fun loadIndex(index: Int, autoPlay: Boolean) { + nowPlayingLyric = null + val music = queue.getOrNull(index) ?: return + // 绝不回退到数据库相对 musicPath(如 "02. ICE LIMIT.wav")— + // NSURL.fileURLWithPath 会静默接受,AVPlayer 稍后失败。 + val path = resolvePlaybackUri?.invoke(music) + ?: music.musicPath?.takeIf { isPlayableUri(it) } + if (path.isNullOrBlank()) { + loadedUri = null + AppLog.e( + TAG, + "loadIndex fail id=${music.musicId} name=${music.musicName} reason=missingPath " + + "autoPlay=$autoPlay dbPath=${music.musicPath} base=${music.baseUrl}", + ) + _state.update { + it.copy( + index = index, + current = music, + error = "Missing music path", + isPlaying = false, + ) + } + return + } + itemWatchJob?.cancel() + attachPlayerItem(music, index, path, autoPlay) + } + + private fun attachPlayerItem(music: Music, index: Int, path: String, autoPlay: Boolean) { + val url = resolveUrl(path) + if (url == null) { + loadedUri = null + AppLog.e( + TAG, + "loadIndex fail id=${music.musicId} name=${music.musicName} reason=badUrl path=$path", + ) + _state.update { + it.copy( + index = index, + current = music, + error = "Cannot resolve URL: $path", + isPlaying = false, + ) + } + return + } + loadedUri = path + if (music.musicId != cachedArtworkMusicId) { + cachedArtwork = null + cachedArtworkMusicId = null + coverFetchingMusicId = null + } + val asset: AVURLAsset + if (path.startsWith("http://") || path.startsWith("https://")) { + val custom = HttpAudioResourceLoaderKeys.toCustomUrl(path) + val customNs = custom?.let { NSURL.URLWithString(it) } + if (customNs == null) { + loadedUri = null + AppLog.e(TAG, "loadIndex fail customScheme path=$path") + _state.update { + it.copy( + index = index, + current = music, + error = "Cannot build proxied URL", + isPlaying = false, + ) + } + return + } + // 须保留 delegate — AVAssetResourceLoader.delegate 为弱引用。 + httpResourceLoader = HttpAudioResourceLoader() + asset = AVURLAsset(uRL = customNs, options = null) + asset.resourceLoader.setDelegate( + httpResourceLoader, + queue = dispatch_get_main_queue(), + ) + } else { + httpResourceLoader = null + asset = AVURLAsset(uRL = url, options = null) + } + val item = AVPlayerItem(asset = asset) + musicItem = item + player.replaceCurrentItemWithPlayerItem(item) + watchItemStatus(item, path, music) + if (autoPlay) { + ensureAudioReady() + player.play() + startPositionUpdates() + } else { + player.pause() + positionJob?.cancel() + } + _state.update { + it.copy( + index = index, + current = music, + isPlaying = autoPlay, + error = null, + positionMs = 0, + ) + } + updateNowPlayingInfo() + } + + private fun watchItemStatus(item: AVPlayerItem, path: String, music: Music) { + itemWatchJob?.cancel() + itemWatchJob = scope.launch { + repeat(40) { + delay(250.milliseconds) + when (item.status) { + AVPlayerItemStatusReadyToPlay -> { + return@launch + } + + AVPlayerItemStatusFailed -> { + val err = item.error + AppLog.e( + TAG, + "item failed id=${music.musicId} name=${music.musicName} " + + "domain=${err?.domain} code=${err?.code} " + + "msg=${err?.localizedDescription} path=$path", + ) + _state.update { + it.copy( + isPlaying = false, + error = err?.localizedDescription ?: "AVPlayerItem status failed", + ) + } + return@launch + } + + else -> Unit + } + } + AppLog.w( + TAG, + "item status timeout id=${music.musicId} name=${music.musicName} " + + "status=${item.status} path=$path", + ) + } + } + + private fun isPlayableUri(raw: String): Boolean = + raw.startsWith("http://") || raw.startsWith("https://") || + raw.startsWith("file://") || raw.startsWith("asset://") || + raw.startsWith("/") + + private fun resolveUrl(raw: String): NSURL? { + val url = when { + raw.startsWith("http://") || raw.startsWith("https://") || + raw.startsWith("file://") -> NSURL.URLWithString(raw) + + raw.startsWith("asset:///") -> { + val name = raw.removePrefix("asset:///").substringBeforeLast('.') + val ext = raw.substringAfterLast('.', "") + val path = NSBundle.mainBundle.pathForResource(name, ext) + path?.let { NSURL.fileURLWithPath(it) } + } + + raw.startsWith("asset://") -> { + val name = raw.removePrefix("asset://").trimStart('/').substringBeforeLast('.') + val ext = raw.substringAfterLast('.', "") + val path = NSBundle.mainBundle.pathForResource(name, ext) + path?.let { NSURL.fileURLWithPath(it) } + } + + raw.startsWith("/") -> NSURL.fileURLWithPath(raw) + else -> { + AppLog.w(TAG, "resolveUrl reject relative path=$raw") + null + } + } + return url + } + + private companion object { + const val TAG = "LLMP-Player" + val END_DEDUPE = 1500.milliseconds + const val END_TOLERANCE_MS = 500L + const val SCRUB_STEP_MS = 2000L + val SCRUB_TICK = 250.milliseconds + } +} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.ios.kt similarity index 81% rename from shared/src/iosMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.ios.kt rename to shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.ios.kt index 81addca..5ab28e7 100644 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/settings/SettingsFactory.ios.kt +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/settings/SettingsFactory.ios.kt @@ -1,4 +1,4 @@ -package top.zhushenwudi.llmp.settings +package com.zhushenwudi.lovelivemusicplayer.settings import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.Settings diff --git a/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.ios.kt b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.ios.kt new file mode 100644 index 0000000..94e7deb --- /dev/null +++ b/shared/src/iosMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/Sha512.ios.kt @@ -0,0 +1,26 @@ +package com.zhushenwudi.lovelivemusicplayer.util + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CC_SHA512 +import platform.CoreCrypto.CC_SHA512_DIGEST_LENGTH + +@OptIn(ExperimentalForeignApi::class) +actual fun sha512Hex(text: String): String { + val data = text.encodeToByteArray() + val digest = UByteArray(CC_SHA512_DIGEST_LENGTH) + data.usePinned { input -> + digest.usePinned { output -> + CC_SHA512( + input.addressOf(0), + data.size.toUInt(), + output.addressOf(0).reinterpret(), + ) + } + } + return digest.joinToString("") { b -> + b.toInt().toString(16).padStart(2, '0') + } +} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/Time.ios.kt b/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/Time.ios.kt deleted file mode 100644 index 6cf3c9e..0000000 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/db/Time.ios.kt +++ /dev/null @@ -1,7 +0,0 @@ -package top.zhushenwudi.llmp.db - -import platform.Foundation.NSDate -import platform.Foundation.timeIntervalSince1970 - -actual fun currentTimeMs(): Long = - (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.ios.kt b/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.ios.kt deleted file mode 100644 index 53336f8..0000000 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/lyric/PlatformInfo.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package top.zhushenwudi.llmp.lyric - -actual object PlatformInfo { - // Align with Flutter: iOS may restrict JP/ROMA until EULA accepted. - actual val allowEulaLyric: Boolean = true -} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/IosPlatformHooks.kt b/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/IosPlatformHooks.kt deleted file mode 100644 index 4df5c9f..0000000 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/IosPlatformHooks.kt +++ /dev/null @@ -1,226 +0,0 @@ -package top.zhushenwudi.llmp.platform - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import platform.Foundation.NSFileManager -import platform.Foundation.NSNotificationCenter -import platform.Foundation.NSUserDefaults - -/** App Group shared with WidgetKit extension and Swift host. */ -const val IOS_APP_GROUP = "group.top.zhushenwudi.llmp" - -const val IOS_NOTIFICATION_DESKTOP_LYRIC = "llmpDesktopLyricUpdate" -const val IOS_NOTIFICATION_PIP = "llmpPiPRequest" -const val IOS_NOTIFICATION_SHARE = "llmpShareRequest" -const val IOS_NOTIFICATION_DEEP_LINK = "llmpDeepLink" -const val IOS_NOTIFICATION_WIDGET = "llmpWidgetAction" -const val IOS_NOTIFICATION_CARPLAY = "llmpCarPlayAction" - -@OptIn(ExperimentalForeignApi::class) -object WidgetDataWriter { - private val defaults: NSUserDefaults? - get() = NSUserDefaults(suiteName = IOS_APP_GROUP) - - fun writeString(key: String, value: String?) { - defaults?.setObject(value, forKey = key) - defaults?.synchronize() - } - - fun writeBool(key: String, value: Boolean) { - defaults?.setBool(value, forKey = key) - defaults?.synchronize() - } - - fun writeInt(key: String, value: Int) { - defaults?.setInteger(value.toLong(), forKey = key) - defaults?.synchronize() - } - - fun writeShareImage(fromPath: String?) { - if (fromPath.isNullOrBlank()) return - val container = NSFileManager.defaultManager - .containerURLForSecurityApplicationGroupIdentifier(IOS_APP_GROUP) - ?: return - val dest = container.URLByAppendingPathComponent("sharedImage.png")?.path ?: return - if (NSFileManager.defaultManager.fileExistsAtPath(fromPath)) { - NSFileManager.defaultManager.removeItemAtPath(dest, error = null) - NSFileManager.defaultManager.copyItemAtPath(fromPath, toPath = dest, error = null) - } - } - - fun reloadWidgets() { - // Swift host / Widget Extension observe this and call WidgetCenter.reloadAllTimelines(). - post("llmpReloadWidgets") - } - - fun post(name: String, userInfo: Map = emptyMap()) { - val info = userInfo.mapKeys { it.key as Any? }.mapValues { it.value as Any? } - NSNotificationCenter.defaultCenter.postNotificationName( - aName = name, - `object` = null, - userInfo = info, - ) - } -} - -class IosDesktopLyricController : DesktopLyricController { - override var enabled: Boolean = false - set(value) { - field = value - WidgetDataWriter.writeBool("desktopLyricEnabled", value) - WidgetDataWriter.post(IOS_NOTIFICATION_DESKTOP_LYRIC, mapOf("enabled" to value.toString())) - } - - override fun show() { - enabled = true - WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "show")) - } - - override fun hide() { - enabled = false - WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "hide")) - } - - override fun updateLines(line1: String?, line2: String?, currentLine: Int) { - WidgetDataWriter.writeString("curJpLrc", line1) - WidgetDataWriter.writeString("nextJpLrc", line2) - WidgetDataWriter.writeInt("pipCurrentLine", currentLine) - WidgetDataWriter.writeString("lyricLine1", line1) - WidgetDataWriter.writeString("lyricLine2", line2) - WidgetDataWriter.writeInt("currentLine", currentLine) - WidgetDataWriter.post( - IOS_NOTIFICATION_DESKTOP_LYRIC, - mapOf( - "line1" to (line1 ?: ""), - "line2" to (line2 ?: ""), - "currentLine" to currentLine.toString(), - ), - ) - } - - override fun setPlaying(playing: Boolean) { - WidgetDataWriter.writeBool("pipIsPlaying", playing) - WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("playing" to playing.toString())) - } -} - -class IosHomeWidgetController : HomeWidgetController { - override fun update( - songName: String?, - artist: String?, - isPlaying: Boolean, - favorite: Boolean, - lyricLine1: String?, - lyricLine2: String?, - currentLine: Int, - coverPath: String?, - playText: String, - ) { - WidgetDataWriter.writeString("songName", songName ?: "") - WidgetDataWriter.writeString("songArtist", artist ?: "") - WidgetDataWriter.writeBool("songFavorite", favorite) - WidgetDataWriter.writeBool("isPlaying", isPlaying) - WidgetDataWriter.writeString("playText", playText) - WidgetDataWriter.writeString("lyricLine1", lyricLine1) - WidgetDataWriter.writeString("lyricLine2", lyricLine2) - WidgetDataWriter.writeInt("currentLine", currentLine) - WidgetDataWriter.writeString("bgColor", "255,255,255") - coverPath?.let { WidgetDataWriter.writeShareImage(it) } - WidgetDataWriter.reloadWidgets() - } - - override fun setShutdown(shutdown: Boolean) { - WidgetDataWriter.writeBool("isShutdown", shutdown) - if (shutdown) { - WidgetDataWriter.writeBool("isPlaying", false) - } - WidgetDataWriter.reloadWidgets() - } - - override fun refresh() { - WidgetDataWriter.reloadWidgets() - } -} - -class IosShareBridge : ShareBridge { - override fun shareText(text: String, title: String?) { - WidgetDataWriter.writeString("pendingShareText", text) - WidgetDataWriter.writeString("pendingShareTitle", title ?: "") - WidgetDataWriter.writeString("pendingShareUrl", "") - WidgetDataWriter.post( - IOS_NOTIFICATION_SHARE, - mapOf("type" to "text", "text" to text, "title" to (title ?: "")), - ) - } - - override fun shareUrl(url: String, title: String?) { - WidgetDataWriter.writeString("pendingShareUrl", url) - WidgetDataWriter.writeString("pendingShareTitle", title ?: "") - WidgetDataWriter.writeString("pendingShareText", "") - WidgetDataWriter.post( - IOS_NOTIFICATION_SHARE, - mapOf("type" to "url", "url" to url, "title" to (title ?: "")), - ) - } -} - -class IosDeepLinkHandler : DeepLinkHandler { - var lastPayload: DeepLinkPayload? = null - private set - - override fun parse(url: String): DeepLinkPayload? = DeepLinkParser.parse(url) - - override fun handle(url: String) { - val payload = parse(url) ?: return - lastPayload = payload - WidgetDataWriter.writeString("pendingDeepLink", url) - WidgetDataWriter.post(IOS_NOTIFICATION_DEEP_LINK, mapOf("url" to url)) - } -} - -class IosCarPlayHook : CarPlayHook { - private var catalog: CarPlayCatalog = CarPlayCatalog() - private val json = Json { ignoreUnknownKeys = true } - - override fun bind(catalog: CarPlayCatalog) { - this.catalog = catalog - } - - override fun refreshCatalog() { - WidgetDataWriter.writeString("carplay_nowPlaying", catalog.nowPlayingTitle) - WidgetDataWriter.writeString("carplay_nowPlayingCover", catalog.nowPlayingCover ?: "") - WidgetDataWriter.writeString("carplay_groups", json.encodeToString(catalog.groups)) - WidgetDataWriter.writeString("carplay_love", json.encodeToString(catalog.loveSongs)) - WidgetDataWriter.writeString("carplay_menus", json.encodeToString(catalog.menus)) - WidgetDataWriter.post(IOS_NOTIFICATION_CARPLAY, mapOf("action" to "refresh")) - } -} - -class IosPiPHook : PictureInPictureHook { - override fun enterPiP() { - WidgetDataWriter.post(IOS_NOTIFICATION_PIP, mapOf("action" to "enter")) - } -} - -class IosMediaSessionBridge : MediaSessionHook { - override fun updateSession(title: String?, artist: String?, isPlaying: Boolean) { - WidgetDataWriter.writeString("songName", title ?: "") - WidgetDataWriter.writeString("songArtist", artist ?: "") - WidgetDataWriter.writeBool("isPlaying", isPlaying) - } -} - -fun installIosPlatformServices() { - PlatformServices.install( - desktopLyric = IosDesktopLyricController(), - homeWidget = IosHomeWidgetController(), - deepLink = IosDeepLinkHandler(), - usbMount = NoOpUsbMount, - share = IosShareBridge(), - mediaSession = IosMediaSessionBridge(), - analytics = NoOpAnalytics, - carPlay = IosCarPlayHook(), - pip = IosPiPHook(), - ) -} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.ios.kt b/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.ios.kt deleted file mode 100644 index 26d01ec..0000000 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/platform/PlatformUi.ios.kt +++ /dev/null @@ -1,38 +0,0 @@ -package top.zhushenwudi.llmp.platform - -import platform.Foundation.NSURL -import platform.UIKit.UIApplication -import kotlin.system.exitProcess - -actual object PlatformUi { - actual fun openQrScanner(onResult: (String) -> Unit) { - // Camera QR on iOS is hosted by Swift; common UI still allows IP paste. - onResult("") - } - - actual fun openWebView(url: String, title: String?) { - val nsUrl = NSURL.URLWithString(url) ?: return - UIApplication.sharedApplication.openURL(nsUrl) - } - - actual fun requestOverlayPermission() = Unit - - actual fun moveTaskToBack() = Unit - - actual fun showToast(message: String) { - // Native toast hosted by Swift later; no-op for now. - } - - actual fun pickImage(onResult: (String?) -> Unit) { - onResult(null) - } - - actual fun openExternalUrl(url: String) { - val nsUrl = NSURL.URLWithString(url) ?: return - UIApplication.sharedApplication.openURL(nsUrl) - } - - actual fun exitApp() { - exitProcess(0) - } -} diff --git a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.ios.kt b/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.ios.kt deleted file mode 100644 index a953eab..0000000 --- a/shared/src/iosMain/kotlin/top/zhushenwudi/llmp/player/PlayerController.ios.kt +++ /dev/null @@ -1,502 +0,0 @@ -package top.zhushenwudi.llmp.player - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import platform.AVFAudio.AVAudioSession -import platform.AVFAudio.AVAudioSessionCategoryPlayback -import platform.AVFAudio.AVAudioSessionRouteChangeNotification -import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey -import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable -import platform.AVFoundation.AVPlayer -import platform.AVFoundation.AVPlayerItem -import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification -import platform.AVFoundation.AVURLAsset -import platform.AVFoundation.replaceCurrentItemWithPlayerItem -import platform.AVFoundation.seekToTime -import platform.CoreMedia.CMTimeGetSeconds -import platform.CoreMedia.CMTimeMake -import platform.Foundation.NSBundle -import platform.Foundation.NSNotification -import platform.Foundation.NSNotificationCenter -import platform.Foundation.NSNumber -import platform.Foundation.NSURL -import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent -import platform.MediaPlayer.MPMediaItemArtwork -import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle -import platform.MediaPlayer.MPMediaItemPropertyArtist -import platform.MediaPlayer.MPMediaItemPropertyArtwork -import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration -import platform.MediaPlayer.MPMediaItemPropertyTitle -import platform.MediaPlayer.MPNowPlayingInfoCenter -import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime -import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate -import platform.MediaPlayer.MPRemoteCommandCenter -import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess -import platform.UIKit.UIImage -import top.zhushenwudi.llmp.domain.Music -import kotlin.random.Random - -@OptIn(ExperimentalForeignApi::class) -actual class PlayerController { - private val player = AVPlayer() - private val scope = CoroutineScope(Dispatchers.Main.immediate) - private var positionJob: Job? = null - private var endObserver: Any? = null - private var routeObserver: Any? = null - - private val _state = MutableStateFlow(PlayerState()) - actual val state: StateFlow = _state.asStateFlow() - - actual var resolvePlaybackUri: ((Music) -> String?)? = null - actual var resolveCoverUri: ((Music) -> String?)? = null - - private var queue: List = emptyList() - private var loopMode: LoopMode = LoopMode.LIST - private var cachedArtworkMusicId: String? = null - private var cachedArtwork: MPMediaItemArtwork? = null - - init { - setupAudioSession() - setupRemoteCommands() - observePlaybackEnd() - observeAudioRouteChanges() - } - - private fun setupAudioSession() { - val session = AVAudioSession.sharedInstance() - session.setCategory(AVAudioSessionCategoryPlayback, error = null) - session.setActive(true, error = null) - } - - private fun setupRemoteCommands() { - val center = MPRemoteCommandCenter.sharedCommandCenter() - center.playCommand.setEnabled(true) - center.pauseCommand.setEnabled(true) - center.togglePlayPauseCommand.setEnabled(true) - center.nextTrackCommand.setEnabled(true) - center.previousTrackCommand.setEnabled(true) - center.changePlaybackPositionCommand.setEnabled(true) - - center.playCommand.addTargetWithHandler { _ -> - play() - MPRemoteCommandHandlerStatusSuccess - } - center.pauseCommand.addTargetWithHandler { _ -> - pause() - MPRemoteCommandHandlerStatusSuccess - } - center.togglePlayPauseCommand.addTargetWithHandler { _ -> - toggle() - MPRemoteCommandHandlerStatusSuccess - } - center.nextTrackCommand.addTargetWithHandler { _ -> - next() - MPRemoteCommandHandlerStatusSuccess - } - center.previousTrackCommand.addTargetWithHandler { _ -> - previous() - MPRemoteCommandHandlerStatusSuccess - } - center.changePlaybackPositionCommand.addTargetWithHandler { event -> - val posEvent = event as? MPChangePlaybackPositionCommandEvent - if (posEvent != null) { - seekTo((posEvent.positionTime * 1000).toLong()) - } - MPRemoteCommandHandlerStatusSuccess - } - } - - private fun observePlaybackEnd() { - endObserver = NSNotificationCenter.defaultCenter.addObserverForName( - name = AVPlayerItemDidPlayToEndTimeNotification, - `object` = null, - queue = null, - ) { _ -> - onPlaybackEnded() - } - } - - /** Pause when headphones / BT audio route becomes unavailable (unplug / disconnect). */ - private fun observeAudioRouteChanges() { - routeObserver = NSNotificationCenter.defaultCenter.addObserverForName( - name = AVAudioSessionRouteChangeNotification, - `object` = null, - queue = null, - ) { notification: NSNotification? -> - val reason = (notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber) - ?.unsignedLongValue - if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) { - pause() - } - } - } - - private fun onPlaybackEnded() { - when (loopMode) { - LoopMode.SINGLE -> { - seekTo(0) - play() - } - LoopMode.LIST -> { - if (_state.value.index < queue.lastIndex) { - loadIndex(_state.value.index + 1, autoPlay = true) - } else if (queue.isNotEmpty()) { - loadIndex(0, autoPlay = true) - } else { - _state.update { it.copy(isPlaying = false, positionMs = 0) } - } - } - LoopMode.SHUFFLE -> { - if (queue.size <= 1) { - seekTo(0) - play() - } else { - val current = _state.value.index - var nextIndex = Random.nextInt(queue.size) - while (nextIndex == current) { - nextIndex = Random.nextInt(queue.size) - } - loadIndex(nextIndex, autoPlay = true) - } - } - } - } - - private fun startPositionUpdates() { - positionJob?.cancel() - positionJob = scope.launch { - while (isActive) { - val positionMs = currentPositionMs() - val durationMs = currentDurationMs() - _state.update { - it.copy( - positionMs = positionMs, - durationMs = durationMs, - isPlaying = player.rate > 0f, - ) - } - updateNowPlayingInfo() - delay(200) - } - } - } - - private fun currentPositionMs(): Long { - val seconds = CMTimeGetSeconds(player.currentTime()) - if (seconds.isNaN() || seconds < 0) return 0 - return (seconds * 1000).toLong() - } - - private fun currentDurationMs(): Long { - val item = player.currentItem ?: return 0 - val seconds = CMTimeGetSeconds(item.duration) - if (seconds.isNaN() || seconds.isInfinite() || seconds < 0) return 0 - return (seconds * 1000).toLong() - } - - /** - * System Now Playing / Dynamic Island / Control Center / lock screen. - * Artwork + album enable the rich Dynamic Island media capsule. - */ - private fun updateNowPlayingInfo() { - val s = _state.value - val current = s.current ?: return - val title = current.musicName ?: return - val artist = current.artist.orEmpty() - val album = current.albumName.orEmpty() - val durationSec = s.durationMs.coerceAtLeast(0) / 1000.0 - val elapsedSec = s.positionMs.coerceAtLeast(0) / 1000.0 - val rate = if (s.isPlaying) 1.0 else 0.0 - val info = mutableMapOf( - MPMediaItemPropertyTitle to title, - MPMediaItemPropertyArtist to artist, - MPMediaItemPropertyAlbumTitle to album, - MPMediaItemPropertyPlaybackDuration to durationSec, - MPNowPlayingInfoPropertyElapsedPlaybackTime to elapsedSec, - MPNowPlayingInfoPropertyPlaybackRate to rate, - ) - artworkFor(current)?.let { art -> - info[MPMediaItemPropertyArtwork] = art - } - MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = info - } - - private fun artworkFor(music: Music): MPMediaItemArtwork? { - val id = music.musicId - if (id != null && id == cachedArtworkMusicId) return cachedArtwork - val raw = resolveCoverUri?.invoke(music) ?: return null - val path = when { - raw.startsWith("file://") -> raw.removePrefix("file://") - raw.startsWith("http://") || raw.startsWith("https://") -> return null - else -> raw - } - val image = UIImage.imageWithContentsOfFile(path) ?: return null - val artwork = MPMediaItemArtwork(boundsSize = image.size) { _ -> image } - cachedArtworkMusicId = id - cachedArtwork = artwork - return artwork - } - - actual fun setQueue(items: List, startIndex: Int) { - queue = items - loopMode = _state.value.loopMode - if (items.isEmpty()) { - player.pause() - player.replaceCurrentItemWithPlayerItem(null) - _state.update { - it.copy(queue = emptyList(), index = -1, current = null, isPlaying = false) - } - return - } - val index = startIndex.coerceIn(0, items.lastIndex) - loadIndex(index, autoPlay = false) - _state.update { it.copy(queue = items, loopMode = loopMode) } - } - - actual fun play() { - player.play() - _state.update { it.copy(isPlaying = true, error = null) } - startPositionUpdates() - updateNowPlayingInfo() - } - - actual fun pause() { - player.pause() - positionJob?.cancel() - _state.update { it.copy(isPlaying = false) } - updateNowPlayingInfo() - } - - actual fun toggle() { - if (_state.value.isPlaying) pause() else play() - } - - actual fun seekTo(positionMs: Long) { - val time = CMTimeMake(value = positionMs, timescale = 1000) - player.seekToTime(time) - _state.update { it.copy(positionMs = positionMs) } - updateNowPlayingInfo() - } - - actual fun next() { - if (queue.isEmpty()) return - when (loopMode) { - LoopMode.SHUFFLE -> onPlaybackEnded() - else -> { - val next = if (_state.value.index < queue.lastIndex) { - _state.value.index + 1 - } else if (loopMode == LoopMode.LIST) { - 0 - } else { - return - } - loadIndex(next, autoPlay = _state.value.isPlaying) - } - } - } - - actual fun previous() { - if (queue.isEmpty()) return - val pos = currentPositionMs() - if (pos > 3000) { - seekTo(0) - return - } - val prev = if (_state.value.index > 0) { - _state.value.index - 1 - } else if (loopMode == LoopMode.LIST) { - queue.lastIndex - } else { - 0 - } - loadIndex(prev, autoPlay = _state.value.isPlaying) - } - - actual fun playAtIndex(index: Int) { - if (index !in queue.indices) return - loadIndex(index, autoPlay = _state.value.isPlaying) - } - - actual fun setLoopMode(mode: LoopMode) { - loopMode = mode - _state.update { it.copy(loopMode = mode) } - } - - actual fun removeAt(index: Int) { - if (index !in queue.indices) return - if (queue.size == 1) { - clearQueue() - return - } - val wasPlaying = _state.value.isPlaying - val currentIndex = _state.value.index - val removingCurrent = index == currentIndex - val newQueue = queue.toMutableList().also { it.removeAt(index) } - queue = newQueue - if (!removingCurrent) { - val newIdx = when { - index < currentIndex -> currentIndex - 1 - else -> currentIndex - }.coerceIn(0, newQueue.lastIndex) - _state.update { - it.copy(queue = newQueue, index = newIdx, current = newQueue.getOrNull(newIdx)) - } - return - } - val nextIndex = when { - loopMode == LoopMode.SHUFFLE -> Random.nextInt(newQueue.size) - index > newQueue.lastIndex -> 0 - else -> index - } - loadIndex(nextIndex.coerceIn(0, newQueue.lastIndex), autoPlay = wasPlaying) - _state.update { it.copy(queue = newQueue) } - } - - actual fun clearQueue() { - positionJob?.cancel() - player.pause() - player.replaceCurrentItemWithPlayerItem(null) - queue = emptyList() - cachedArtwork = null - cachedArtworkMusicId = null - MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null - _state.update { - it.copy( - queue = emptyList(), - index = -1, - current = null, - isPlaying = false, - positionMs = 0, - durationMs = 0, - ) - } - } - - actual fun playUri(uri: String, title: String?) { - val music = Music( - musicId = "local", - musicName = title ?: uri.substringAfterLast('/'), - musicPath = uri, - ) - queue = listOf(music) - loopMode = _state.value.loopMode - val url = resolveUrl(uri) - if (url == null) { - _state.update { - it.copy( - current = music, - queue = queue, - index = 0, - isPlaying = false, - error = "Cannot resolve playback URL: $uri", - ) - } - return - } - val asset = AVURLAsset(uRL = url, options = null) - val item = AVPlayerItem(asset = asset) - player.replaceCurrentItemWithPlayerItem(item) - player.play() - _state.update { - it.copy( - current = music, - queue = queue, - index = 0, - isPlaying = true, - error = null, - positionMs = 0, - ) - } - startPositionUpdates() - updateNowPlayingInfo() - } - - actual fun release() { - positionJob?.cancel() - player.pause() - player.replaceCurrentItemWithPlayerItem(null) - endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } - endObserver = null - routeObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } - routeObserver = null - queue = emptyList() - _state.value = PlayerState() - } - - private fun loadIndex(index: Int, autoPlay: Boolean) { - val music = queue.getOrNull(index) ?: return - val path = resolvePlaybackUri?.invoke(music) ?: music.musicPath - if (path.isNullOrBlank()) { - _state.update { - it.copy( - index = index, - current = music, - error = "Missing music path", - isPlaying = false, - ) - } - return - } - val url = resolveUrl(path) - if (url == null) { - _state.update { - it.copy( - index = index, - current = music, - error = "Cannot resolve URL: $path", - isPlaying = false, - ) - } - return - } - val asset = AVURLAsset(uRL = url, options = null) - val item = AVPlayerItem(asset = asset) - player.replaceCurrentItemWithPlayerItem(item) - if (autoPlay) { - player.play() - startPositionUpdates() - } else { - player.pause() - positionJob?.cancel() - } - _state.update { - it.copy( - index = index, - current = music, - isPlaying = autoPlay, - error = null, - positionMs = 0, - ) - } - updateNowPlayingInfo() - } - - private fun resolveUrl(raw: String): NSURL? { - return when { - raw.startsWith("http://") || raw.startsWith("https://") || - raw.startsWith("file://") -> NSURL.URLWithString(raw) - raw.startsWith("asset:///") -> { - val name = raw.removePrefix("asset:///").substringBeforeLast('.') - val ext = raw.substringAfterLast('.', "") - val path = NSBundle.mainBundle.pathForResource(name, ext) - path?.let { NSURL.fileURLWithPath(it) } - } - raw.startsWith("asset://") -> { - val name = raw.removePrefix("asset://").trimStart('/').substringBeforeLast('.') - val ext = raw.substringAfterLast('.', "") - val path = NSBundle.mainBundle.pathForResource(name, ext) - path?.let { NSURL.fileURLWithPath(it) } - } - else -> NSURL.fileURLWithPath(raw) - } - } -}