diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..47f2273 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,87 @@ +name: Cross-platform build and tests + +on: + push: + branches: + - main + - "codex/**" + pull_request: + +concurrency: + group: cross-platform-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + name: Android unit tests and debug APK + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + - name: Test and assemble PlayerAgent + run: bash ./gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug + - name: Test and assemble Android Controller + run: bash ./gradlew :ControllerApp:testDebugUnitTest :ControllerApp:assembleDebug + - uses: actions/upload-artifact@v4 + with: + name: PlayerAgent-debug-apk + path: PlayerAgentApp/build/outputs/apk/debug/*.apk + - uses: actions/upload-artifact@v4 + with: + name: Controller-debug-apk + path: ControllerApp/build/outputs/apk/debug/*.apk + + ios: + name: iOS simulator XCTest + runs-on: macos-15 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + - name: Inspect Xcode and available simulators + run: | + xcodebuild -version + xcrun simctl list devices available + - name: Run unsigned simulator tests + run: | + set -euo pipefail + simulator_udid="$( + xcrun simctl list devices available -j | + python3 -c ' + import json + import sys + + runtimes = json.load(sys.stdin).get("devices", {}) + devices = ( + device + for runtime_devices in runtimes.values() + for device in runtime_devices + ) + print(next( + device["udid"] + for device in devices + if device.get("isAvailable") and device.get("name", "").startswith("iPhone") + )) + ' + )" + echo "Using iOS simulator: ${simulator_udid}" + run_ios_tests() { + local attempt="$1" + xcodebuild \ + -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ + -scheme sonyMusic \ + -destination "platform=iOS Simulator,id=${simulator_udid}" \ + -derivedDataPath "${RUNNER_TEMP}/IOSBleFeasibilityDerivedData-${attempt}" \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO \ + test + } + if ! run_ios_tests 1; then + echo "::warning::iOS simulator test attempt 1 failed; restarting the simulator and retrying once" + xcrun simctl shutdown "${simulator_udid}" || true + run_ios_tests 2 + fi diff --git a/.gitignore b/.gitignore index 738fe07..e22bf0c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ Thumbs.db # Logs *.log +*.hprof # Local deploy state tools/deploy/last_deploy.json +tools/deploy/last_successful_deploy.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..51fef87 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Codebase Memory 工作约定 + +本仓库已使用 `codebase-memory-mcp` 建立本地代码知识图谱。当前索引项目名为 +`Volumes-e99bb7e794b5-project-MusicBleController`。 + +## 修改前先查索引 + +对代码进行修改、重构、性能优化或缺陷排查前: + +1. 先调用 `list_projects`,并用 `index_status` 确认当前仓库索引可用。 +2. 索引不存在、状态异常或结果明显陈旧时,调用 `index_repository`,对当前仓库执行完整索引;除非用户明确要求,不生成仓库内持久化图谱文件。 +3. 使用 `get_architecture` 或 `search_graph` 定位模块、类型和符号。 +4. 涉及调用关系、数据流或影响范围时,使用 `trace_path` 和 `detect_changes`。 +5. 使用 `get_code_snippet` 获取精确符号位置,然后必须读取真实源文件再编辑。 + +## 验证原则 + +- 图谱用于结构化发现,不是编译器或测试结果的替代品。 +- 反射、依赖注入、系统回调、动态分派和生成代码可能无法完整建图;重要结论必须用源码、`rg`、构建和测试验证。 +- 字符串、错误信息、配置、脚本和非代码文件可直接使用 `rg`/文件读取工具搜索。 +- 不得仅凭空结果断言“没有调用者”“未使用”或“无影响”;这类结论必须补充源码搜索。 +- 编辑后使用 `detect_changes` 检查结构影响,并运行与改动相关的测试或构建。 +- 如果编辑后的图谱结果仍显示旧代码,重新运行 `index_repository` 后再继续分析。 diff --git a/ControllerApp/build.gradle b/ControllerApp/build.gradle index ea7f2b6..43341ff 100644 --- a/ControllerApp/build.gradle +++ b/ControllerApp/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'kotlin-kapt' android { namespace 'com.example.controllerapp' @@ -9,8 +10,10 @@ android { applicationId 'com.example.controllerapp' minSdk 23 targetSdk 35 - versionCode 1 - versionName '1.0' + versionCode 2 + versionName '2.0' + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -21,4 +24,53 @@ android { kotlinOptions { jvmTarget = '1.8' } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.14' + } + + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + def composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation composeBom + androidTestImplementation composeBom + + implementation "androidx.core:core-ktx:1.13.1" + implementation "androidx.activity:activity-compose:1.9.0" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + implementation "androidx.navigation:navigation-compose:2.7.7" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.3" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3" + implementation "androidx.datastore:datastore-preferences:1.1.1" + implementation "androidx.room:room-runtime:2.6.1" + implementation "androidx.room:room-ktx:2.6.1" + implementation "androidx.media:media:1.7.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + kapt "androidx.room:room-compiler:2.6.1" + + debugImplementation "androidx.compose.ui:ui-tooling" + debugImplementation "androidx.compose.ui:ui-test-manifest" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.json:json:20240303" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1" + + androidTestImplementation "androidx.test:core:1.7.0" + androidTestImplementation "androidx.test:runner:1.7.0" + androidTestImplementation "androidx.test.ext:junit:1.3.0" + androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0" + androidTestImplementation "androidx.compose.ui:ui-test-junit4" } diff --git a/ControllerApp/src/androidTest/java/com/example/controllerapp/ControllerRepositoryProtocolTest.kt b/ControllerApp/src/androidTest/java/com/example/controllerapp/ControllerRepositoryProtocolTest.kt new file mode 100644 index 0000000..6a2774a --- /dev/null +++ b/ControllerApp/src/androidTest/java/com/example/controllerapp/ControllerRepositoryProtocolTest.kt @@ -0,0 +1,347 @@ +package com.example.controllerapp + +import android.content.Context +import android.content.ContextWrapper +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.controllerapp.data.ControllerLogStore +import com.example.controllerapp.data.ControllerPreferences +import com.example.controllerapp.data.PlaybackHistoryDatabase +import com.example.controllerapp.protocol.ControllerProtocolCodec +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.Collections +import java.util.zip.Deflater + +@RunWith(AndroidJUnit4::class) +class ControllerRepositoryProtocolTest { + private lateinit var database: PlaybackHistoryDatabase + private lateinit var repository: ControllerRepository + private lateinit var logStore: ControllerLogStore + private lateinit var root: File + private val commands = Collections.synchronizedList(mutableListOf()) + + @Before + fun setUp() { + val base = ApplicationProvider.getApplicationContext() + root = File(base.cacheDir, "repository-protocol-test-${System.nanoTime()}").apply { + mkdirs() + } + val isolated = object : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getFilesDir(): File = root + } + database = Room.inMemoryDatabaseBuilder( + base, + PlaybackHistoryDatabase::class.java + ).build() + logStore = ControllerLogStore(isolated) + repository = ControllerRepository( + isolated, + ControllerPreferences(isolated), + database.historyDao(), + logStore + ) + repository.attachTransport { bytes -> + commands += JSONObject(String(bytes, Charsets.UTF_8)) + true + } + } + + @After + fun tearDown() { + repository.close() + database.close() + logStore.close() + root.deleteRecursively() + } + + @Test + fun fullLyricsAdoptsNewerSonyGenerationAndPublishesAllLines() = runBlocking { + repository.onTransportReady("Sony", "00:11:22:33:44:55", 517) + repository.handleNotification( + json( + "type" to "trackInfo", + "trackId" to "song-1", + "generation" to 1L, + "title" to "测试歌曲", + "artist" to "QQ 音乐" + ) + ) + repository.handleNotification( + json( + "type" to "clientCapabilitiesAck", + "protocolVersion" to 2, + "albumArtBinary" to true, + "fullLyricsZlib" to true, + "lyricWindow" to true, + "ping" to true, + "transferRetry" to true + ) + ) + await { commands.any { it.optString("cmd") == "GET_FULL_LYRICS" } } + + val windowId = "window-1" + repository.handleNotification( + json( + "type" to "lyricWindowStart", + "trackId" to "song-1", + "transferId" to windowId, + "generation" to 2L, + "count" to 5 + ) + ) + repeat(5) { index -> + repository.handleNotification( + json( + "type" to "lyricWindowChunk", + "trackId" to "song-1", + "transferId" to windowId, + "index" to index + 10, + "timeMs" to index * 1_000L, + "durationMs" to 900L, + "text" to "窗口 ${index + 1}" + ) + ) + } + repository.handleNotification( + json( + "type" to "lyricWindowEnd", + "trackId" to "song-1", + "transferId" to windowId, + "generation" to 2L + ) + ) + await { repository.lyrics.value.windowLines.size == 5 } + assertEquals(2L, repository.playback.value.generation) + + val lines = JSONArray().apply { + repeat(60) { index -> + put( + JSONObject() + .put("index", index) + .put("timeMs", index * 1_000L) + .put("durationMs", 900L) + .put("text", "完整歌词 $index") + ) + } + } + val raw = JSONObject() + .put("format", "zlib-json-v1") + .put("trackId", "song-1") + .put("generation", 2L) + .put("lines", lines) + .toString() + .toByteArray(Charsets.UTF_8) + val compressed = deflate(raw) + val chunks = compressed.toList().chunked(220).map { it.toByteArray() } + val transferId = "lyrics-1" + repository.handleNotification( + json( + "type" to "fullLyricsBinaryStart", + "trackId" to "song-1", + "transferId" to transferId, + "generation" to 2L, + "size" to compressed.size, + "uncompressedSize" to raw.size, + "chunks" to chunks.size, + "count" to 60, + "crc32" to ControllerProtocolCodec.crc32Hex(compressed) + ) + ) + chunks.indices.reversed().forEach { index -> + repository.handleNotification(binaryChunk(index, chunks, chunks[index])) + } + repository.handleNotification( + json( + "type" to "fullLyricsBinaryEnd", + "trackId" to "song-1", + "transferId" to transferId, + "generation" to 2L + ) + ) + + await { repository.lyrics.value.isFinal } + assertEquals(60, repository.lyrics.value.fullLines.size) + assertEquals("zlib-json-v1", repository.lyrics.value.protocolFormat) + assertTrue(repository.lyrics.value.partialFullLines.isEmpty()) + } + + @Test + fun missingBinaryLyricChunkRequestsOnlyTheMissingPacket() = runBlocking { + prepareV2Track("song-retry", generation = 1L) + val lines = JSONArray().apply { + repeat(60) { index -> + put( + JSONObject() + .put("index", index) + .put("timeMs", index * 1_000L) + .put("durationMs", 900L) + .put("text", "缺包测试 $index ${index * 7919L}") + ) + } + } + val raw = JSONObject() + .put("format", "zlib-json-v1") + .put("trackId", "song-retry") + .put("generation", 2L) + .put("lines", lines) + .toString() + .toByteArray(Charsets.UTF_8) + val compressed = deflate(raw) + val chunks = compressed.toList().chunked(80).map { it.toByteArray() } + assertTrue(chunks.size > 2) + val transferId = "lyrics-retry" + repository.handleNotification( + json( + "type" to "fullLyricsBinaryStart", + "trackId" to "song-retry", + "transferId" to transferId, + "generation" to 2L, + "size" to compressed.size, + "uncompressedSize" to raw.size, + "chunks" to chunks.size, + "count" to 60, + "crc32" to ControllerProtocolCodec.crc32Hex(compressed) + ) + ) + chunks.indices.filter { it != 1 }.reversed().forEach { index -> + repository.handleNotification(binaryChunk(index, chunks, chunks[index])) + } + repository.handleNotification( + json( + "type" to "fullLyricsBinaryEnd", + "trackId" to "song-retry", + "transferId" to transferId, + "generation" to 2L + ) + ) + + await { + commands.any { + it.optString("cmd") == "RETRY_TRANSFER" && + it.optString("transferId") == transferId + } + } + val retry = commands.last { + it.optString("cmd") == "RETRY_TRANSFER" && + it.optString("transferId") == transferId + } + assertFalse(retry.optBoolean("retryAll")) + assertEquals(1, retry.getJSONArray("missing").length()) + assertEquals(1, retry.getJSONArray("missing").getInt(0)) + } + + @Test + fun staleMediaErrorsDoNotOverwriteCurrentSongState() = runBlocking { + prepareV2Track("song-current", generation = 5L) + repository.handleNotification( + json( + "type" to "fullLyricsUnavailable", + "trackId" to "song-current", + "generation" to 4L, + "reason" to "stale lyric failure" + ) + ) + repository.handleNotification( + json( + "type" to "lyricWindowUnavailable", + "trackId" to "song-current", + "generation" to 4L, + "reason" to "stale window failure" + ) + ) + repository.handleNotification( + json( + "type" to "albumArtBinaryError", + "id" to "song-current", + "generation" to 4L, + "reason" to "stale artwork failure" + ) + ) + delay(100L) + + assertTrue(repository.lyrics.value.failureReason.isBlank()) + assertTrue(repository.artwork.value.failureReason.isBlank()) + } + + private suspend fun prepareV2Track(trackId: String, generation: Long) { + repository.onTransportReady("Sony", "00:11:22:33:44:55", 517) + repository.handleNotification( + json( + "type" to "trackInfo", + "trackId" to trackId, + "generation" to generation, + "title" to "测试歌曲", + "artist" to "QQ 音乐" + ) + ) + repository.handleNotification( + json( + "type" to "clientCapabilitiesAck", + "protocolVersion" to 2, + "albumArtBinary" to true, + "fullLyricsZlib" to true, + "lyricWindow" to true, + "ping" to true, + "transferRetry" to true + ) + ) + await { repository.playback.value.trackId == trackId } + await { commands.any { it.optString("cmd") == "GET_FULL_LYRICS" } } + } + + private fun json(vararg values: Pair): ByteArray = + JSONObject().apply { values.forEach { (key, value) -> put(key, value) } } + .toString() + .toByteArray(Charsets.UTF_8) + + private fun binaryChunk( + index: Int, + chunks: List, + payload: ByteArray + ): ByteArray = ByteArray(6 + payload.size).also { packet -> + packet[0] = ControllerProtocolCodec.FULL_LYRICS_MAGIC.toByte() + packet[1] = 1 + packet[2] = (index ushr 8).toByte() + packet[3] = index.toByte() + packet[4] = (chunks.size ushr 8).toByte() + packet[5] = chunks.size.toByte() + payload.copyInto(packet, destinationOffset = 6) + } + + private fun deflate(value: ByteArray): ByteArray { + val deflater = Deflater().apply { + setInput(value) + finish() + } + return ByteArrayOutputStream().use { output -> + val buffer = ByteArray(1_024) + while (!deflater.finished()) { + output.write(buffer, 0, deflater.deflate(buffer)) + } + deflater.end() + output.toByteArray() + } + } + + private suspend fun await(condition: () -> Boolean) { + repeat(100) { + if (condition()) return + delay(20L) + } + assertTrue("condition did not become true", condition()) + } +} diff --git a/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt b/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt new file mode 100644 index 0000000..ec1edf0 --- /dev/null +++ b/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt @@ -0,0 +1,71 @@ +package com.example.controllerapp.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.example.controllerapp.ControllerApplication +import com.example.controllerapp.ControllerViewModel +import org.junit.Rule +import org.junit.Test + +class ControllerUiSmokeTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun playerAndFullLyricsNavigationRender() { + setControllerContent() + + compose.onNodeWithText("QQ 音乐").assertIsDisplayed() + compose.onNodeWithTag("compact_lyrics").assertIsDisplayed().performClick() + compose.onNodeWithText("完整歌词").assertIsDisplayed() + } + + @Test + fun historySettingsAndHealthRoutesRenderFromDailyMenu() { + setControllerContent() + + compose.onNodeWithContentDescription("打开菜单").performClick() + compose.onNodeWithText("播放历史").performClick() + compose.onNodeWithText("播放历史").assertIsDisplayed() + compose.onNodeWithText("最近播放").assertIsDisplayed() + compose.onNodeWithContentDescription("返回").performClick() + + compose.onNodeWithContentDescription("打开菜单").performClick() + compose.onNodeWithText("设置").performClick() + compose.onNodeWithText("性能模式").assertIsDisplayed() + compose.onNodeWithText("本地封面增强").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("调试").performScrollTo().performClick() + compose.onNodeWithContentDescription("返回").performClick() + + compose.onNodeWithContentDescription("打开菜单").performClick() + compose.onNodeWithText("调试工具").performClick() + compose.onNodeWithText("启动 RFCOMM 兼容模式") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithText("停止 RFCOMM 并恢复 BLE") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithContentDescription("返回").performClick() + + compose.onNodeWithContentDescription("打开菜单").performClick() + compose.onNodeWithText("系统健康").performClick() + compose.onNodeWithText("系统健康").assertIsDisplayed() + } + + private fun setControllerContent() { + val application = + ApplicationProvider.getApplicationContext() + val viewModel = ControllerViewModel(application) + compose.setContent { + ControllerTheme { + ControllerApp(viewModel) + } + } + } +} diff --git a/ControllerApp/src/debug/AndroidManifest.xml b/ControllerApp/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..fa619e1 --- /dev/null +++ b/ControllerApp/src/debug/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/ControllerApp/src/debug/java/com/example/controllerapp/debug/DebugCommandReceiver.kt b/ControllerApp/src/debug/java/com/example/controllerapp/debug/DebugCommandReceiver.kt new file mode 100644 index 0000000..a675e16 --- /dev/null +++ b/ControllerApp/src/debug/java/com/example/controllerapp/debug/DebugCommandReceiver.kt @@ -0,0 +1,42 @@ +package com.example.controllerapp.debug + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.example.controllerapp.service.ControllerConnectionService + +/** + * A debug-build-only entry point used by ADB smoke and soak tests. + * + * The receiver is intentionally absent from release builds. It lets the test harness exercise + * the same foreground-service command path used by MediaStyle notification actions without + * requiring input injection privileges on heavily restricted Android vendor builds. + */ +class DebugCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val serviceAction = when (intent.getStringExtra(EXTRA_COMMAND)?.uppercase()) { + "PREVIOUS" -> ControllerConnectionService.ACTION_PREVIOUS + "PLAY_PAUSE" -> ControllerConnectionService.ACTION_PLAY_PAUSE + "NEXT" -> ControllerConnectionService.ACTION_NEXT + "RECONNECT" -> ControllerConnectionService.ACTION_RECONNECT + else -> return + } + runCatching { + // The smoke harness brings MainActivity to the foreground first, so the normal + // connection service is already running. startService then only delivers the action. + // Never try to create a connectedDevice FGS from this background-only test receiver: + // Android 14+ correctly rejects that transition. + context.startService( + Intent(context, ControllerConnectionService::class.java).setAction(serviceAction) + ) + }.onFailure { + Log.w(TAG, "Debug command ignored because Controller service is not active", it) + } + } + + companion object { + private const val TAG = "ControllerDebugCommand" + const val EXTRA_COMMAND = "command" + } +} diff --git a/ControllerApp/src/main/AndroidManifest.xml b/ControllerApp/src/main/AndroidManifest.xml index 07cc3ae..45360aa 100644 --- a/ControllerApp/src/main/AndroidManifest.xml +++ b/ControllerApp/src/main/AndroidManifest.xml @@ -15,16 +15,22 @@ + - + + + @@ -38,6 +44,21 @@ + + + + + + diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerApplication.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerApplication.kt new file mode 100644 index 0000000..d672584 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerApplication.kt @@ -0,0 +1,44 @@ +package com.example.controllerapp + +import android.app.Application +import android.content.ComponentCallbacks2 +import com.example.controllerapp.data.ControllerLogStore +import com.example.controllerapp.data.ControllerPreferences +import com.example.controllerapp.data.PlaybackHistoryDatabase + +class ControllerApplication : Application() { + lateinit var preferences: ControllerPreferences + private set + lateinit var database: PlaybackHistoryDatabase + private set + lateinit var logStore: ControllerLogStore + private set + lateinit var repository: ControllerRepository + private set + lateinit var commandGateway: ControllerCommandGateway + private set + + override fun onCreate() { + super.onCreate() + preferences = ControllerPreferences(this) + database = PlaybackHistoryDatabase.create(this) + logStore = ControllerLogStore(this) + repository = ControllerRepository( + context = this, + preferences = preferences, + historyDao = database.historyDao(), + logStore = logStore + ) + commandGateway = ControllerCommandGateway(repository) + } + + override fun onTrimMemory(level: Int) { + repository.trimMemory(level) + super.onTrimMemory(level) + } + + override fun onLowMemory() { + repository.trimMemory(ComponentCallbacks2.TRIM_MEMORY_COMPLETE) + super.onLowMemory() + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt new file mode 100644 index 0000000..ab15899 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt @@ -0,0 +1,44 @@ +package com.example.controllerapp + +import com.example.controllerapp.model.ArtworkQuality + +/** + * The single command surface shared by Compose, MediaSession and debug tooling. + * + * Connection ownership and protocol decoding remain in [ControllerRepository]; callers never + * construct wire JSON or bypass its generation/transfer guards. + */ +class ControllerCommandGateway internal constructor( + private val repository: ControllerRepository +) { + fun playPause() = repository.playPause() + fun previous() = repository.previous() + fun next() = repository.next() + fun seekTo(positionMs: Long) = repository.seekTo(positionMs) + fun setVolume(value: Int) = repository.setVolume(value) + fun requestPlaybackState() = repository.requestPlaybackState() + fun requestVolume() = repository.requestVolume() + + fun requestFullLyrics() = repository.requestFullLyrics() + fun ensureFullLyrics() = repository.ensureFullLyrics() + fun retryLyrics() = repository.retryLyrics() + + fun requestLyricDiagnostic() = repository.requestLyricDiagnostic() + fun requestArtwork(quality: ArtworkQuality = ArtworkQuality.HQ) = + repository.requestArtwork(quality) + + fun retryArtwork() = repository.forceRefreshArtwork() + fun clearArtworkCache() = repository.clearArtworkCache() + + fun syncHistory() = repository.syncHistory() + fun loadMoreHistory() = repository.loadMoreHistory() + fun clearLocalHistory() = repository.clearLocalHistory() + + fun requestSonyLogs() = repository.requestSonyLogs() + fun requestMediaDump() = repository.requestMediaDump() + fun reconnect(reason: String, forceScan: Boolean = false) = + repository.requestReconnect(reason, forceScan) + + fun startLegacyRfcomm() = repository.startLegacyRfcomm() + fun stopLegacyRfcomm() = repository.stopLegacyRfcomm() +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt new file mode 100644 index 0000000..6db69e1 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt @@ -0,0 +1,2899 @@ +package com.example.controllerapp + +import android.content.Context +import android.graphics.Bitmap +import android.os.SystemClock +import android.os.PowerManager +import android.util.Base64 +import com.example.controllerapp.data.ControllerLogStore +import com.example.controllerapp.data.ControllerPreferences +import com.example.controllerapp.data.NowPlayingSnapshotStore +import com.example.controllerapp.data.PlaybackHistoryDao +import com.example.controllerapp.data.PlaybackHistoryEntity +import com.example.controllerapp.media.ArtworkCache +import com.example.controllerapp.media.ArtworkEnhancer +import com.example.controllerapp.media.ArtworkPlaceholderPolicy +import com.example.controllerapp.media.CachedArtwork +import com.example.controllerapp.model.AppExperienceMode +import com.example.controllerapp.model.ArtworkLoadingStage +import com.example.controllerapp.model.ArtworkQuality +import com.example.controllerapp.model.ArtworkState +import com.example.controllerapp.model.ConnectionHealth +import com.example.controllerapp.model.ConnectionPhase +import com.example.controllerapp.model.ConnectionState +import com.example.controllerapp.model.ControllerSettings +import com.example.controllerapp.model.DailyListenStat +import com.example.controllerapp.model.DiagnosticsState +import com.example.controllerapp.model.HistoryState +import com.example.controllerapp.model.LyricDiagnosticState +import com.example.controllerapp.model.LyricDisplayMode +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricLoadingStage +import com.example.controllerapp.model.LyricsState +import com.example.controllerapp.model.PlaybackHistorySession +import com.example.controllerapp.model.PlaybackPerformanceMode +import com.example.controllerapp.model.PlaybackState +import com.example.controllerapp.model.PlaybackStats +import com.example.controllerapp.model.PlaybackTopArtist +import com.example.controllerapp.model.PlaybackTopTrack +import com.example.controllerapp.protocol.CapabilityPolicy +import com.example.controllerapp.protocol.ControllerProtocolCodec +import com.example.controllerapp.protocol.CurrentWordOrderingFence +import com.example.controllerapp.protocol.LyricsPublicationPolicy +import com.example.controllerapp.protocol.MediaIdentityPolicy +import com.example.controllerapp.protocol.ServerCapabilities +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +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.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +interface ControllerServiceActions { + fun requestReconnect(reason: String, forceScan: Boolean = false) + fun stopConnection() + fun startLegacyRfcomm() + fun stopLegacyRfcomm() +} + +class ControllerRepository( + context: Context, + val preferences: ControllerPreferences, + private val historyDao: PlaybackHistoryDao, + val logStore: ControllerLogStore +) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mediaDispatcher: ExecutorCoroutineDispatcher = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "Controller-MediaReducer").apply { isDaemon = true } + }.asCoroutineDispatcher() + private val mediaScope = CoroutineScope(SupervisorJob() + mediaDispatcher) + private val artworkCache = ArtworkCache(appContext) + private val snapshotStore = NowPlayingSnapshotStore(appContext) + private val sequence = AtomicLong(0L) + private val transferLock = Any() + private var commandSender: ((ByteArray) -> Boolean)? = null + private var serviceActions: ControllerServiceActions? = null + private var capabilities = ServerCapabilities() + private var capabilityDecisionReady = false + private var capabilityTimeout: Job? = null + private var currentWordFence = CurrentWordOrderingFence() + private var trackInfoTransfer: JsonChunkTransfer? = null + private var lyricWindowTransfer: LyricWindowTransfer? = null + private var activeLyricWindowRequest: LyricWindowRequest? = null + private var lyricWindowRequestTimeout: Job? = null + private var legacyLyricsTransfer: LegacyLyricsTransfer? = null + private var binaryLyricsTransfer: BinaryLyricsTransfer? = null + private val binaryLyricsRetryCounts = HashMap() + private var activeFullLyricsRequest: FullLyricsRequest? = null + private var fullLyricsRequestStartTimeout: Job? = null + private var fullLyricsTransferTimeout: Job? = null + private var legacyLyricsRetryCount = 0 + private var lastLegacyPartialPublishAtMs = 0L + private val completedFullLyricsKeys = HashSet() + private val delayedLyricsRetryKeys = HashSet() + private val qrcWaitRetryCounts = HashMap() + private val fullLyricsBinaryFallbackKeys = HashSet() + private var secondaryTransfer: SecondaryTransfer? = null + private var secondaryRequestInFlightMode: String? = null + private var secondaryTransferTimeout: Job? = null + private val pendingSecondaryModes = ArrayDeque() + private val completedSecondaryKeys = HashSet() + private val requestedSecondaryKeys = HashSet() + private val secondaryRetryCounts = HashMap() + private var artworkTransfer: ArtworkTransfer? = null + private var artworkRequestTimeout: Job? = null + private var artworkTransferTimeout: Job? = null + private var hqArtworkPrefetchJob: Job? = null + private val artworkRequestRetryCounts = HashMap() + private val artworkRetryCounts = HashMap() + private val artworkPlaceholderRefreshCounts = HashMap() + private var textTransfer: TextTransfer? = null + private var diagnosticTransferTimeout: Job? = null + private val historyTransfers = HashMap() + private val historySyncQueue = ArrayDeque() + private var historySyncTimeout: Job? = null + private var volumeSyncTimeout: Job? = null + private var volumeSyncAttempts = 0 + private val requestedArtwork = HashSet() + private val artworkRestoreEpoch = AtomicLong(0L) + private var lastSnapshotSaveAtMs = 0L + private var logListener: ((String) -> Unit)? = null + + private val _connection = MutableStateFlow(ConnectionState()) + val connection: StateFlow = _connection.asStateFlow() + + private val _playback = MutableStateFlow(PlaybackState()) + val playback: StateFlow = _playback.asStateFlow() + + private val _lyrics = MutableStateFlow(LyricsState()) + val lyrics: StateFlow = _lyrics.asStateFlow() + + private val _artwork = MutableStateFlow(ArtworkState()) + val artwork: StateFlow = _artwork.asStateFlow() + + private val _diagnostics = MutableStateFlow(DiagnosticsState()) + val diagnostics: StateFlow = _diagnostics.asStateFlow() + + private val _history = MutableStateFlow(HistoryState()) + val history: StateFlow = _history.asStateFlow() + + private val _settings = MutableStateFlow(ControllerSettings()) + val settings: StateFlow = _settings.asStateFlow() + + init { + scope.launch { + preferences.settings.collectLatest { value -> + if (_settings.value != value) _settings.value = value + } + } + scope.launch(Dispatchers.IO) { + historyDao.observeRecent().collectLatest { values -> + _history.update { it.copy(sessions = values.map(PlaybackHistoryEntity::toModel)) } + } + } + scope.launch { + snapshotStore.load()?.let { snapshot -> + _playback.value = snapshot.playback + _lyrics.value = LyricsState( + trackId = snapshot.playback.trackId, + currentText = snapshot.lines.firstOrNull()?.text.orEmpty(), + windowLines = snapshot.lines, + loadingStage = LyricLoadingStage.WINDOW_READY + ) + if (snapshot.artworkId.isNotBlank()) { + restoreCachedArtwork(snapshot.artworkId, restored = true) + } + } + } + val listener: (String) -> Unit = { line -> + _diagnostics.update { current -> + current.copy(recentLogs = (current.recentLogs + line).takeLast(200)) + } + } + logListener = listener + logStore.addListener(listener) + } + + fun attachTransport(sender: ((ByteArray) -> Boolean)?) { + commandSender = sender + } + + fun attachServiceActions(actions: ControllerServiceActions?) { + serviceActions = actions + } + + fun updateConnectionPhase( + phase: ConnectionPhase, + deviceName: String = _connection.value.deviceName, + address: String = _connection.value.deviceAddress, + reason: String = "" + ) { + val previous = _connection.value + val health = when (phase) { + ConnectionPhase.CONNECTED -> ConnectionHealth.HEALTHY + ConnectionPhase.DISCONNECTED -> ConnectionHealth.DISCONNECTED + else -> if (previous.health == ConnectionHealth.STALE) { + ConnectionHealth.STALE + } else { + ConnectionHealth.SUSPECT + } + } + _connection.value = previous.copy( + phase = phase, + health = health, + deviceName = deviceName.ifBlank { "Sony PlayerAgent" }, + deviceAddress = address, + characteristicReady = phase == ConnectionPhase.CONNECTED, + lastReconnectReason = reason.ifBlank { previous.lastReconnectReason } + ) + if (phase == ConnectionPhase.DISCONNECTED) { + capabilities = ServerCapabilities() + capabilityDecisionReady = false + capabilityTimeout?.cancel() + } + } + + fun onTransportReady(deviceName: String, address: String, mtu: Int) { + val nextGeneration = _connection.value.generation + 1L + _connection.value = _connection.value.copy( + phase = ConnectionPhase.CONNECTED, + health = ConnectionHealth.HEALTHY, + deviceName = deviceName, + deviceAddress = address, + mtu = mtu, + characteristicReady = true, + generation = nextGeneration, + reconnectAttempt = 0, + lastNotifyElapsedMs = SystemClock.elapsedRealtime() + ) + capabilities = ServerCapabilities() + capabilityDecisionReady = false + resetTransfers("new connection") + currentWordFence = CurrentWordOrderingFence() + sendCapabilities() + scope.launch { + delay(80L) + sendCommand("GET_PLAYBACK_STATE") + delay(80L) + requestVolume() + } + } + + fun markNotifyActivity() { + _connection.update { + it.copy( + health = ConnectionHealth.HEALTHY, + lastNotifyElapsedMs = SystemClock.elapsedRealtime() + ) + } + } + + fun markHealth(value: ConnectionHealth, reason: String = "") { + _connection.update { + it.copy( + health = value, + lastReconnectReason = reason.ifBlank { it.lastReconnectReason } + ) + } + } + + fun setReconnectAttempt(attempt: Int, reason: String) { + _connection.update { + it.copy( + reconnectAttempt = attempt.coerceAtLeast(0), + lastReconnectReason = reason + ) + } + } + + fun recordSelfHealing(action: String) { + if (action.isBlank()) return + logStore.append("[SelfHealing] $action") + _diagnostics.update { + it.copy(selfHealingActions = (it.selfHealingActions + action).takeLast(50)) + } + } + + fun onWriteResult(success: Boolean, payload: ByteArray) { + if (!success) { + val command = runCatching { + JSONObject(String(payload, Charsets.UTF_8)).optString("cmd") + }.getOrDefault("unknown") + reportIssue("命令写入失败:$command") + } + } + + fun handleNotification(value: ByteArray) { + markNotifyActivity() + val snapshot = value.copyOf() + mediaScope.launch { handleNotificationOnMedia(snapshot) } + } + + private fun handleNotificationOnMedia(value: ByteArray) { + when (value.firstOrNull()?.toInt()?.and(0xff)) { + ControllerProtocolCodec.ALBUM_ART_MAGIC -> handleArtworkBinaryChunk(value) + ControllerProtocolCodec.FULL_LYRICS_MAGIC -> handleLyricsBinaryChunk(value) + else -> { + val objectValue = runCatching { + JSONObject(String(value, Charsets.UTF_8)) + }.getOrElse { + reportIssue("状态消息解析失败") + return + } + handleJson(objectValue) + } + } + } + + fun requestReconnect(reason: String = "manual", forceScan: Boolean = true) { + serviceActions?.requestReconnect(reason, forceScan) + } + + fun stopConnection() = serviceActions?.stopConnection() + fun startLegacyRfcomm() = serviceActions?.startLegacyRfcomm() + fun stopLegacyRfcomm() = serviceActions?.stopLegacyRfcomm() + + fun playPause() = sendCommand("PLAY_PAUSE") + fun previous() = sendCommand("PREVIOUS") + fun next() = sendCommand("NEXT") + fun requestPlaybackState() = sendCommand("GET_PLAYBACK_STATE") + fun requestVolume() { + mediaScope.launch { requestVolumeOnMedia(force = false) } + } + + private fun requestVolumeOnMedia(force: Boolean) { + if (!force && volumeSyncTimeout?.isActive == true) return + val sent = sendCommand("GET_VOLUME") + if (!sent) return + volumeSyncTimeout?.cancel() + volumeSyncTimeout = mediaScope.launch { + delay(VOLUME_SYNC_TIMEOUT_MS) + if (volumeSyncAttempts < MAX_VOLUME_SYNC_RETRIES) { + volumeSyncAttempts += 1 + logStore.append("[Volume] response timeout, retry=$volumeSyncAttempts") + requestVolumeOnMedia(force = true) + } else { + volumeSyncAttempts = 0 + volumeSyncTimeout = null + reportIssue("Sony 音量状态响应超时") + } + } + } + fun healthProbe() = + if (_connection.value.serverSupportsV2) sendCommand("PING") else requestPlaybackState() + + fun seekTo(positionMs: Long) = + sendCommand("SEEK_TO", JSONObject().put("position", positionMs.coerceAtLeast(0L))) + + fun setVolume(value: Int) = + sendCommand("SET_VOLUME", JSONObject().put("volume", value.coerceAtLeast(0))) + + fun requestFullLyrics(forceLegacy: Boolean = false) { + mediaScope.launch { requestFullLyricsOnMedia(forceLegacy, force = false) } + } + + fun ensureFullLyrics() { + mediaScope.launch { requestFullLyricsOnMedia(forceLegacy = false, force = false) } + } + + fun retryLyrics() { + mediaScope.launch { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return@launch + cancelFullLyricsTimers() + activeFullLyricsRequest = null + legacyLyricsTransfer = null + binaryLyricsTransfer = null + legacyLyricsRetryCount = 0 + fullLyricsBinaryFallbackKeys.remove(lyricsKey(trackId, _playback.value.generation)) + _lyrics.update { + it.copy( + partialFullLines = emptyList(), + loadingStage = LyricLoadingStage.WAITING_QQ_QRC, + receivedChunks = 0, + expectedChunks = 0, + transferId = "", + retryCount = 0, + failureReason = "" + ) + } + requestLyricWindowOnMedia(force = true) + requestFullLyricsOnMedia(forceLegacy = false, force = true) + } + } + + private fun requestFullLyricsOnMedia(forceLegacy: Boolean, force: Boolean) { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + if (!capabilityDecisionReady) { + logStore.append("[Lyrics] request deferred until capability decision trackId=$trackId") + return + } + val generation = _playback.value.generation + val key = lyricsKey(trackId, generation) + if (!force && (completedFullLyricsKeys.contains(key) || + (_lyrics.value.trackId == trackId && _lyrics.value.isFinal) + ) + ) { + return + } + val now = SystemClock.elapsedRealtime() + val active = activeFullLyricsRequest + if (!force && active != null && active.trackId == trackId && + MediaIdentityPolicy.generationMatches(active.generation, generation) && + now - active.sentAtElapsedMs < FULL_LYRICS_REQUEST_DEDUP_MS + ) { + logStore.append("[Lyrics] request deduplicated trackId=$trackId") + return + } + if (forceLegacy) fullLyricsBinaryFallbackKeys += key + val useBinary = !forceLegacy && + key !in fullLyricsBinaryFallbackKeys && capabilities.fullLyricsZlib + val extra = JSONObject() + .put("trackId", trackId) + .put("positionMs", displayedPositionMs()) + .put("includeWordsAroundCurrent", true) + if (useBinary) { + extra.put("format", "zlib-json-v1") + } + val attempt = if (active?.trackId == trackId) active.attempt + 1 else 1 + activeFullLyricsRequest = FullLyricsRequest( + trackId = trackId, + generation = generation, + sentAtElapsedMs = now, + format = if (useBinary) "zlib-json-v1" else "legacy", + attempt = attempt + ) + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FULL_LYRICS, + protocolFormat = if (useBinary) "zlib-json-v1" else "legacy", + retryCount = (attempt - 1).coerceAtLeast(0), + failureReason = "" + ) + } + val sent = sendCommand("GET_FULL_LYRICS", extra) + logStore.append( + "[Lyrics] request trackId=$trackId generation=$generation " + + "format=${if (useBinary) "zlib-json-v1" else "legacy"} attempt=$attempt sent=$sent" + ) + if (sent) { + scheduleFullLyricsStartTimeout(activeFullLyricsRequest ?: return) + } else { + activeFullLyricsRequest = null + _lyrics.update { + it.copy(loadingStage = LyricLoadingStage.FAILED, failureReason = "完整歌词命令发送失败") + } + } + } + + fun requestLyricWindow() { + mediaScope.launch { requestLyricWindowOnMedia(force = false) } + } + + private fun requestLyricWindowOnMedia(force: Boolean) { + val trackId = _playback.value.trackId + if (trackId.isBlank() || !capabilities.lyricWindow) return + val generation = _playback.value.generation + val now = SystemClock.elapsedRealtime() + val active = activeLyricWindowRequest + if (!force && ((lyricWindowTransfer?.trackId == trackId) || + (active?.trackId == trackId && + MediaIdentityPolicy.generationMatches(active.generation, generation) && + now - active.sentAtElapsedMs < LYRIC_WINDOW_REQUEST_DEDUP_MS)) + ) return + val attempt = if (active?.trackId == trackId) active.attempt + 1 else 1 + val sent = sendCommand( + "GET_LYRIC_WINDOW", + JSONObject() + .put("trackId", trackId) + .put("positionMs", displayedPositionMs()) + ) + if (!sent) return + activeLyricWindowRequest = LyricWindowRequest(trackId, generation, now, attempt) + lyricWindowRequestTimeout?.cancel() + lyricWindowRequestTimeout = mediaScope.launch { + delay(LYRIC_WINDOW_START_TIMEOUT_MS) + val waiting = activeLyricWindowRequest ?: return@launch + if (_playback.value.trackId != waiting.trackId || + waiting.sentAtElapsedMs != now || lyricWindowTransfer != null || + _lyrics.value.isFinal + ) return@launch + if (waiting.attempt <= MAX_LYRIC_WINDOW_RETRIES) { + logStore.append( + "[Lyrics] window start timeout trackId=${waiting.trackId} retry=${waiting.attempt}" + ) + requestLyricWindowOnMedia(force = true) + } else { + activeLyricWindowRequest = null + logStore.append("[Lyrics] window unavailable after retry trackId=${waiting.trackId}") + } + } + } + + fun requestSecondary(mode: String) { + mediaScope.launch { enqueueSecondaryOnMedia(mode) } + } + + private fun enqueueSecondaryOnMedia(mode: String) { + val trackId = _playback.value.trackId + if (trackId.isBlank() || !_lyrics.value.isFinal || mode !in SECONDARY_MODES) return + val key = secondaryKey(trackId, mode) + if (key in completedSecondaryKeys || key in requestedSecondaryKeys || + pendingSecondaryModes.contains(mode) || secondaryTransfer?.mode == mode + ) return + requestedSecondaryKeys += key + pendingSecondaryModes += mode + requestNextSecondaryOnMedia() + } + + fun requestLyricDiagnostic() { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + sendCommand("GET_LYRIC_DIAGNOSTIC", JSONObject().put("trackId", trackId)) + } + + fun requestArtwork(quality: ArtworkQuality, forceRefresh: Boolean = false) { + mediaScope.launch { requestArtworkOnMedia(quality, forceRefresh) } + } + + private fun requestArtworkOnMedia( + quality: ArtworkQuality, + forceRefresh: Boolean = false + ) { + val id = _playback.value.trackId + if (id.isBlank()) return + val wireQuality = if (quality == ArtworkQuality.HQ) "hq" else "preview" + val requestKey = "$id|$wireQuality" + if (!forceRefresh && requestKey in requestedArtwork) { + logStore.append("[Artwork] request deduplicated id=$id quality=$wireQuality") + return + } + requestedArtwork += requestKey + _artwork.update { + it.copy( + artworkId = id, + loadingStage = if (quality == ArtworkQuality.HQ) { + ArtworkLoadingStage.HQ + } else { + ArtworkLoadingStage.PREVIEW + }, + failureReason = "" + ) + } + val sent = sendCommand( + "ALBUM_ART_REQUEST", + JSONObject() + .put("id", id) + .put("quality", wireQuality) + .put("forceRefresh", forceRefresh) + ) + if (!sent) { + requestedArtwork.remove(requestKey) + handleArtworkFailure("封面命令发送失败") + return + } + scheduleArtworkRequestTimeout(id, quality) + logStore.append("[Artwork] request id=$id quality=$wireQuality force=$forceRefresh") + } + + fun forceRefreshArtwork() { + mediaScope.launch { + val id = _playback.value.trackId + if (id.isBlank()) return@launch + val iterator = requestedArtwork.iterator() + while (iterator.hasNext()) { + if (iterator.next().startsWith("$id|")) iterator.remove() + } + artworkRequestTimeout?.cancel() + artworkTransferTimeout?.cancel() + hqArtworkPrefetchJob?.cancel() + hqArtworkPrefetchJob = null + artworkTransfer = null + requestArtworkOnMedia(ArtworkQuality.PREVIEW, forceRefresh = true) + } + } + + fun requestSonyLogs(limit: Int = 80) { + mediaScope.launch { + requestRemoteText( + command = "GET_LOGS", + kind = "log", + extra = JSONObject().put("limit", limit.coerceIn(1, 200)) + ) + } + } + + fun requestMediaDump() { + mediaScope.launch { requestRemoteText("DUMP_MEDIA_FIELDS", "dump") } + } + + fun syncHistory() { + if (_history.value.loading) return + _history.update { it.copy(loading = true, status = "正在同步") } + scope.launch(Dispatchers.IO) { + synchronized(historySyncQueue) { + historySyncQueue.clear() + historySyncQueue.addAll(listOf("TODAY", "WEEK", "MONTH")) + } + val after = historyDao.latestSessionId() ?: 0L + mediaScope.launch { requestHistorySinceOnMedia(after) } + } + } + + fun loadMoreHistory() { + mediaScope.launch { + if (_history.value.loading || !_history.value.hasMore) return@launch + val before = _history.value.sessions.minOfOrNull { it.sessionId } ?: Long.MAX_VALUE + _history.update { it.copy(loading = true, status = "正在加载更多") } + val sent = sendCommand( + "GET_PLAY_HISTORY_PAGE", + JSONObject() + .put("requestId", requestId("page")) + .put("beforeSessionId", before) + .put("limit", 50) + ) + if (sent) { + scheduleHistoryTimeout("更多历史") + } else { + _history.update { it.copy(loading = false, status = "Sony 未连接") } + } + } + } + + fun clearLocalHistory() { + scope.launch(Dispatchers.IO) { + historyDao.clear() + _history.update { it.copy(stats = emptyMap(), status = "本地缓存已清理") } + } + } + + fun clearArtworkCache() { + val id = _playback.value.trackId + scope.launch { + if (id.isNotBlank()) artworkCache.remove(id) + _artwork.update { + it.copy( + bitmap = null, + quality = ArtworkQuality.PLACEHOLDER, + enhancementMessage = "缓存已清理" + ) + } + } + } + + fun clearLogs() { + logStore.clear() + _diagnostics.update { it.copy(recentLogs = emptyList()) } + } + + fun updateExperienceMode(value: AppExperienceMode) { + scope.launch { preferences.setExperienceMode(value) } + } + + fun updatePerformanceMode(value: PlaybackPerformanceMode) { + scope.launch { preferences.setPerformanceMode(value) } + } + + fun updateAutoReconnect(value: Boolean) { + scope.launch { preferences.setAutoReconnect(value) } + } + + fun updateLyricOffset(value: Long) { + scope.launch { preferences.setLyricOffsetMs(value) } + } + + fun updateLyricMode(value: LyricDisplayMode) { + scope.launch { preferences.setLyricDisplayMode(value) } + if (value.showsTranslation) requestSecondary("translation") + if (value.showsRomanization) requestSecondary("romanization") + } + + fun updateArtworkSize(value: Int) { + scope.launch { preferences.setArtworkDisplaySizeDp(value) } + } + + fun updateArtworkEnhancement(value: Boolean) { + scope.launch { preferences.setArtworkEnhancementEnabled(value) } + } + + fun displayedPositionMs(nowElapsedMs: Long = SystemClock.elapsedRealtime()): Long { + val state = _playback.value + if (!state.isPlaying || state.receivedAtElapsedMs <= 0L) return state.positionMs + return (state.positionMs + nowElapsedMs - state.receivedAtElapsedMs) + .coerceIn(0L, state.durationMs.coerceAtLeast(0L)) + } + + fun trimMemory(level: Int) = artworkCache.trimMemory(level) + + suspend fun historyArtwork(artworkId: String): Bitmap? { + if (artworkId.isBlank()) return null + return artworkCache.load(artworkId, ArtworkQuality.HQ, 128)?.bitmap + ?: artworkCache.load(artworkId, ArtworkQuality.PREVIEW, 128)?.bitmap + } + + fun close() { + logListener?.let(logStore::removeListener) + logListener = null + commandSender = null + serviceActions = null + scope.cancel() + mediaScope.cancel() + mediaDispatcher.close() + } + + private fun sendCapabilities() { + capabilityDecisionReady = false + val extra = JSONObject() + .put("protocolVersion", 2) + .put("albumArtBinary", true) + .put("fullLyricsZlib", true) + .put("lyricWindow", true) + .put("ping", true) + .put("transferRetry", true) + sendCommand("CLIENT_CAPABILITIES", extra) + capabilityTimeout?.cancel() + capabilityTimeout = scope.launch { + delay(CapabilityPolicy.ACK_TIMEOUT_MS) + mediaScope.launch { + val fallback = CapabilityPolicy.fallbackIfUnacknowledged(capabilities) + if (fallback != capabilities) { + capabilities = fallback + capabilityDecisionReady = true + _connection.update { + it.copy(serverProtocolVersion = 1, serverSupportsV2 = false) + } + logStore.append("[Protocol] capability ACK timeout, legacy fallback") + requestCurrentMediaPayloadsOnMedia() + } + } + } + } + + private fun sendCommand(command: String, extra: JSONObject = JSONObject()): Boolean { + val sender = commandSender ?: return false + val value = JSONObject() + .put("cmd", command) + .put("seq", sequence.incrementAndGet().toString()) + .put("time", System.currentTimeMillis()) + extra.keys().forEach { key -> value.put(key, extra.opt(key)) } + val bytes = value.toString().toByteArray(Charsets.UTF_8) + val sent = sender(bytes) + if (!sent) reportIssue("命令未发送:$command") + return sent + } + + private fun handleJson(value: JSONObject) { + when (val type = value.optString("type")) { + "clientCapabilitiesAck" -> handleCapabilities(value) + "pong" -> markHealth(ConnectionHealth.HEALTHY) + "playbackState" -> handlePlayback(value) + "trackInfo" -> applyTrackInfo(value) + "trackInfoStart" -> { + trackInfoTransfer = JsonChunkTransfer( + expectedSize = value.optInt("size"), + expectedChunks = value.optInt("chunks") + ) + } + "trackInfoChunk" -> appendJsonChunk(trackInfoTransfer, value) + "trackInfoEnd" -> finishTrackInfoTransfer() + "currentWord" -> handleCurrentWord(value) + "volumeState" -> handleVolume(value) + "lyricWindowStart" -> startLyricWindow(value) + "lyricWindowChunk" -> appendLyricWindow(value) + "lyricWindowEnd" -> finishLyricWindow(value) + "lyricWindowUnavailable" -> handleLyricWindowUnavailable(value) + "fullLyricsStart" -> startLegacyLyrics(value) + "fullLyricsChunk" -> appendLegacyLyrics(value) + "fullLyricsEnd" -> finishLegacyLyrics(value) + "fullLyricsUnavailable" -> handleLyricsUnavailable(value) + "fullLyricsBinaryStart" -> startBinaryLyrics(value) + "fullLyricsBinaryEnd" -> finishBinaryLyrics(value) + "fullLyricsBinaryError" -> fallbackBinaryLyrics(value.optString("reason")) + "lyricSecondaryStart" -> startSecondary(value) + "lyricSecondaryPart" -> appendSecondary(value) + "lyricSecondaryEnd" -> finishSecondary(value) + "lyricSecondaryUnavailable", "lyricSecondaryError" -> + handleSecondaryUnavailable(value) + "lyricDiagnostic" -> handleLyricDiagnostic(value) + "lyricDiagnosticUnavailable" -> reportIssue( + "歌词诊断不可用:${value.optString("reason")}" + ) + "albumArtOffer" -> handleArtworkOffer(value.optString("id")) + "albumArtStart" -> startLegacyArtwork(value) + "albumArtChunk" -> appendLegacyArtwork(value) + "albumArtEnd" -> finishLegacyArtwork(value) + "albumArtBinaryStart" -> startBinaryArtwork(value) + "albumArtBinaryEnd" -> finishBinaryArtwork(value) + "albumArtBinaryError" -> handleArtworkProtocolError(value) + "albumArtUnavailable" -> handleArtworkUnavailable(value) + "logStart", "mediaFieldDumpStart" -> startTextTransfer(type, value) + "logChunk", "mediaFieldDumpChunk" -> appendTextTransfer(type, value) + "logEnd", "mediaFieldDumpEnd" -> finishTextTransfer(type, value) + "mediaFieldDumpError" -> finishRemoteTextWithError( + "Media Dump失败:${value.optString("message")}" + ) + "playHistoryPage", "playHistorySince", "playStats" -> handleHistoryPayload(value) + "historyPayloadStart" -> startHistoryPayload(value) + "historyPayloadChunk" -> appendHistoryPayload(value) + "historyPayloadEnd" -> finishHistoryPayload(value) + "playHistoryError" -> { + historySyncTimeout?.cancel() + historySyncTimeout = null + synchronized(historySyncQueue) { historySyncQueue.clear() } + _history.update { + it.copy( + loading = false, + status = value.optString("message", "同步失败") + ) + } + } + else -> logStore.append("[Protocol] unsupported type=$type") + } + } + + private fun handleCapabilities(value: JSONObject) { + capabilities = ServerCapabilities( + negotiated = true, + protocolVersion = value.optInt("protocolVersion", 1), + albumArtBinary = value.optBoolean("albumArtBinary", true), + fullLyricsZlib = value.optBoolean("fullLyricsZlib"), + lyricWindow = value.optBoolean("lyricWindow"), + ping = value.optBoolean("ping"), + transferRetry = value.optBoolean("transferRetry") + ) + capabilityDecisionReady = true + capabilityTimeout?.cancel() + _connection.update { + it.copy( + serverProtocolVersion = capabilities.protocolVersion, + serverSupportsV2 = capabilities.protocolVersion >= 2 + ) + } + requestCurrentMediaPayloadsOnMedia() + logStore.append("[Protocol] V2 capability negotiated") + } + + private fun requestCurrentMediaPayloadsOnMedia() { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + requestLyricWindowOnMedia(force = false) + requestFullLyricsOnMedia(forceLegacy = false, force = false) + if (_artwork.value.artworkId != trackId || _artwork.value.bitmap == null) { + requestArtworkOnMedia(ArtworkQuality.PREVIEW) + } + } + + private fun handlePlayback(value: JSONObject) { + val previous = _playback.value + val trackId = previous.trackId + val state = previous.copy( + isPlaying = value.optBoolean("playing"), + positionMs = value.optLong("position").coerceAtLeast(0L), + durationMs = value.optLong("duration").coerceAtLeast(0L), + receivedAtElapsedMs = SystemClock.elapsedRealtime(), + restoredSnapshot = false + ) + _playback.value = state + val lyricText = value.optString("lyric") + if (lyricText.isNotBlank()) { + val previousLyricText = _lyrics.value.currentText + _lyrics.update { + if (it.trackId == trackId) it.copy(currentText = lyricText) else it + } + val lyricState = _lyrics.value + val retryKey = lyricsKey(trackId, _playback.value.generation) + if (!lyricState.isFinal && trackId.isNotBlank() && + (lyricState.lines.isEmpty() || lyricState.loadingStage == LyricLoadingStage.FAILED || + lyricState.loadingStage == LyricLoadingStage.WAITING_QQ_QRC) && + (previousLyricText.isBlank() || retryKey !in delayedLyricsRetryKeys) + ) { + delayedLyricsRetryKeys += retryKey + mediaScope.launch { + delay(200L) + if (_playback.value.trackId == trackId && !_lyrics.value.isFinal) { + requestLyricWindowOnMedia(force = true) + requestFullLyricsOnMedia(forceLegacy = false, force = true) + } + } + } + } + val lyricStatus = value.optString("lyricStatus") + if (lyricStatus.isNotBlank()) { + _diagnostics.update { + it.copy( + lyricDiagnostic = LyricDiagnosticState( + status = lyricStatus, + reason = value.optString("lyricReason"), + suggestion = value.optString("lyricSuggestion") + ) + ) + } + } + saveSnapshotDebounced() + } + + private fun applyTrackInfo(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId.isBlank()) { + _playback.value = PlaybackState() + _lyrics.value = LyricsState() + _artwork.value = ArtworkState() + scope.launch { snapshotStore.clear() } + resetTransfers("no active track") + return + } + val old = _playback.value + val incomingGeneration = value.optLong("generation") + val changed = MediaIdentityPolicy.isNewMedia( + currentTrackId = old.trackId, + currentGeneration = old.generation, + incomingTrackId = trackId, + incomingGeneration = incomingGeneration + ) + _playback.value = old.copy( + trackId = trackId, + generation = incomingGeneration.takeIf { it > 0L } ?: old.generation, + title = value.optString("title").ifBlank { "-" }, + artist = value.optString("artist").ifBlank { "-" }, + album = value.optString("album").ifBlank { "-" }, + restoredSnapshot = false + ) + _artwork.update { + if (it.artworkId == trackId) it.copy(restoredSnapshot = false) else it + } + if (changed) { + resetMediaForTrack(trackId) + restoreCachedArtwork(trackId, restored = false) + scope.launch { + delay(40L) + mediaScope.launch { + if (capabilityDecisionReady) requestCurrentMediaPayloadsOnMedia() + } + } + } else if (_artwork.value.artworkId != trackId || _artwork.value.bitmap == null) { + restoreCachedArtwork(trackId, restored = false) + requestArtwork(ArtworkQuality.PREVIEW) + } + saveSnapshotDebounced() + } + + private fun handleCurrentWord(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId.isBlank() || trackId != _playback.value.trackId) return + val accepted = currentWordFence.accept( + incomingGeneration = value.optLong("generation", -1L), + incomingSequence = value.optLong("seq", -1L), + incomingPositionMs = value.optLong("position", -1L) + ) ?: return + currentWordFence = accepted + _lyrics.update { + if (it.trackId != trackId) { + it + } else { + it.copy( + generation = value.optLong("generation", it.generation), + currentLineIndex = value.optInt("line", it.currentLineIndex), + currentWordIndex = value.optInt("word", it.currentWordIndex), + currentWordSequence = value.optLong("seq", it.currentWordSequence), + currentWordPositionMs = value.optLong( + "position", + it.currentWordPositionMs + ) + ) + } + } + } + + private fun handleVolume(value: JSONObject) { + volumeSyncTimeout?.cancel() + volumeSyncTimeout = null + volumeSyncAttempts = 0 + _playback.update { + it.copy( + volumeCurrent = value.optInt("current").coerceAtLeast(0), + volumeMax = value.optInt("max").coerceAtLeast(0) + ) + } + } + + private fun startLyricWindow(value: JSONObject) { + val trackId = value.optString("trackId") + val count = value.optInt("count") + val generation = value.optLong("generation") + if (!adoptIncomingGeneration(trackId, generation) || + count !in 1..5 + ) { + return + } + lyricWindowRequestTimeout?.cancel() + lyricWindowRequestTimeout = null + activeLyricWindowRequest = null + lyricWindowTransfer = LyricWindowTransfer( + trackId = trackId, + transferId = value.optString("transferId"), + generation = generation, + expectedCount = count + ) + } + + private fun appendLyricWindow(value: JSONObject) { + val transfer = lyricWindowTransfer ?: return + if (!transfer.matches(value, _playback.value.trackId)) return + ControllerProtocolCodec.decodeLyricLine(value)?.let { + transfer.lines[it.index] = it + } + } + + private fun finishLyricWindow(value: JSONObject) { + val transfer = lyricWindowTransfer ?: return + lyricWindowTransfer = null + if (!transfer.matches(value, _playback.value.trackId) || + value.optLong("generation") != transfer.generation || + !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + transfer.generation + ) || + transfer.lines.size != transfer.expectedCount + ) { + return + } + publishLyricWindow(transfer.lines.values.sortedBy(LyricLine::index)) + } + + private fun handleLyricWindowUnavailable(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId.isNotBlank() && trackId != _playback.value.trackId) return + val generation = value.optLong("generation") + if (generation > 0L && !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + generation + ) + ) return + lyricWindowTransfer = null + lyricWindowRequestTimeout?.cancel() + lyricWindowRequestTimeout = null + activeLyricWindowRequest = null + logStore.append( + "[Lyrics] window unavailable trackId=${_playback.value.trackId} " + + "reason=${value.optString("reason")}" + ) + } + + private fun startLegacyLyrics(value: JSONObject) { + val trackId = value.optString("trackId") + val count = value.optInt("count") + if (trackId != _playback.value.trackId || count !in 1..MAX_FULL_LYRIC_LINES) return + fullLyricsRequestStartTimeout?.cancel() + fullLyricsRequestStartTimeout = null + binaryLyricsTransfer = null + legacyLyricsTransfer = LegacyLyricsTransfer(trackId, count) + lastLegacyPartialPublishAtMs = 0L + scheduleFullLyricsTransferTimeout(trackId, transferId = "legacy") + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FULL_LYRICS, + receivedChunks = 0, + expectedChunks = count, + transferId = "legacy", + protocolFormat = "legacy" + ) + } + logStore.append("[Lyrics] legacy start trackId=$trackId lines=$count") + } + + private fun appendLegacyLyrics(value: JSONObject) { + val transfer = legacyLyricsTransfer ?: return + if (value.optString("trackId") != transfer.trackId || + transfer.trackId != _playback.value.trackId + ) { + return + } + ControllerProtocolCodec.decodeLyricLine(value)?.let { + if (it.index in 0 until transfer.expectedCount) { + transfer.lines[it.index] = it + if (it.index == 0 || + it.index == transfer.expectedCount - 1 || + it.index % 10 == 0 + ) { + _lyrics.update { state -> + state.copy( + receivedChunks = transfer.lines.size, + expectedChunks = transfer.expectedCount + ) + } + } + val now = SystemClock.elapsedRealtime() + if (transfer.lines.size >= 3 && + now - lastLegacyPartialPublishAtMs >= PARTIAL_LYRICS_PUBLISH_MS + ) { + lastLegacyPartialPublishAtMs = now + publishPartialLyrics(transfer.lines.values.sortedBy(LyricLine::index)) + } + } + } + } + + private fun finishLegacyLyrics(value: JSONObject) { + val transfer = legacyLyricsTransfer ?: return + legacyLyricsTransfer = null + if (value.optString("trackId") != transfer.trackId || + transfer.trackId != _playback.value.trackId + ) { + return + } + if (transfer.lines.size != transfer.expectedCount) { + logStore.append( + "[Lyrics] legacy incomplete trackId=${transfer.trackId} " + + "received=${transfer.lines.size}/${transfer.expectedCount}" + ) + retryLegacyLyrics("legacy incomplete") + return + } + completeFullLyricsTransfer( + transfer.trackId, + transfer.lines.values.sortedBy(LyricLine::index), + "legacy" + ) + } + + private fun startBinaryLyrics(value: JSONObject) { + val trackId = value.optString("trackId", value.optString("id")) + val generation = value.optLong("generation", value.optLong("g")) + if (!adoptIncomingGeneration(trackId, generation)) { + return + } + val transferId = value.optString("transferId", value.optString("tid")) + val expectedSize = value.optInt("size", value.optInt("s")) + val uncompressedSize = value.optInt("uncompressedSize", value.optInt("u")) + val chunks = value.optInt("chunks", value.optInt("c")) + val count = value.optInt("count", value.optInt("n")) + val crc = ControllerProtocolCodec.parseHexCrc( + value.optString("crc32", value.optString("crc")) + ) + if (transferId.isBlank() || + expectedSize !in 1..24 * 1024 || + uncompressedSize !in 1..512 * 1024 || + chunks !in 1..0xffff || + count <= 0 || + crc == null + ) { + fallbackBinaryLyrics("invalid start") + return + } + fullLyricsRequestStartTimeout?.cancel() + fullLyricsRequestStartTimeout = null + legacyLyricsTransfer = null + binaryLyricsTransfer = BinaryLyricsTransfer( + trackId = trackId, + transferId = transferId, + generation = generation, + expectedSize = expectedSize, + uncompressedSize = uncompressedSize, + expectedChunks = chunks, + expectedLineCount = count, + expectedCrc = crc + ) + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FULL_LYRICS, + receivedChunks = 0, + expectedChunks = chunks, + transferId = transferId, + protocolFormat = "zlib-json-v1" + ) + } + scheduleFullLyricsTransferTimeout(trackId, transferId) + logStore.append( + "[Lyrics] binary start trackId=$trackId transferId=$transferId chunks=$chunks lines=$count" + ) + } + + private fun handleLyricsBinaryChunk(value: ByteArray) { + val chunk = ControllerProtocolCodec.decodeBinaryChunk( + value, + ControllerProtocolCodec.FULL_LYRICS_MAGIC + ) ?: return + val transfer = binaryLyricsTransfer ?: return + if (chunk.kindCode != 1 || + transfer.trackId != _playback.value.trackId || + chunk.total != transfer.expectedChunks + ) { + return + } + transfer.chunks[chunk.index] = chunk.payload + if (chunk.index == 0 || chunk.index == chunk.total - 1 || chunk.index % 10 == 0) { + _lyrics.update { + it.copy( + receivedChunks = transfer.chunks.size, + expectedChunks = transfer.expectedChunks + ) + } + } + } + + private fun finishBinaryLyrics(value: JSONObject) { + val transfer = binaryLyricsTransfer ?: return + val trackId = value.optString("trackId", value.optString("id")) + val transferId = value.optString("transferId", value.optString("tid")) + val generation = value.optLong("generation", value.optLong("g")) + if (trackId != transfer.trackId || + transferId != transfer.transferId || + generation != transfer.generation || + trackId != _playback.value.trackId + ) { + return + } + val missing = ControllerProtocolCodec.missingIndexes( + transfer.chunks, + transfer.expectedChunks + ) + if (missing.isNotEmpty()) { + retryBinaryLyrics(transfer, missing, missing.size > 32, "missing chunks") + return + } + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = null + scope.launch(Dispatchers.Default) { + val compressed = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) + val reason = when { + compressed == null -> "reassemble failed" + compressed.size != transfer.expectedSize -> "size mismatch" + ControllerProtocolCodec.crc32(compressed) != transfer.expectedCrc -> "crc mismatch" + else -> null + } + if (reason != null) { + mediaScope.launch { + if (binaryLyricsTransfer?.transferId == transfer.transferId) { + retryBinaryLyrics(transfer, emptyList(), true, reason) + } + } + return@launch + } + val raw = ControllerProtocolCodec.zlibDecompress( + compressed!!, + transfer.uncompressedSize + ) + val decoded = raw?.let(ControllerProtocolCodec::decodeLyricPayload) + if (decoded == null || + decoded.first != transfer.trackId || + decoded.second != transfer.generation || + decoded.third.size != transfer.expectedLineCount + ) { + mediaScope.launch { + if (binaryLyricsTransfer?.transferId == transfer.transferId) { + retryBinaryLyrics(transfer, emptyList(), true, "decode failed") + } + } + return@launch + } + if (_playback.value.trackId == transfer.trackId && + MediaIdentityPolicy.generationMatches( + _playback.value.generation, + transfer.generation + ) + ) { + mediaScope.launch publishLyrics@{ + if (_playback.value.trackId != transfer.trackId || + !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + transfer.generation + ) || binaryLyricsTransfer?.transferId != transfer.transferId + ) return@publishLyrics + binaryLyricsRetryCounts.remove(transfer.transferId) + binaryLyricsTransfer = null + completeFullLyricsTransfer( + transfer.trackId, + decoded.third, + "zlib-json-v1" + ) + } + } + } + } + + private fun retryBinaryLyrics( + transfer: BinaryLyricsTransfer, + missing: List, + retryAll: Boolean, + reason: String + ) { + val count = binaryLyricsRetryCounts[transfer.transferId] ?: 0 + if (!capabilities.transferRetry || count >= 1) { + fallbackBinaryLyrics(reason) + return + } + binaryLyricsRetryCounts[transfer.transferId] = count + 1 + sendCommand( + "RETRY_TRANSFER", + JSONObject() + .put("trackId", transfer.trackId) + .put("transferId", transfer.transferId) + .put("missing", ControllerProtocolCodec.missingArray(missing)) + .put("retryAll", retryAll) + ) + scheduleFullLyricsTransferTimeout(transfer.trackId, transfer.transferId) + _lyrics.update { it.copy(retryCount = count + 1, failureReason = reason) } + logStore.append( + "[Lyrics] binary retry transferId=${transfer.transferId} " + + "retryAll=$retryAll missing=${missing.size} reason=$reason" + ) + } + + private fun fallbackBinaryLyrics(reason: String) { + val trackId = binaryLyricsTransfer?.trackId ?: _playback.value.trackId + val generation = binaryLyricsTransfer?.generation ?: _playback.value.generation + binaryLyricsTransfer = null + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = null + fullLyricsBinaryFallbackKeys += lyricsKey(trackId, generation) + activeFullLyricsRequest = null + logStore.append("[Lyrics] binary fallback reason=$reason") + if (trackId == _playback.value.trackId) { + requestFullLyricsOnMedia(forceLegacy = true, force = true) + } + } + + private fun handleLyricsUnavailable(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId != _playback.value.trackId) return + val generation = value.optLong("generation") + if (generation > 0L && !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + generation + ) + ) return + cancelFullLyricsTimers() + activeFullLyricsRequest = null + legacyLyricsTransfer = null + binaryLyricsTransfer = null + val reason = value.optString("reason", value.optString("lyricStatus")) + val waitingForQrc = reason.contains("loading", true) || + reason.contains("waiting", true) || + reason.contains("pending", true) + _lyrics.update { + it.copy( + loadingStage = if (waitingForQrc) { + LyricLoadingStage.WAITING_QQ_QRC + } else { + LyricLoadingStage.FAILED + }, + failureReason = reason + ) + } + logStore.append("[Lyrics] unavailable trackId=$trackId reason=$reason") + if (waitingForQrc) scheduleQrcWaitRetry(trackId) + } + + private fun publishLyricWindow(lines: List) { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + _lyrics.update { + val localIndex = findCurrentLine(lines, displayedPositionMs()) + LyricsPublicationPolicy.publishWindow( + state = it, + trackId = trackId, + generation = _playback.value.generation, + lines = lines, + currentLineIndex = lines.getOrNull(localIndex)?.index ?: -1 + ) + } + logStore.append("[Lyrics] window published trackId=$trackId lines=${lines.size}") + saveSnapshotDebounced() + } + + private fun publishPartialLyrics(lines: List) { + val trackId = _playback.value.trackId + if (trackId.isBlank() || _lyrics.value.isFinal) return + _lyrics.update { + LyricsPublicationPolicy.publishPartial( + state = it, + trackId = trackId, + generation = _playback.value.generation, + lines = lines + ) + } + } + + private fun completeFullLyricsTransfer( + trackId: String, + lines: List, + format: String + ) { + if (trackId != _playback.value.trackId || lines.isEmpty()) return + cancelFullLyricsTimers() + activeFullLyricsRequest = null + legacyLyricsTransfer = null + binaryLyricsTransfer = null + legacyLyricsRetryCount = 0 + val sorted = lines.sortedBy(LyricLine::index) + completedFullLyricsKeys += lyricsKey(trackId, _playback.value.generation) + qrcWaitRetryCounts.remove(lyricsKey(trackId, _playback.value.generation)) + _lyrics.update { + val localIndex = findCurrentLine(sorted, displayedPositionMs()) + LyricsPublicationPolicy.publishFull( + state = it, + trackId = trackId, + generation = _playback.value.generation, + lines = sorted, + currentLineIndex = sorted.getOrNull(localIndex)?.index ?: -1, + format = format + ) + } + logStore.append("[Lyrics] full published trackId=$trackId lines=${sorted.size} format=$format") + val mode = _settings.value.lyricDisplayMode + if (mode.showsTranslation) enqueueSecondaryOnMedia("translation") + if (mode.showsRomanization) enqueueSecondaryOnMedia("romanization") + saveSnapshotDebounced() + } + + private fun scheduleFullLyricsStartTimeout(request: FullLyricsRequest) { + fullLyricsRequestStartTimeout?.cancel() + fullLyricsRequestStartTimeout = mediaScope.launch { + delay(FULL_LYRICS_START_TIMEOUT_MS) + val active = activeFullLyricsRequest ?: return@launch + if (active.trackId != request.trackId || + active.generation != request.generation || + active.sentAtElapsedMs != request.sentAtElapsedMs || + legacyLyricsTransfer != null || binaryLyricsTransfer != null || + _playback.value.trackId != request.trackId + ) return@launch + if (active.attempt <= MAX_FULL_LYRICS_START_RETRIES) { + logStore.append( + "[Lyrics] start timeout trackId=${active.trackId} retry=${active.attempt}" + ) + requestFullLyricsOnMedia( + forceLegacy = active.format == "legacy", + force = true + ) + } else { + activeFullLyricsRequest = null + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FAILED, + failureReason = "Sony 未开始发送完整歌词", + retryCount = active.attempt - 1 + ) + } + logStore.append("[Lyrics] start timeout exhausted trackId=${active.trackId}") + } + } + } + + private fun scheduleFullLyricsTransferTimeout(trackId: String, transferId: String) { + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = mediaScope.launch { + delay(FULL_LYRICS_TRANSFER_TIMEOUT_MS) + if (_playback.value.trackId != trackId) return@launch + val binary = binaryLyricsTransfer + if (binary != null && binary.transferId == transferId) { + val missing = ControllerProtocolCodec.missingIndexes( + binary.chunks, + binary.expectedChunks + ) + retryBinaryLyrics( + binary, + missing, + missing.isEmpty() || missing.size > 32, + "transfer timeout" + ) + return@launch + } + if (transferId == "legacy" && legacyLyricsTransfer?.trackId == trackId) { + retryLegacyLyrics("legacy transfer timeout") + } + } + } + + private fun retryLegacyLyrics(reason: String) { + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = null + legacyLyricsTransfer = null + if (legacyLyricsRetryCount >= MAX_LEGACY_LYRICS_RETRIES) { + activeFullLyricsRequest = null + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FAILED, + failureReason = reason, + retryCount = legacyLyricsRetryCount + ) + } + logStore.append("[Lyrics] legacy failed reason=$reason") + return + } + legacyLyricsRetryCount += 1 + logStore.append("[Lyrics] legacy retry=$legacyLyricsRetryCount reason=$reason") + requestFullLyricsOnMedia(forceLegacy = true, force = true) + } + + private fun cancelFullLyricsTimers() { + fullLyricsRequestStartTimeout?.cancel() + fullLyricsRequestStartTimeout = null + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = null + } + + private fun lyricsKey(trackId: String, generation: Long) = "$trackId|$generation" + + private fun scheduleQrcWaitRetry(trackId: String) { + val generation = _playback.value.generation + val key = lyricsKey(trackId, generation) + val retry = qrcWaitRetryCounts[key] ?: 0 + if (retry >= MAX_QRC_WAIT_RETRIES) { + logStore.append("[Lyrics] QQ QRC wait retry exhausted trackId=$trackId") + return + } + qrcWaitRetryCounts[key] = retry + 1 + mediaScope.launch { + delay(QRC_WAIT_RETRY_DELAYS_MS[retry]) + if (_playback.value.trackId != trackId || + !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + generation + ) || _lyrics.value.isFinal + ) return@launch + logStore.append("[Lyrics] QQ QRC wait retry=${retry + 1} trackId=$trackId") + requestLyricWindowOnMedia(force = true) + requestFullLyricsOnMedia(forceLegacy = false, force = true) + } + } + + private fun secondaryKey(trackId: String, mode: String) = "$trackId|$mode" + + private fun startSecondary(value: JSONObject) { + val trackId = value.optString("trackId") + val transferId = value.optString("transferId") + val mode = value.optString("mode") + val expectedItems = value.optInt("itemCount") + if (trackId != _playback.value.trackId || !_lyrics.value.isFinal || + transferId.isBlank() || mode !in SECONDARY_MODES || expectedItems <= 0 || + (secondaryRequestInFlightMode != null && secondaryRequestInFlightMode != mode) + ) return + secondaryTransferTimeout?.cancel() + secondaryRequestInFlightMode = null + secondaryTransfer = SecondaryTransfer( + trackId = trackId, + transferId = transferId, + mode = mode, + expectedItems = expectedItems + ) + scheduleSecondaryTimeout(trackId, mode, transferId) + logStore.append( + "[Lyrics] secondary start trackId=$trackId mode=$mode items=$expectedItems" + ) + } + + private fun appendSecondary(value: JSONObject) { + val transfer = secondaryTransfer ?: return + if (!transfer.matches(value, _playback.value.trackId)) return + val line = value.optInt("lineIndex", -1) + val part = value.optInt("partIndex", -1) + val count = value.optInt("partCount", -1) + if (line < 0 || part < 0 || count <= 0) return + val lineParts = transfer.parts.getOrPut(line) { SecondaryLineParts(count) } + if (lineParts.expectedCount == count) { + lineParts.parts[part] = value.optString("text") + } + } + + private fun finishSecondary(value: JSONObject) { + val transfer = secondaryTransfer ?: return + if (!transfer.matches(value, _playback.value.trackId)) return + val secondary = transfer.parts.mapNotNull { (line, parts) -> + if (parts.parts.size != parts.expectedCount) { + null + } else { + line to buildString { + repeat(parts.expectedCount) { append(parts.parts[it].orEmpty()) } + } + } + }.toMap() + if (secondary.size != transfer.expectedItems) { + secondaryTransfer = null + secondaryTransferTimeout?.cancel() + retrySecondaryOnMedia(transfer.mode, "incomplete ${secondary.size}/${transfer.expectedItems}") + return + } + secondaryTransfer = null + secondaryTransferTimeout?.cancel() + secondaryTransferTimeout = null + _lyrics.update { state -> + state.copy( + fullLines = state.fullLines.map { line -> + val text = ControllerProtocolCodec.sanitizeSecondary(secondary[line.index]) + when (transfer.mode) { + "translation" -> line.copy(translation = text) + "romanization" -> line.copy(romanization = text) + else -> line + } + } + ) + } + val key = secondaryKey(transfer.trackId, transfer.mode) + completedSecondaryKeys += key + requestedSecondaryKeys.remove(key) + secondaryRetryCounts.remove(key) + logStore.append( + "[Lyrics] secondary published trackId=${transfer.trackId} " + + "mode=${transfer.mode} items=${secondary.size}" + ) + saveSnapshotDebounced() + requestNextSecondaryOnMedia() + } + + private fun handleSecondaryUnavailable(value: JSONObject) { + val trackId = value.optString("trackId", _playback.value.trackId) + val mode = value.optString("mode", secondaryTransfer?.mode.orEmpty()) + secondaryTransfer = null + secondaryRequestInFlightMode = null + secondaryTransferTimeout?.cancel() + secondaryTransferTimeout = null + if (trackId.isNotBlank() && mode in SECONDARY_MODES) { + val key = secondaryKey(trackId, mode) + completedSecondaryKeys += key + requestedSecondaryKeys.remove(key) + } + logStore.append( + "[Lyrics] secondary unavailable trackId=$trackId mode=$mode " + + "reason=${value.optString("reason", value.optString("message"))}" + ) + requestNextSecondaryOnMedia() + } + + private fun requestNextSecondaryOnMedia() { + if (secondaryTransfer != null || secondaryRequestInFlightMode != null) return + val mode = pendingSecondaryModes.removeFirstOrNull() ?: return + val trackId = _playback.value.trackId + if (trackId.isBlank() || !_lyrics.value.isFinal) return + secondaryRequestInFlightMode = mode + val sent = sendCommand( + "GET_LYRIC_SECONDARY", + JSONObject().put("trackId", trackId).put("mode", mode) + ) + logStore.append("[Lyrics] secondary request trackId=$trackId mode=$mode sent=$sent") + if (!sent) { + secondaryRequestInFlightMode = null + requestedSecondaryKeys.remove(secondaryKey(trackId, mode)) + requestNextSecondaryOnMedia() + return + } + scheduleSecondaryTimeout(trackId, mode, transferId = "") + } + + private fun scheduleSecondaryTimeout(trackId: String, mode: String, transferId: String) { + secondaryTransferTimeout?.cancel() + secondaryTransferTimeout = mediaScope.launch { + delay( + if (transferId.isBlank()) SECONDARY_START_TIMEOUT_MS + else SECONDARY_TRANSFER_TIMEOUT_MS + ) + if (_playback.value.trackId != trackId) return@launch + val stillWaiting = if (transferId.isBlank()) { + secondaryRequestInFlightMode == mode + } else { + secondaryTransfer?.transferId == transferId + } + if (!stillWaiting) return@launch + secondaryTransfer = null + secondaryRequestInFlightMode = null + retrySecondaryOnMedia(mode, "timeout") + } + } + + private fun retrySecondaryOnMedia(mode: String, reason: String) { + val trackId = _playback.value.trackId + val key = secondaryKey(trackId, mode) + val retry = secondaryRetryCounts[key] ?: 0 + if (trackId.isBlank() || retry >= MAX_SECONDARY_RETRIES) { + requestedSecondaryKeys.remove(key) + logStore.append("[Lyrics] secondary failed mode=$mode reason=$reason") + requestNextSecondaryOnMedia() + return + } + secondaryRetryCounts[key] = retry + 1 + requestedSecondaryKeys.remove(key) + logStore.append("[Lyrics] secondary retry mode=$mode reason=$reason") + enqueueSecondaryOnMedia(mode) + } + + private fun handleLyricDiagnostic(value: JSONObject) { + if (value.optString("trackId") != _playback.value.trackId) return + val details = linkedMapOf() + value.keys().forEach { key -> + if (key !in setOf("type", "trackId", "status", "reason", "suggestion")) { + details[key] = value.opt(key)?.toString().orEmpty() + } + } + _diagnostics.update { + it.copy( + lyricDiagnostic = LyricDiagnosticState( + status = value.optString("status"), + reason = value.optString("reason"), + suggestion = value.optString("suggestion"), + details = details + ) + ) + } + } + + private fun handleArtworkOffer(id: String) { + if (id.isBlank() || id != _playback.value.trackId) return + val current = _artwork.value + if (current.artworkId == id && current.quality.rank >= ArtworkQuality.HQ.rank) { + sendCommand( + "ALBUM_ART_SKIP", + JSONObject().put("id", id).put("quality", "hq") + ) + return + } + if (current.artworkId == id && current.bitmap != null && + !current.cacheRequiresRefresh + ) { + // A valid cached Preview is already on screen. Re-requesting it + // consumes the interactive BLE lane without improving first paint. + logStore.append("[Artwork] preview cache confirmed id=$id, skip retransmit") + scheduleHqArtworkPrefetch(id, CACHED_PREVIEW_HQ_DELAY_MS) + return + } + requestArtwork(ArtworkQuality.PREVIEW) + } + + private fun startLegacyArtwork(value: JSONObject) { + val id = value.optString("id") + if (id != _playback.value.trackId) return + val chunks = value.optInt("chunks", value.optInt("totalChunks")) + val size = value.optInt("size") + if (chunks <= 0) return + artworkRequestTimeout?.cancel() + artworkRequestTimeout = null + artworkTransfer = ArtworkTransfer( + artworkId = id, + quality = quality(value.optString("quality")), + transferId = "", + generation = 0L, + expectedSize = size, + expectedChunks = chunks, + expectedCrc = null, + binary = false + ) + logStore.append( + "[Artwork] legacy start id=$id quality=${wireQuality(quality(value.optString("quality")))} " + + "chunks=$chunks size=$size" + ) + scheduleArtworkTransferTimeout(artworkTransfer ?: return) + } + + private fun appendLegacyArtwork(value: JSONObject) { + val transfer = artworkTransfer ?: return + if (transfer.binary || + transfer.artworkId != value.optString("id") || + transfer.artworkId != _playback.value.trackId + ) { + return + } + val index = value.optInt("index", -1) + val data = value.optString("data") + if (index in 0 until transfer.expectedChunks && data.isNotBlank()) { + runCatching { Base64.decode(data, Base64.NO_WRAP) } + .getOrNull() + ?.let { transfer.chunks[index] = it } + } + } + + private fun finishLegacyArtwork(value: JSONObject) { + val transfer = artworkTransfer ?: return + if (transfer.binary || + value.optString("id") != transfer.artworkId || + transfer.artworkId != _playback.value.trackId + ) { + return + } + artworkTransfer = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null + finishArtworkData(transfer) + } + + private fun startBinaryArtwork(value: JSONObject) { + val id = value.optString("id") + val generation = value.optLong("generation") + if (!adoptIncomingGeneration(id, generation)) { + return + } + val transferId = value.optString("transferId") + val chunks = value.optInt("chunks") + val size = value.optInt("size") + val crc = ControllerProtocolCodec.parseHexCrc(value.optString("crc32")) + if (transferId.isBlank() || chunks <= 0 || size <= 0 || crc == null) return + artworkRequestTimeout?.cancel() + artworkRequestTimeout = null + artworkTransfer = ArtworkTransfer( + artworkId = id, + quality = quality(value.optString("quality")), + transferId = transferId, + generation = generation, + expectedSize = size, + expectedChunks = chunks, + expectedCrc = crc, + binary = true + ) + _artwork.update { + it.copy( + artworkId = id, + loadingStage = if (quality(value.optString("quality")) == ArtworkQuality.HQ) { + ArtworkLoadingStage.HQ + } else { + ArtworkLoadingStage.PREVIEW + }, + receivedChunks = 0, + expectedChunks = chunks + ) + } + logStore.append( + "[Artwork] binary start id=$id transferId=$transferId " + + "quality=${wireQuality(quality(value.optString("quality")))} chunks=$chunks size=$size" + ) + scheduleArtworkTransferTimeout(artworkTransfer ?: return) + } + + private fun handleArtworkBinaryChunk(value: ByteArray) { + val chunk = ControllerProtocolCodec.decodeBinaryChunk( + value, + ControllerProtocolCodec.ALBUM_ART_MAGIC + ) ?: return + val transfer = artworkTransfer ?: return + if (!transfer.binary || + transfer.artworkId != _playback.value.trackId || + chunk.total != transfer.expectedChunks + ) { + return + } + val expectedQuality = when (transfer.quality) { + ArtworkQuality.PREVIEW -> 1 + ArtworkQuality.HQ -> 3 + else -> 2 + } + if (chunk.kindCode != expectedQuality) return + transfer.chunks[chunk.index] = chunk.payload + if (chunk.index == 0 || chunk.index == chunk.total - 1 || chunk.index % 10 == 0) { + _artwork.update { + it.copy( + receivedChunks = transfer.chunks.size, + expectedChunks = transfer.expectedChunks + ) + } + } + } + + private fun finishBinaryArtwork(value: JSONObject) { + val transfer = artworkTransfer ?: return + if (!transfer.binary || + value.optString("id") != transfer.artworkId || + value.optString("transferId") != transfer.transferId || + value.optLong("generation") != transfer.generation || + !MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = _playback.value.trackId, + transferTrackId = transfer.artworkId, + currentGeneration = _playback.value.generation, + transferGeneration = transfer.generation + ) + ) { + logStore.append( + "[Artwork] binary end ignored id=${value.optString("id")} " + + "transferId=${value.optString("transferId")}" + ) + return + } + if (_playback.value.generation > transfer.generation && transfer.generation > 0L) { + logStore.append( + "[Artwork] completing same-track transfer after generation advance " + + "${transfer.generation}->${_playback.value.generation}" + ) + } + val missing = ControllerProtocolCodec.missingIndexes( + transfer.chunks, + transfer.expectedChunks + ) + if (missing.isNotEmpty()) { + retryArtwork(transfer, missing, missing.size > 32) + return + } + artworkTransfer = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null + finishArtworkData(transfer) + } + + private fun finishArtworkData(transfer: ArtworkTransfer) { + scope.launch(Dispatchers.IO) { + val data = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) + val failure = when { + data == null -> "封面分包不完整" + transfer.expectedSize > 0 && data.size != transfer.expectedSize -> "封面大小不匹配" + transfer.expectedCrc != null && + ControllerProtocolCodec.crc32(data) != transfer.expectedCrc -> "封面CRC错误" + else -> null + } + if (failure != null) { + if (transfer.binary) { + mediaScope.launch { retryArtwork(transfer, emptyList(), true) } + } else { + mediaScope.launch { handleArtworkFailure(failure) } + } + return@launch + } + val bitmap = ArtworkCache.decodeDownsampled(data!!, 780) + if (bitmap == null || ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { + bitmap?.recycle() + mediaScope.launch placeholder@{ + if (_playback.value.trackId != transfer.artworkId) return@placeholder + handleArtworkFailure("等待QQ音乐真实封面") + val refreshCount = + (artworkPlaceholderRefreshCounts[transfer.artworkId] ?: 0) + 1 + artworkPlaceholderRefreshCounts[transfer.artworkId] = refreshCount + if (refreshCount <= MAX_PLACEHOLDER_REFRESHES) { + delay(500L * refreshCount) + if (_playback.value.trackId == transfer.artworkId) { + requestArtworkOnMedia( + ArtworkQuality.PREVIEW, + forceRefresh = true + ) + } + } + } + return@launch + } + if (!MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = _playback.value.trackId, + transferTrackId = transfer.artworkId, + currentGeneration = _playback.value.generation, + transferGeneration = transfer.generation + ) + ) { + // The decode belongs to an obsolete song/connection generation. It was never + // published or inserted into a cache, so release its native pixel allocation + // immediately instead of waiting for a later GC during rapid song switching. + bitmap.recycle() + return@launch + } + artworkCache.store( + artworkId = transfer.artworkId, + quality = transfer.quality, + data = data, + bitmap = bitmap, + maximumPixelSize = 780 + ) + mediaScope.launch publishArtwork@{ + if (!MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = _playback.value.trackId, + transferTrackId = transfer.artworkId, + currentGeneration = _playback.value.generation, + transferGeneration = transfer.generation + ) + ) { + return@publishArtwork + } + artworkPlaceholderRefreshCounts.remove(transfer.artworkId) + artworkRequestRetryCounts.remove( + "${transfer.artworkId}|${wireQuality(transfer.quality)}" + ) + requestedArtwork.remove( + "${transfer.artworkId}|${wireQuality(transfer.quality)}" + ) + _artwork.value = ArtworkState( + artworkId = transfer.artworkId, + bitmap = bitmap, + quality = transfer.quality, + loadingStage = if (transfer.quality == ArtworkQuality.HQ) { + ArtworkLoadingStage.HQ_READY + } else { + ArtworkLoadingStage.PREVIEW_READY + }, + receivedChunks = transfer.expectedChunks, + expectedChunks = transfer.expectedChunks, + cacheRequiresRefresh = false + ) + logStore.append( + "[Artwork] published id=${transfer.artworkId} " + + "quality=${wireQuality(transfer.quality)} bytes=${data.size}" + ) + saveSnapshotDebounced() + if (transfer.quality == ArtworkQuality.PREVIEW) { + scheduleHqArtworkPrefetch(transfer.artworkId, hqDelayMs()) + } else if (_settings.value.artworkEnhancementEnabled) { + scope.launch { + val enhanced = ArtworkEnhancer.enhance(bitmap) + mediaScope.launch publish@{ + if (_playback.value.trackId != transfer.artworkId || + _artwork.value.bitmap !== bitmap || enhanced === bitmap + ) return@publish + _artwork.update { + it.copy( + bitmap = enhanced, + quality = ArtworkQuality.ENHANCED, + enhancementMessage = "本地增强完成" + ) + } + } + } + } + } + } + } + + private fun retryArtwork( + transfer: ArtworkTransfer, + missing: List, + retryAll: Boolean + ) { + val count = artworkRetryCounts[transfer.transferId] ?: 0 + if (!capabilities.transferRetry || count >= 1) { + artworkTransfer = null + handleArtworkFailure("封面重传失败") + return + } + artworkRetryCounts[transfer.transferId] = count + 1 + sendCommand( + "RETRY_TRANSFER", + JSONObject() + .put("trackId", transfer.artworkId) + .put("transferId", transfer.transferId) + .put("missing", ControllerProtocolCodec.missingArray(missing)) + .put("retryAll", retryAll) + ) + scheduleArtworkTransferTimeout(transfer) + } + + private fun scheduleArtworkRequestTimeout( + artworkId: String, + quality: ArtworkQuality + ) { + artworkRequestTimeout?.cancel() + artworkRequestTimeout = mediaScope.launch { + delay(ARTWORK_REQUEST_TIMEOUT_MS) + if (_playback.value.trackId != artworkId || artworkTransfer != null) { + return@launch + } + val key = "$artworkId|${wireQuality(quality)}" + if (key !in requestedArtwork) return@launch + requestedArtwork.remove(key) + val retries = artworkRequestRetryCounts[key] ?: 0 + if (retries < MAX_ARTWORK_REQUEST_RETRIES) { + artworkRequestRetryCounts[key] = retries + 1 + logStore.append( + "[Artwork] start timeout id=$artworkId quality=${wireQuality(quality)} retry=${retries + 1}" + ) + requestArtworkOnMedia(quality, forceRefresh = true) + return@launch + } + artworkRequestRetryCounts.remove(key) + if (quality == ArtworkQuality.HQ && _artwork.value.bitmap != null) { + _artwork.update { + it.copy( + loadingStage = if (it.quality.rank >= ArtworkQuality.HQ.rank) { + ArtworkLoadingStage.HQ_READY + } else { + ArtworkLoadingStage.PREVIEW_READY + }, + failureReason = "HQ 封面请求超时,已保留当前封面" + ) + } + logStore.append("[Artwork] HQ start timeout, current image retained id=$artworkId") + } else { + handleArtworkFailure("Sony 未开始发送封面") + } + } + } + + private fun scheduleArtworkTransferTimeout(transfer: ArtworkTransfer) { + artworkTransferTimeout?.cancel() + artworkTransferTimeout = mediaScope.launch { + delay(ARTWORK_TRANSFER_TIMEOUT_MS) + val active = artworkTransfer ?: return@launch + if (active !== transfer || active.artworkId != _playback.value.trackId) return@launch + if (active.binary) { + val missing = ControllerProtocolCodec.missingIndexes( + active.chunks, + active.expectedChunks + ) + logStore.append( + "[Artwork] transfer timeout id=${active.artworkId} " + + "received=${active.chunks.size}/${active.expectedChunks}" + ) + retryArtwork( + active, + missing, + retryAll = missing.isEmpty() || missing.size > 32 + ) + } else { + artworkTransfer = null + val key = "legacy|${active.artworkId}|${wireQuality(active.quality)}" + val retries = artworkRetryCounts[key] ?: 0 + if (retries < MAX_ARTWORK_TRANSFER_RETRIES) { + artworkRetryCounts[key] = retries + 1 + requestedArtwork.remove( + "${active.artworkId}|${wireQuality(active.quality)}" + ) + logStore.append( + "[Artwork] legacy transfer timeout id=${active.artworkId} retry=${retries + 1}" + ) + requestArtworkOnMedia(active.quality, forceRefresh = true) + } else { + artworkRetryCounts.remove(key) + handleArtworkFailure("封面传输超时") + } + } + } + } + + private fun handleArtworkUnavailable(value: JSONObject) { + if (value.optString("id") != _playback.value.trackId) return + val generation = value.optLong("generation") + if (generation > 0L && !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + generation + ) + ) return + artworkRequestTimeout?.cancel() + artworkRequestTimeout = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null + requestedArtwork.remove( + "${value.optString("id")}|${wireQuality(quality(value.optString("quality")))}" + ) + val reason = value.optString("reason", "封面不可用") + if (quality(value.optString("quality")) == ArtworkQuality.HQ && + _artwork.value.bitmap != null + ) { + _artwork.update { + it.copy( + loadingStage = ArtworkLoadingStage.PREVIEW_READY, + failureReason = reason + ) + } + logStore.append("[Artwork] HQ unavailable, preview retained reason=$reason") + } else { + handleArtworkFailure(reason) + } + } + + private fun handleArtworkProtocolError(value: JSONObject) { + val transfer = artworkTransfer + val id = value.optString("id", value.optString("trackId")) + val transferId = value.optString("transferId") + val generation = value.optLong("generation") + if (id.isNotBlank() && id != _playback.value.trackId) return + if (generation > 0L && !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + generation + ) + ) return + if (transfer != null && transferId.isNotBlank() && + transferId != transfer.transferId + ) return + if (transfer != null && id.isNotBlank() && id != transfer.artworkId) return + artworkTransfer = null + handleArtworkFailure( + value.optString("message", value.optString("reason", "封面协议错误")) + ) + } + + private fun handleArtworkFailure(reason: String) { + artworkRequestTimeout?.cancel() + artworkRequestTimeout = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null + artworkTransfer = null + val id = _playback.value.trackId + if (id.isNotBlank()) { + requestedArtwork.remove("$id|preview") + requestedArtwork.remove("$id|hq") + } + _artwork.update { + it.copy( + loadingStage = when { + it.bitmap == null -> ArtworkLoadingStage.FAILED + it.quality.rank >= ArtworkQuality.HQ.rank -> ArtworkLoadingStage.HQ_READY + else -> ArtworkLoadingStage.PREVIEW_READY + }, + failureReason = reason + ) + } + reportIssue(reason) + } + + private fun restoreCachedArtwork(id: String, restored: Boolean) { + val epoch = artworkRestoreEpoch.incrementAndGet() + artworkCache.peekBest(id, MAIN_ARTWORK_MAXIMUM_PIXEL_SIZE)?.let { cached -> + mediaScope.launch { publishCachedArtwork(id, cached, restored, epoch) } + return + } + scope.launch(Dispatchers.IO) { + val cached = artworkCache.loadBest(id, MAIN_ARTWORK_MAXIMUM_PIXEL_SIZE) + mediaScope.launch { + if (cached != null) publishCachedArtwork(id, cached, restored, epoch) + } + } + } + + private fun publishCachedArtwork( + id: String, + cached: CachedArtwork, + restored: Boolean, + epoch: Long + ) { + if (epoch != artworkRestoreEpoch.get() || _playback.value.trackId != id) return + _artwork.value = ArtworkState( + artworkId = id, + bitmap = cached.bitmap, + quality = cached.quality, + loadingStage = if (cached.quality == ArtworkQuality.HQ) { + ArtworkLoadingStage.HQ_READY + } else { + ArtworkLoadingStage.PREVIEW_READY + }, + restoredSnapshot = restored, + cacheRequiresRefresh = cached.requiresRefresh, + enhancementMessage = if (cached.requiresRefresh) "缓存展示·后台刷新" else "缓存命中" + ) + logStore.append( + "[Artwork] cache ${if (cached.requiresRefresh) "stale" else "hit"} " + + "id=$id quality=${wireQuality(cached.quality)}" + ) + if (cached.requiresRefresh && _connection.value.connected) { + requestArtworkOnMedia(ArtworkQuality.PREVIEW, forceRefresh = true) + } + } + + private fun scheduleHqArtworkPrefetch(id: String, delayMs: Long) { + hqArtworkPrefetchJob?.cancel() + hqArtworkPrefetchJob = mediaScope.launch { + delay(delayMs) + val current = _artwork.value + if (_playback.value.trackId == id && current.artworkId == id && + current.quality.rank < ArtworkQuality.HQ.rank + ) { + requestArtworkOnMedia(ArtworkQuality.HQ) + } + } + } + + private fun startTextTransfer(type: String, value: JSONObject) { + val kind = if (type.startsWith("log")) "log" else "dump" + val chunks = value.optInt("chunks") + if (chunks <= 0 && value.optBoolean("empty")) { + diagnosticTransferTimeout?.cancel() + diagnosticTransferTimeout = null + if (kind == "log") { + _diagnostics.update { + it.copy(sonyLogs = "Sony 暂无日志", remoteTransferInProgress = false) + } + } else { + _diagnostics.update { it.copy(remoteTransferInProgress = false) } + } + return + } + textTransfer = TextTransfer(kind, value.optInt("size"), chunks) + _diagnostics.update { it.copy(remoteTransferInProgress = true) } + scheduleDiagnosticTransferTimeout(kind) + } + + private fun appendTextTransfer(type: String, value: JSONObject) { + val transfer = textTransfer ?: return + val kind = if (type.startsWith("log")) "log" else "dump" + if (kind != transfer.kind) return + val index = value.optInt("index", -1) + if (index !in 0 until transfer.expectedChunks) return + runCatching { Base64.decode(value.optString("data"), Base64.NO_WRAP) } + .getOrNull() + ?.let { + transfer.chunks[index] = it + scheduleDiagnosticTransferTimeout(kind) + } + } + + private fun finishTextTransfer(type: String, value: JSONObject) { + val transfer = textTransfer + textTransfer = null + diagnosticTransferTimeout?.cancel() + diagnosticTransferTimeout = null + if (value.optBoolean("empty")) { + _diagnostics.update { + it.copy(sonyLogs = "Sony 暂无日志", remoteTransferInProgress = false) + } + return + } + if (transfer == null || + transfer.chunks.size != transfer.expectedChunks || + (type.startsWith("log") && transfer.kind != "log") || + (type.startsWith("mediaFieldDump") && transfer.kind != "dump") + ) { + finishRemoteTextWithError("远程诊断数据分包不完整") + return + } + val bytes = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) ?: run { + finishRemoteTextWithError("远程诊断数据重组失败") + return + } + val text = String(bytes, Charsets.UTF_8) + _diagnostics.update { + if (transfer.kind == "log") { + it.copy(sonyLogs = text, remoteTransferInProgress = false) + } else { + it.copy(mediaFieldDump = text, remoteTransferInProgress = false) + } + } + } + + private fun requestRemoteText( + command: String, + kind: String, + extra: JSONObject = JSONObject() + ) { + if (_diagnostics.value.remoteTransferInProgress) return + _diagnostics.update { it.copy(remoteTransferInProgress = true) } + val sent = sendCommand(command, extra) + if (!sent) { + _diagnostics.update { it.copy(remoteTransferInProgress = false) } + return + } + scheduleDiagnosticTransferTimeout(kind) + } + + private fun scheduleDiagnosticTransferTimeout(kind: String) { + diagnosticTransferTimeout?.cancel() + diagnosticTransferTimeout = mediaScope.launch { + delay(DIAGNOSTIC_TRANSFER_TIMEOUT_MS) + textTransfer = null + diagnosticTransferTimeout = null + _diagnostics.update { it.copy(remoteTransferInProgress = false) } + reportIssue(if (kind == "log") "Sony 日志获取超时" else "Media Dump 获取超时") + } + } + + private fun finishRemoteTextWithError(reason: String) { + diagnosticTransferTimeout?.cancel() + diagnosticTransferTimeout = null + textTransfer = null + _diagnostics.update { it.copy(remoteTransferInProgress = false) } + reportIssue(reason) + } + + private fun startHistoryPayload(value: JSONObject) { + val requestId = value.optString("requestId") + val chunks = value.optInt("chunks") + if (requestId.isBlank() || chunks <= 0) return + historyTransfers[requestId] = HistoryPayloadTransfer( + responseType = value.optString("responseType"), + expectedSize = value.optInt("size"), + expectedChunks = chunks + ) + scheduleHistoryTimeout("历史数据传输") + } + + private fun appendHistoryPayload(value: JSONObject) { + val requestId = value.optString("requestId") + val transfer = historyTransfers[requestId] ?: return + val index = value.optInt("index", -1) + if (index !in 0 until transfer.expectedChunks) return + runCatching { Base64.decode(value.optString("data"), Base64.NO_WRAP) } + .getOrNull() + ?.let { + transfer.chunks[index] = it + scheduleHistoryTimeout("历史数据传输") + } + } + + private fun finishHistoryPayload(value: JSONObject) { + val requestId = value.optString("requestId") + val transfer = historyTransfers.remove(requestId) ?: return + val data = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) + if (data == null || (transfer.expectedSize > 0 && data.size != transfer.expectedSize)) { + failHistorySync("历史数据分包不完整") + return + } + val payload = runCatching { JSONObject(String(data, Charsets.UTF_8)) }.getOrNull() + if (payload == null) { + failHistorySync("历史数据解析失败") + return + } + handleHistoryPayload(payload) + } + + private fun handleHistoryPayload(value: JSONObject) { + when (value.optString("type")) { + "playHistoryPage", "playHistorySince" -> { + val sessions = decodeHistorySessions(value.optJSONArray("items")) + val responseType = value.optString("type") + val hasMore = value.optBoolean("hasMore") + val lastSessionId = value.optLong( + "lastSessionId", + sessions.maxOfOrNull(PlaybackHistorySession::sessionId) ?: 0L + ) + scope.launch(Dispatchers.IO) { + historyDao.upsert(sessions.map(PlaybackHistoryEntity::from)) + _history.update { + it.copy( + status = if (sessions.isEmpty()) "历史已是最新" else "历史同步完成", + hasMore = if (responseType == "playHistoryPage") hasMore else it.hasMore + ) + } + mediaScope.launch { + if (responseType == "playHistorySince" && hasMore && lastSessionId > 0L) { + requestHistorySinceOnMedia(lastSessionId) + } else { + requestNextHistoryStat() + } + } + } + } + "playStats" -> { + decodeStats(value)?.let { stats -> + _history.update { + it.copy( + stats = it.stats + (stats.range to stats), + status = "${stats.range} 统计已更新" + ) + } + } + requestNextHistoryStat() + } + } + } + + private fun requestNextHistoryStat() { + val range = synchronized(historySyncQueue) { + if (historySyncQueue.isEmpty()) null else historySyncQueue.removeFirst() + } + if (range == null) { + historySyncTimeout?.cancel() + historySyncTimeout = null + _history.update { it.copy(loading = false, status = "同步完成") } + return + } + val sent = sendCommand( + "GET_PLAY_STATS", + JSONObject() + .put("requestId", requestId("stats")) + .put("range", range) + ) + if (sent) { + scheduleHistoryTimeout("$range 统计") + } else { + requestNextHistoryStat() + } + } + + private fun requestHistorySinceOnMedia(afterSessionId: Long) { + val sent = sendCommand( + "GET_PLAY_HISTORY_SINCE", + JSONObject() + .put("requestId", requestId("history")) + .put("afterSessionId", afterSessionId.coerceAtLeast(0L)) + .put("limit", 100) + ) + if (sent) { + scheduleHistoryTimeout("历史记录") + } else { + synchronized(historySyncQueue) { historySyncQueue.clear() } + _history.update { it.copy(loading = false, status = "Sony 未连接,保留本地历史") } + } + } + + private fun scheduleHistoryTimeout(stage: String) { + historySyncTimeout?.cancel() + historySyncTimeout = mediaScope.launch { + delay(HISTORY_SYNC_TIMEOUT_MS) + synchronized(historySyncQueue) { historySyncQueue.clear() } + historyTransfers.clear() + historySyncTimeout = null + _history.update { + it.copy( + loading = false, + status = "$stage 超时,已保留本地数据,可稍后重试" + ) + } + logStore.append("[History] timeout stage=$stage") + } + } + + private fun failHistorySync(reason: String) { + historySyncTimeout?.cancel() + historySyncTimeout = null + synchronized(historySyncQueue) { historySyncQueue.clear() } + _history.update { it.copy(loading = false, status = "$reason,已保留本地数据") } + logStore.append("[History] failed reason=$reason") + } + + private fun decodeHistorySessions(array: JSONArray?): List = + buildList { + if (array == null) return@buildList + repeat(array.length()) { index -> + val value = array.optJSONObject(index) ?: return@repeat + val sessionId = value.optLong("sessionId") + if (sessionId <= 0L) return@repeat + add( + PlaybackHistorySession( + sessionId = sessionId, + trackKey = value.optString("trackKey"), + title = value.optString("title"), + artist = value.optString("artist"), + album = value.optString("album"), + artworkId = value.optString("artworkId").ifBlank { null }, + startedAt = value.optLong("startedAt"), + endedAt = value.opt("endedAt") + ?.takeUnless { it == JSONObject.NULL } + ?.toString() + ?.toLongOrNull(), + listenedMs = value.optLong("listenedMs"), + durationMs = value.optLong("durationMs"), + completed = value.optBoolean("completed"), + skipped = value.optBoolean("skipped"), + countedPlay = value.optBoolean("countedPlay") + ) + ) + } + } + + private fun decodeStats(value: JSONObject): PlaybackStats? { + val range = value.optString("range") + if (range.isBlank()) return null + return PlaybackStats( + range = range, + totalListenMs = value.optLong("totalListenMs"), + playCount = value.optInt("playCount"), + uniqueTrackCount = value.optInt("uniqueTrackCount"), + completionRate = value.optDouble("completionRate"), + skipRate = value.optDouble("skipRate"), + topTracks = buildList { + val array = value.optJSONArray("topTracks") ?: JSONArray() + repeat(array.length()) { index -> + val item = array.optJSONObject(index) ?: return@repeat + add( + PlaybackTopTrack( + trackKey = item.optString("trackKey"), + title = item.optString("title"), + artist = item.optString("artist"), + listenedMs = item.optLong("listenedMs"), + playCount = item.optInt("playCount") + ) + ) + } + }, + topArtists = buildList { + val array = value.optJSONArray("topArtists") ?: JSONArray() + repeat(array.length()) { index -> + val item = array.optJSONObject(index) ?: return@repeat + add( + PlaybackTopArtist( + artist = item.optString("artist"), + listenedMs = item.optLong("listenedMs"), + playCount = item.optInt("playCount") + ) + ) + } + }, + dailyTrend = buildList { + val array = value.optJSONArray("dailyTrend") ?: JSONArray() + repeat(array.length()) { index -> + val item = array.optJSONObject(index) ?: return@repeat + add( + DailyListenStat( + dateKey = item.optString("dateKey"), + listenedMs = item.optLong("listenedMs"), + playCount = item.optInt("playCount") + ) + ) + } + } + ) + } + + private fun appendJsonChunk(transfer: JsonChunkTransfer?, value: JSONObject) { + transfer ?: return + val index = value.optInt("index", -1) + if (index !in 0 until transfer.expectedChunks) return + runCatching { Base64.decode(value.optString("data"), Base64.NO_WRAP) } + .getOrNull() + ?.let { transfer.chunks[index] = it } + } + + private fun finishTrackInfoTransfer() { + val transfer = trackInfoTransfer ?: return + trackInfoTransfer = null + val data = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) ?: return + if (transfer.expectedSize > 0 && data.size != transfer.expectedSize) return + runCatching { JSONObject(String(data, Charsets.UTF_8)) } + .getOrNull() + ?.let(::applyTrackInfo) + } + + private fun resetMediaForTrack(trackId: String) { + resetTransfers("track changed") + currentWordFence = CurrentWordOrderingFence() + requestedArtwork.clear() + binaryLyricsRetryCounts.clear() + artworkRetryCounts.clear() + artworkRequestRetryCounts.clear() + artworkPlaceholderRefreshCounts.clear() + activeFullLyricsRequest = null + legacyLyricsRetryCount = 0 + lastLegacyPartialPublishAtMs = 0L + pendingSecondaryModes.clear() + secondaryRequestInFlightMode = null + completedSecondaryKeys.clear() + requestedSecondaryKeys.clear() + secondaryRetryCounts.clear() + delayedLyricsRetryKeys.clear() + qrcWaitRetryCounts.clear() + _lyrics.value = LyricsState( + trackId = trackId, + loadingStage = LyricLoadingStage.WAITING_QQ_QRC + ) + _artwork.value = ArtworkState( + artworkId = trackId, + loadingStage = ArtworkLoadingStage.PREVIEW + ) + } + + private fun resetTransfers(reason: String) { + synchronized(transferLock) { + cancelFullLyricsTimers() + activeFullLyricsRequest = null + legacyLyricsRetryCount = 0 + lyricWindowRequestTimeout?.cancel() + lyricWindowRequestTimeout = null + secondaryTransferTimeout?.cancel() + secondaryTransferTimeout = null + trackInfoTransfer = null + lyricWindowTransfer = null + activeLyricWindowRequest = null + legacyLyricsTransfer = null + binaryLyricsTransfer = null + secondaryTransfer = null + secondaryRequestInFlightMode = null + pendingSecondaryModes.clear() + requestedSecondaryKeys.clear() + secondaryRetryCounts.clear() + artworkRequestTimeout?.cancel() + artworkRequestTimeout = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null + hqArtworkPrefetchJob?.cancel() + hqArtworkPrefetchJob = null + volumeSyncTimeout?.cancel() + volumeSyncTimeout = null + volumeSyncAttempts = 0 + historySyncTimeout?.cancel() + historySyncTimeout = null + artworkTransfer = null + requestedArtwork.clear() + artworkRequestRetryCounts.clear() + artworkRetryCounts.clear() + diagnosticTransferTimeout?.cancel() + diagnosticTransferTimeout = null + textTransfer = null + historyTransfers.clear() + } + logStore.append("[Transfer] reset reason=$reason") + } + + private fun saveSnapshotDebounced() { + val now = SystemClock.elapsedRealtime() + if (now - lastSnapshotSaveAtMs < 350L) return + lastSnapshotSaveAtMs = now + val playback = _playback.value + val lyrics = _lyrics.value.lines + val artworkId = _artwork.value.artworkId + scope.launch { + delay(350L) + if (_playback.value.trackId == playback.trackId && + MediaIdentityPolicy.generationMatches( + _playback.value.generation, + playback.generation + ) + ) { + snapshotStore.save(playback, lyricWindowAround(lyrics), artworkId) + } + } + } + + private fun lyricWindowAround(lines: List): List { + if (lines.size <= 5) return lines + val current = findCurrentLine(lines, displayedPositionMs()).coerceAtLeast(0) + val start = (current - 2).coerceAtLeast(0).coerceAtMost(lines.size - 5) + return lines.subList(start, start + 5) + } + + private fun findCurrentLine(lines: List, positionMs: Long): Int { + if (lines.isEmpty()) return -1 + var result = 0 + lines.forEachIndexed { index, line -> + if (line.timeMs <= positionMs) result = index + } + return result + } + + private fun adoptIncomingGeneration(trackId: String, incomingGeneration: Long): Boolean { + val playback = _playback.value + if (trackId.isBlank() || trackId != playback.trackId || + !MediaIdentityPolicy.canAdoptIncomingGeneration( + playback.generation, + incomingGeneration + ) + ) return false + if (incomingGeneration > 0L && incomingGeneration > playback.generation) { + _playback.value = playback.copy(generation = incomingGeneration) + _lyrics.update { + if (it.trackId == trackId) it.copy(generation = incomingGeneration) else it + } + logStore.append( + "[Media] adopted newer generation trackId=$trackId " + + "${playback.generation}->$incomingGeneration" + ) + } + return true + } + + private fun quality(value: String): ArtworkQuality = + if (value == "hq" || value == "full") ArtworkQuality.HQ else ArtworkQuality.PREVIEW + + private fun wireQuality(value: ArtworkQuality): String = + if (value.rank >= ArtworkQuality.HQ.rank) "hq" else "preview" + + private fun hqDelayMs(): Long = when (_settings.value.performanceMode) { + PlaybackPerformanceMode.SMOOTH -> 750L + PlaybackPerformanceMode.POWER_SAVING -> 2_500L + PlaybackPerformanceMode.AUTOMATIC -> { + val powerSaving = appContext.getSystemService(PowerManager::class.java) + ?.isPowerSaveMode == true + if (powerSaving) 2_500L else 1_200L + } + } + + private fun reportIssue(message: String) { + if (message.isBlank()) return + logStore.append("[Issue] $message") + _diagnostics.update { it.copy(lastIssue = message) } + } + + private fun requestId(prefix: String) = + "$prefix-${System.currentTimeMillis()}-${sequence.incrementAndGet()}" + + private data class JsonChunkTransfer( + val expectedSize: Int, + val expectedChunks: Int, + val chunks: MutableMap = HashMap() + ) + + private data class LyricWindowTransfer( + val trackId: String, + val transferId: String, + val generation: Long, + val expectedCount: Int, + val lines: MutableMap = HashMap() + ) { + fun matches(value: JSONObject, currentTrackId: String): Boolean = + trackId == currentTrackId && + value.optString("trackId") == trackId && + value.optString("transferId") == transferId + } + + private data class LyricWindowRequest( + val trackId: String, + val generation: Long, + val sentAtElapsedMs: Long, + val attempt: Int + ) + + private data class LegacyLyricsTransfer( + val trackId: String, + val expectedCount: Int, + val lines: MutableMap = HashMap() + ) + + private data class FullLyricsRequest( + val trackId: String, + val generation: Long, + val sentAtElapsedMs: Long, + val format: String, + val attempt: Int + ) + + private data class BinaryLyricsTransfer( + val trackId: String, + val transferId: String, + val generation: Long, + val expectedSize: Int, + val uncompressedSize: Int, + val expectedChunks: Int, + val expectedLineCount: Int, + val expectedCrc: Long, + val chunks: MutableMap = HashMap() + ) + + private data class SecondaryLineParts( + val expectedCount: Int, + val parts: MutableMap = HashMap() + ) + + private data class SecondaryTransfer( + val trackId: String, + val transferId: String, + val mode: String, + val expectedItems: Int, + val parts: MutableMap = HashMap() + ) { + fun matches(value: JSONObject, currentTrackId: String): Boolean = + trackId == currentTrackId && + value.optString("trackId") == trackId && + value.optString("transferId") == transferId && + value.optString("mode") == mode + } + + private data class ArtworkTransfer( + val artworkId: String, + val quality: ArtworkQuality, + val transferId: String, + val generation: Long, + val expectedSize: Int, + val expectedChunks: Int, + val expectedCrc: Long?, + val binary: Boolean, + val chunks: MutableMap = HashMap() + ) + + private data class TextTransfer( + val kind: String, + val expectedSize: Int, + val expectedChunks: Int, + val chunks: MutableMap = HashMap() + ) + + private data class HistoryPayloadTransfer( + val responseType: String, + val expectedSize: Int, + val expectedChunks: Int, + val chunks: MutableMap = HashMap() + ) + + companion object { + private const val MAIN_ARTWORK_MAXIMUM_PIXEL_SIZE = 780 + private const val CACHED_PREVIEW_HQ_DELAY_MS = 350L + private const val MAX_PLACEHOLDER_REFRESHES = 3 + private const val MAX_FULL_LYRIC_LINES = 1_000 + private const val LYRIC_WINDOW_REQUEST_DEDUP_MS = 800L + private const val LYRIC_WINDOW_START_TIMEOUT_MS = 1_500L + private const val MAX_LYRIC_WINDOW_RETRIES = 1 + private const val FULL_LYRICS_REQUEST_DEDUP_MS = 1_500L + private const val FULL_LYRICS_START_TIMEOUT_MS = 3_000L + private const val FULL_LYRICS_TRANSFER_TIMEOUT_MS = 6_000L + private const val MAX_FULL_LYRICS_START_RETRIES = 2 + private const val MAX_LEGACY_LYRICS_RETRIES = 1 + private const val MAX_QRC_WAIT_RETRIES = 4 + private val QRC_WAIT_RETRY_DELAYS_MS = longArrayOf(800L, 1_500L, 3_000L, 5_000L) + private const val PARTIAL_LYRICS_PUBLISH_MS = 250L + private const val SECONDARY_START_TIMEOUT_MS = 3_000L + private const val SECONDARY_TRANSFER_TIMEOUT_MS = 6_000L + private const val MAX_SECONDARY_RETRIES = 1 + private const val ARTWORK_REQUEST_TIMEOUT_MS = 3_500L + private const val ARTWORK_TRANSFER_TIMEOUT_MS = 6_500L + private const val MAX_ARTWORK_REQUEST_RETRIES = 1 + private const val MAX_ARTWORK_TRANSFER_RETRIES = 1 + private const val VOLUME_SYNC_TIMEOUT_MS = 2_000L + private const val MAX_VOLUME_SYNC_RETRIES = 1 + private const val HISTORY_SYNC_TIMEOUT_MS = 8_000L + private const val DIAGNOSTIC_TRANSFER_TIMEOUT_MS = 6_000L + private val SECONDARY_MODES = setOf("translation", "romanization") + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt new file mode 100644 index 0000000..aa841ef --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt @@ -0,0 +1,219 @@ +package com.example.controllerapp + +import android.app.Application +import android.animation.ValueAnimator +import android.os.Build +import android.os.PowerManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.example.controllerapp.model.AppExperienceMode +import com.example.controllerapp.model.ArtworkQuality +import com.example.controllerapp.model.LyricDisplayMode +import com.example.controllerapp.model.PlaybackPerformanceMode +import com.example.controllerapp.media.PlaybackClockPolicy +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class ControllerViewModel(application: Application) : AndroidViewModel(application) { + private val controllerApplication = application as ControllerApplication + private val repository = controllerApplication.repository + private val commands = controllerApplication.commandGateway + + val connection = repository.connection + val playback = repository.playback + val lyrics = repository.lyrics + val artwork = repository.artwork + val diagnostics = repository.diagnostics + val history = repository.history + val settings = repository.settings + + private val _displayedPositionMs = MutableStateFlow(0L) + val displayedPositionMs: StateFlow = _displayedPositionMs.asStateFlow() + private val uiVisible = MutableStateFlow(false) + private val _reduceAnimations = MutableStateFlow(false) + val reduceAnimations: StateFlow = _reduceAnimations.asStateFlow() + + init { + viewModelScope.launch { + while (true) { + if (!uiVisible.value) { + uiVisible.first { it } + continue + } + val state = playback.value + refreshPowerPolicy() + _displayedPositionMs.value = repository.displayedPositionMs() + if (!state.isPlaying) { + playback.first { next -> + next.isPlaying || + next.trackId != state.trackId || + next.positionMs != state.positionMs + } + continue + } + val interval = PlaybackClockPolicy.refreshIntervalMs( + uiVisible = true, + isPlaying = true, + performanceMode = settings.value.performanceMode, + systemPowerSaveMode = isPowerSaveMode() + ) ?: continue + delay(interval) + } + } + } + + fun setUiVisible(visible: Boolean) { + uiVisible.value = visible + if (visible) { + refreshPowerPolicy() + _displayedPositionMs.value = repository.displayedPositionMs() + } + } + + fun playPause() { + commands.playPause() + } + + fun previous() { + commands.previous() + } + + fun next() { + commands.next() + } + + fun seekTo(positionMs: Long) { + commands.seekTo(positionMs) + } + + fun setVolume(value: Int) { + commands.setVolume(value) + } + + fun reconnect(forceScan: Boolean = false) { + commands.reconnect("user", forceScan) + } + + fun requestFullLyrics() { + commands.requestFullLyrics() + } + + fun ensureFullLyrics() { + commands.ensureFullLyrics() + } + + fun retryLyrics() { + commands.retryLyrics() + } + + fun requestArtwork(quality: ArtworkQuality = ArtworkQuality.HQ) { + commands.requestArtwork(quality) + } + + fun retryArtwork() { + commands.retryArtwork() + } + + fun requestLyricDiagnostic() { + commands.requestLyricDiagnostic() + } + + fun requestSonyLogs() { + commands.requestSonyLogs() + } + + fun requestMediaDump() { + commands.requestMediaDump() + } + + fun requestPlaybackState() { + commands.requestPlaybackState() + } + + fun syncHistory() { + commands.syncHistory() + } + + fun loadMoreHistory() { + commands.loadMoreHistory() + } + + fun clearLocalHistory() { + commands.clearLocalHistory() + } + + fun clearArtworkCache() { + commands.clearArtworkCache() + } + + fun clearLogs() { + repository.clearLogs() + } + + fun startLegacyRfcomm() { + commands.startLegacyRfcomm() + } + + fun stopLegacyRfcomm() { + commands.stopLegacyRfcomm() + } + + fun updateExperienceMode(value: AppExperienceMode) { + repository.updateExperienceMode(value) + } + + fun updatePerformanceMode(value: PlaybackPerformanceMode) { + repository.updatePerformanceMode(value) + refreshPowerPolicy(value) + } + + fun updateAutoReconnect(value: Boolean) { + repository.updateAutoReconnect(value) + } + + fun updateLyricOffset(value: Long) { + repository.updateLyricOffset(value) + _displayedPositionMs.value = repository.displayedPositionMs() + } + + fun updateLyricMode(value: LyricDisplayMode) { + repository.updateLyricMode(value) + } + + fun updateArtworkSize(value: Int) { + repository.updateArtworkSize(value) + } + + fun updateArtworkEnhancement(value: Boolean) { + repository.updateArtworkEnhancement(value) + } + + fun logFile() = repository.logStore.currentFile + + suspend fun historyArtwork(artworkId: String) = + repository.historyArtwork(artworkId) + + private fun refreshPowerPolicy( + performanceMode: PlaybackPerformanceMode = settings.value.performanceMode + ) { + val animationsEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ValueAnimator.areAnimatorsEnabled() + } else { + true + } + _reduceAnimations.value = !PlaybackClockPolicy.animationsEnabled( + performanceMode = performanceMode, + systemPowerSaveMode = isPowerSaveMode(), + systemAnimationsEnabled = animationsEnabled + ) + } + + private fun isPowerSaveMode(): Boolean = + getApplication() + .getSystemService(PowerManager::class.java) + ?.isPowerSaveMode == true +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/MainActivity.kt b/ControllerApp/src/main/java/com/example/controllerapp/MainActivity.kt index 8b08ec4..d759af1 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/MainActivity.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/MainActivity.kt @@ -1,663 +1,87 @@ package com.example.controllerapp import android.Manifest -import android.app.Activity -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager +import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint import android.os.Build import android.os.Bundle -import android.util.Log -import android.view.Gravity -import android.view.View -import android.widget.Button -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.ScrollView -import android.widget.SeekBar -import android.widget.TextView -import com.example.controllerapp.classicbluetooth.RfcommServerManager -import org.json.JSONObject - -class MainActivity : Activity() { - - private lateinit var connectionStateTextView: TextView - private lateinit var albumArtImageView: ImageView - private lateinit var titleTextView: TextView - private lateinit var artistTextView: TextView - private lateinit var albumTextView: TextView - private lateinit var playbackStateTextView: TextView - private lateinit var progressTextView: TextView - private lateinit var lyricTextView: TextView - private lateinit var progressBar: SeekBar - private lateinit var volumeTextView: TextView - private lateinit var volumeProgressBar: ProgressBar - private lateinit var drawerToggleButton: Button - private lateinit var drawerLayout: LinearLayout - private lateinit var logToggleButton: Button - private lateinit var logTextView: TextView - private lateinit var logScrollView: ScrollView - - private var bluetoothAdapter: BluetoothAdapter? = null - private var rfcommServerManager: RfcommServerManager? = null - private lateinit var albumArtReceiver: AlbumArtReceiver - private val recentLogs = ArrayDeque() - private var currentDurationMs: Long = 0L - private var isUserSeeking = false +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.example.controllerapp.service.ControllerConnectionService +import com.example.controllerapp.ui.ControllerApp +import com.example.controllerapp.ui.ControllerTheme + +class MainActivity : ComponentActivity() { + private val viewModel: ControllerViewModel by viewModels() + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + if (hasBluetoothPermission()) startConnectionService() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setupUi() - initializeBluetooth() - requestRequiredPermissions() - updateConnectionState(CONNECTION_DISCONNECTED) - log("UI ready") - autoStartRfcommServer() - } - - override fun onDestroy() { - rfcommServerManager?.stopServer() - super.onDestroy() - } - - private fun setupUi() { - val root = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(24), dp(20), dp(16)) - } - - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - - val contentScrollView = ScrollView(this).apply { - addView(content) - } - - connectionStateTextView = TextView(this).apply { - textSize = 18f - gravity = Gravity.CENTER - text = CONNECTION_DISCONNECTED - } - content.addView(connectionStateTextView, matchWrapParams()) - - albumArtImageView = ImageView(this).apply { - scaleType = ImageView.ScaleType.CENTER_CROP - setImageBitmap(createDefaultAlbumArt()) - } - content.addView( - albumArtImageView, - LinearLayout.LayoutParams(dp(ALBUM_ART_SIZE_DP), dp(ALBUM_ART_SIZE_DP)).apply { - gravity = Gravity.CENTER_HORIZONTAL - topMargin = dp(20) - } - ) - - titleTextView = TextView(this).apply { - textSize = 22f - gravity = Gravity.CENTER - maxLines = 2 - text = "Title" - } - content.addView(titleTextView, topMarginParams(dp(24))) - - artistTextView = TextView(this).apply { - textSize = 16f - gravity = Gravity.CENTER - maxLines = 1 - text = "Artist" - } - content.addView(artistTextView, topMarginParams(dp(8))) - - albumTextView = TextView(this).apply { - textSize = 14f - gravity = Gravity.CENTER - maxLines = 1 - text = "Album" - } - content.addView(albumTextView, topMarginParams(dp(4))) - - playbackStateTextView = TextView(this).apply { - textSize = 16f - gravity = Gravity.CENTER - text = "Paused" - } - content.addView(playbackStateTextView, topMarginParams(dp(20))) - - progressTextView = TextView(this).apply { - textSize = 14f - gravity = Gravity.CENTER - text = "00:00 / 00:00" - } - content.addView(progressTextView, topMarginParams(dp(8))) - - progressBar = SeekBar(this).apply { - max = 1 - progress = 0 - setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) { - progressTextView.text = "${formatMs(progress.toLong())} / ${formatMs(currentDurationMs)}" - } - } - - override fun onStartTrackingTouch(seekBar: SeekBar?) { - isUserSeeking = true - } - - override fun onStopTrackingTouch(seekBar: SeekBar?) { - val targetPosition = seekBar?.progress?.toLong() ?: 0L - progressTextView.text = "${formatMs(targetPosition)} / ${formatMs(currentDurationMs)}" - isUserSeeking = false - sendSeekToCommand(targetPosition) + // Ask for/restore the connected-device service before Compose performs its first frame. + // This keeps the user-initiated foreground-start allowance alive on aggressive vendor + // Android builds, including launches while the keyguard is showing. + ensurePermissionsAndStart() + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> viewModel.setUiVisible(true) + Lifecycle.Event.ON_STOP -> viewModel.setUiVisible(false) + else -> Unit } - }) - } - content.addView(progressBar, topMarginParams(dp(8))) - - lyricTextView = TextView(this).apply { - textSize = 18f - gravity = Gravity.CENTER - maxLines = 2 - text = NO_LYRIC_TEXT - } - content.addView(lyricTextView, topMarginParams(dp(18))) - - volumeTextView = TextView(this).apply { - textSize = 14f - gravity = Gravity.CENTER - text = "Volume: -- / --" - } - content.addView(volumeTextView, topMarginParams(dp(14))) - - volumeProgressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { - max = 1 - progress = 0 - } - content.addView(volumeProgressBar, topMarginParams(dp(6))) - - val controlRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER - } - controlRow.addView(createButton("上一首") { sendRfcommCommand("PREVIOUS") }, rowButtonParams()) - controlRow.addView(createButton("播放/暂停") { sendRfcommCommand("PLAY_PAUSE") }, rowButtonParams()) - controlRow.addView(createButton("下一首") { sendRfcommCommand("NEXT") }, rowButtonParams()) - content.addView(controlRow, topMarginParams(dp(28))) - - val volumeRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER - } - volumeRow.addView(createButton("音量-") { sendRfcommCommand("VOLUME_DOWN") }, rowButtonParams()) - volumeRow.addView(createButton("音量+") { sendRfcommCommand("VOLUME_UP") }, rowButtonParams()) - content.addView(volumeRow, topMarginParams(dp(8))) - - drawerToggleButton = createButton("更多控制") { toggleDrawer() } - - drawerLayout = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - visibility = View.GONE - } - - val serverRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - } - serverRow.addView(createButton("启动服务") { startRfcommServer() }, rowButtonParams()) - serverRow.addView(createButton("停止服务") { stopRfcommServer() }, rowButtonParams()) - drawerLayout.addView(serverRow, topMarginParams(dp(8))) - - val auxTopRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - } - auxTopRow.addView(createButton("获取播放状态") { sendPlaybackStateCommand() }, rowButtonParams()) - auxTopRow.addView(createButton("获取音量") { sendGetVolumeCommand() }, rowButtonParams()) - drawerLayout.addView(auxTopRow, topMarginParams(dp(8))) - - val auxBottomRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - } - auxBottomRow.addView(createButton("诊断音频") { sendAudioStreamDiagnosticCommand() }, rowButtonParams()) - drawerLayout.addView(auxBottomRow, topMarginParams(dp(8))) - - logTextView = TextView(this).apply { - textSize = 12f - text = "" - } - logScrollView = ScrollView(this).apply { - addView(logTextView) - } - - logToggleButton = createButton("隐藏日志") { toggleLogArea() } - - albumArtReceiver = AlbumArtReceiver( - logger = ::log, - onBitmapReady = ::showAlbumArt, - onUnavailable = ::showDefaultAlbumArt - ) - - root.addView( - contentScrollView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 0 - ).apply { - weight = 1f } ) - root.addView( - drawerToggleButton, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - topMargin = dp(8) + setContent { + ControllerTheme { + ControllerApp(viewModel) } - ) - root.addView( - drawerLayout, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - ) - root.addView( - logToggleButton, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - topMargin = dp(8) - } - ) - root.addView( - logScrollView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - dp(132) - ).apply { - topMargin = dp(12) - } - ) - - setContentView(root) - } - - private fun initializeBluetooth() { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - bluetoothAdapter = bluetoothManager?.adapter - - if (bluetoothAdapter == null) { - log("BluetoothAdapter unavailable") - return } - - log("Bluetooth initialized") } - private fun startRfcommServer() { - if (!ensureRequiredPermissions()) { - log("Please grant Bluetooth permissions, then start RFCOMM server again") - return - } - - val adapter = bluetoothAdapter - if (adapter == null) { - log("Cannot start RFCOMM server: Bluetooth unavailable") - return - } - - if (!adapter.isEnabled) { - log("Cannot start RFCOMM server: Bluetooth is disabled") - return - } - - updateConnectionState(CONNECTION_WAITING) - rfcommServerManager?.stopServer() - rfcommServerManager = RfcommServerManager( - bluetoothAdapter = adapter, - logger = ::log, - onMessageReceived = ::handleRfcommMessage - ) - rfcommServerManager?.startServer() - } - - private fun stopRfcommServer() { - rfcommServerManager?.stopServer() - rfcommServerManager = null - updateConnectionState(CONNECTION_DISCONNECTED) - log("RFCOMM server stopped") - } - - private fun autoStartRfcommServer() { - if (!hasRequiredPermissions()) { - log("Auto start waiting for Bluetooth permissions") - return - } - - if (rfcommServerManager != null) { - return - } - - log("Auto start RFCOMM server") - startRfcommServer() - } - - private fun handleRfcommMessage(message: String) { - try { - val jsonObject = JSONObject(message) - when (jsonObject.optString("type")) { - "playbackState" -> updatePlaybackState(jsonObject) - "volumeState" -> updateVolumeState(jsonObject) - "audioStreamDiagnostic" -> logAudioStreamDiagnostic(jsonObject) - "albumArtStart", - "albumArtChunk", - "albumArtEnd", - "albumArtUnavailable" -> albumArtReceiver.handle(jsonObject) - } - } catch (exception: Exception) { - log("Failed to parse RFCOMM message: ${exception.message}") + private fun ensurePermissionsAndStart() { + val missing = requiredPermissions().filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } - } - - private fun updateVolumeState(jsonObject: JSONObject) { - runOnUiThread { - val current = jsonObject.optInt("current") - val max = jsonObject.optInt("max") - volumeTextView.text = "Volume: $current / $max" - volumeProgressBar.max = max.coerceAtLeast(1) - volumeProgressBar.progress = current.coerceIn(0, volumeProgressBar.max) - log("volumeState current=$current max=$max") + if (missing.isEmpty()) { + startConnectionService() + } else { + permissionLauncher.launch(missing.toTypedArray()) } } - private fun logAudioStreamDiagnostic(jsonObject: JSONObject) { - val streams = jsonObject.optJSONArray("streams") - if (streams == null) { - log("Audio stream diagnostic response missing streams") - return + private fun hasBluetoothPermission(): Boolean = + requiredBluetoothPermissions().all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED } - for (index in 0 until streams.length()) { - val stream = streams.optJSONObject(index) ?: continue - val name = stream.optString("stream") - val error = stream.optString("error") - - if (error.isNotBlank()) { - log("[AudioStreamDiagnostic] stream=$name error=$error") - } else { - log( - "[AudioStreamDiagnostic] stream=$name " + - "before=${stream.optInt("before")} " + - "max=${stream.optInt("max")} " + - "afterRaise=${stream.optInt("afterRaise")} " + - "afterLower=${stream.optInt("afterLower")}" - ) - } - } + private fun startConnectionService() { + val intent = Intent(this, ControllerConnectionService::class.java) + ContextCompat.startForegroundService(this, intent) } - private fun updatePlaybackState(jsonObject: JSONObject) { - runOnUiThread { - val title = jsonObject.optString("title") - val artist = jsonObject.optString("artist") - val album = jsonObject.optString("album") - val playing = jsonObject.optBoolean("playing") - val position = jsonObject.optLong("position") - val duration = jsonObject.optLong("duration") - val lyric = jsonObject.optString("lyric") - currentDurationMs = duration.coerceAtLeast(0L) - - titleTextView.text = title.ifBlank { "Title" } - artistTextView.text = artist.ifBlank { "Artist" } - albumTextView.text = album.ifBlank { "Album" } - playbackStateTextView.text = if (playing) "Playing" else "Paused" - if (!isUserSeeking) { - progressTextView.text = "${formatMs(position)} / ${formatMs(duration)}" - progressBar.max = duration.coerceAtLeast(1L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - progressBar.progress = position.coerceIn(0L, duration.coerceAtLeast(0L)) - .coerceAtMost(Int.MAX_VALUE.toLong()) - .toInt() + private fun requiredPermissions(): List = + buildList { + addAll(requiredBluetoothPermissions()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.POST_NOTIFICATIONS) } - lyricTextView.text = lyric.ifBlank { NO_LYRIC_TEXT } - } - } - - private fun showAlbumArt(bitmap: Bitmap) { - runOnUiThread { - albumArtImageView.setImageBitmap(bitmap) - } - } - - private fun showDefaultAlbumArt() { - runOnUiThread { - albumArtImageView.setImageBitmap(createDefaultAlbumArt()) - } - } - - private fun sendRfcommCommand(command: String) { - val manager = rfcommServerManager - if (manager == null) { - log("RFCOMM not connected") - return - } - - manager.sendCommand(command) - } - - private fun sendGetVolumeCommand() { - val manager = rfcommServerManager - if (manager == null) { - log("RFCOMM not connected") - return - } - - manager.sendGetVolume() - } - - private fun sendAudioStreamDiagnosticCommand() { - val manager = rfcommServerManager - if (manager == null) { - log("RFCOMM not connected") - return - } - - manager.sendAudioStreamDiagnostic() - } - - private fun sendPlaybackStateCommand() { - val manager = rfcommServerManager - if (manager == null) { - log("RFCOMM not connected") - return - } - - manager.sendPlaybackStateRequest() - } - - private fun sendSeekToCommand(positionMs: Long) { - val manager = rfcommServerManager - if (manager == null) { - log("RFCOMM not connected") - return - } - - val safePosition = positionMs.coerceAtLeast(0L) - log("[Seek]\nuser seek to=$safePosition") - manager.sendSeekTo(safePosition) - log("[Seek]\ncommand sent") - } - - private fun toggleDrawer() { - val isOpen = drawerLayout.visibility == View.VISIBLE - drawerLayout.visibility = if (isOpen) View.GONE else View.VISIBLE - drawerToggleButton.text = if (isOpen) "更多控制" else "收起控制" - } - - private fun toggleLogArea() { - val isVisible = logScrollView.visibility == View.VISIBLE - logScrollView.visibility = if (isVisible) View.GONE else View.VISIBLE - logToggleButton.text = if (isVisible) "显示日志" else "隐藏日志" - } - - private fun updateConnectionState(state: String) { - runOnUiThread { - connectionStateTextView.text = state - } - } - - private fun ensureRequiredPermissions(): Boolean { - val missingPermissions = requiredPermissions().filter { - checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED - } - - if (missingPermissions.isNotEmpty()) { - requestPermissions( - missingPermissions.toTypedArray(), - REQUEST_PERMISSIONS - ) - return false } - return true - } - - private fun hasRequiredPermissions(): Boolean { - return requiredPermissions().all { - checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } - } - - private fun requestRequiredPermissions() { - ensureRequiredPermissions() - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == REQUEST_PERMISSIONS && hasRequiredPermissions()) { - autoStartRfcommServer() - } - } - - private fun requiredPermissions(): List { - val permissions = mutableListOf() - + private fun requiredBluetoothPermissions(): List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - permissions += Manifest.permission.BLUETOOTH_CONNECT + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) } else { - permissions += Manifest.permission.ACCESS_FINE_LOCATION - } - - return permissions - } - - private fun log(message: String) { - when { - message == "RFCOMM server listening" -> updateConnectionState(CONNECTION_WAITING) - message == "Client connected" -> updateConnectionState(CONNECTION_CONNECTED) - message.startsWith("RFCOMM server failed") -> updateConnectionState(CONNECTION_DISCONNECTED) - message.startsWith("RFCOMM read stopped") -> updateConnectionState(CONNECTION_DISCONNECTED) - } - - val fullMessage = "[ControllerApp] $message" - Log.i(TAG, fullMessage) - runOnUiThread { - recentLogs.addLast(fullMessage) - while (recentLogs.size > MAX_LOG_LINES) { - recentLogs.removeFirst() - } - logTextView.text = recentLogs.joinToString("\n") - logScrollView.postDelayed({ - logScrollView.fullScroll(ScrollView.FOCUS_DOWN) - }, LOG_SCROLL_DELAY_MS) - } - } - - private fun createButton(text: String, onClick: () -> Unit): Button { - return Button(this).apply { - this.text = text - isAllCaps = false - setOnClickListener { - onClick() - } - } - } - - private fun matchWrapParams(): LinearLayout.LayoutParams { - return LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - } - - private fun topMarginParams(topMargin: Int): LinearLayout.LayoutParams { - return matchWrapParams().apply { - this.topMargin = topMargin - } - } - - private fun rowButtonParams(): LinearLayout.LayoutParams { - return LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - weight = 1f - leftMargin = dp(3) - rightMargin = dp(3) + listOf(Manifest.permission.ACCESS_FINE_LOCATION) } - } - - private fun formatMs(value: Long): String { - val safeValue = value.coerceAtLeast(0L) - val totalSeconds = safeValue / 1000L - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L - return "%02d:%02d".format(minutes, seconds) - } - - private fun dp(value: Int): Int { - return (value * resources.displayMetrics.density).toInt() - } - - private fun createDefaultAlbumArt(): Bitmap { - val size = dp(ALBUM_ART_SIZE_DP) - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(72, 72, 72) - style = Paint.Style.FILL - } - canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), backgroundPaint) - - val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textAlign = Paint.Align.CENTER - textSize = dp(18).toFloat() - } - val y = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f - canvas.drawText("Album Art", size / 2f, y, textPaint) - return bitmap - } - - companion object { - private const val TAG = "ControllerApp" - private const val REQUEST_PERMISSIONS = 2001 - private const val MAX_LOG_LINES = 40 - private const val LOG_SCROLL_DELAY_MS = 50L - private const val ALBUM_ART_SIZE_DP = 110 - private const val NO_LYRIC_TEXT = "暂无歌词" - - private const val CONNECTION_DISCONNECTED = "\u672a\u8fde\u63a5" - private const val CONNECTION_WAITING = "\u7b49\u5f85\u8fde\u63a5" - private const val CONNECTION_CONNECTED = "\u5df2\u8fde\u63a5" - } } diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt b/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt new file mode 100644 index 0000000..41b2f52 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt @@ -0,0 +1,590 @@ +package com.example.controllerapp.ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelUuid +import com.example.controllerapp.model.ConnectionPhase +import java.util.UUID + +interface BleGattTransportListener { + fun onPhaseChanged( + phase: ConnectionPhase, + deviceName: String = "Sony PlayerAgent", + address: String = "", + reason: String = "" + ) + + fun onReady(deviceName: String, address: String, mtu: Int) + fun onNotification(value: ByteArray) + fun onNotifyActivity() + fun onWriteResult(success: Boolean, payload: ByteArray) +} + +@SuppressLint("MissingPermission") +class BleGattTransport( + context: Context, + private val logger: (String) -> Unit, + private val listener: BleGattTransportListener +) { + private val appContext = context.applicationContext + private val bluetoothManager = appContext.getSystemService(BluetoothManager::class.java) + private val adapter + get() = bluetoothManager?.adapter + private val thread = HandlerThread("Controller-BLE").apply { start() } + private val handler = Handler(thread.looper) + private var gatt: BluetoothGatt? = null + private var commandCharacteristic: BluetoothGattCharacteristic? = null + private var statusCharacteristic: BluetoothGattCharacteristic? = null + private var scanning = false + private var broadScanFallback = false + private var scanToken = 0L + private var closing = false + @Volatile + private var ready = false + private var mtu = DEFAULT_MTU + private var connectionToken = 0L + private val writes = BoundedGattWriteQueue(MAX_QUEUED_WRITES) + private var activeWrite: ByteArray? = null + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + handler.post { + val name = runCatching { + result.scanRecord?.deviceName ?: result.device.name + }.getOrNull().orEmpty() + val hasService = result.scanRecord?.serviceUuids + ?.contains(ParcelUuid(ControllerUuids.SERVICE_UUID)) == true + if (hasService || name == ControllerUuids.TARGET_DEVICE_NAME) { + logger("[BLE] target discovered name=$name") + stopScanOnThread() + connectOnThread(result.device) + } + } + } + + override fun onScanFailed(errorCode: Int) { + handler.post { + scanning = false + logger("[BLE] scan failed code=$errorCode") + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = "scan failed $errorCode" + ) + } + } + } + + private val callback = object : BluetoothGattCallback() { + override fun onConnectionStateChange( + callbackGatt: BluetoothGatt, + status: Int, + newState: Int + ) { + handler.post { + if (callbackGatt !== gatt) { + runCatching { callbackGatt.close() } + return@post + } + if (status != BluetoothGatt.GATT_SUCCESS || + newState == BluetoothProfile.STATE_DISCONNECTED + ) { + val reason = "gatt disconnected status=$status state=$newState" + logger("[BLE] $reason") + closeGattOnThread() + if (!closing) { + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = reason + ) + } + return@post + } + if (newState == BluetoothProfile.STATE_CONNECTED) { + val name = deviceName(callbackGatt.device) + logger("[BLE] connected name=$name") + listener.onPhaseChanged( + ConnectionPhase.DISCOVERING, + name, + callbackGatt.device.address.orEmpty() + ) + val requested = runCatching { + callbackGatt.requestMtu(REQUESTED_MTU) + }.getOrDefault(false) + if (!requested) { + discoverServicesOnThread(callbackGatt) + } else { + handler.postDelayed({ + if (commandCharacteristic == null && callbackGatt === gatt) { + discoverServicesOnThread(callbackGatt) + } + }, MTU_TIMEOUT_MS) + } + } + } + } + + override fun onMtuChanged(callbackGatt: BluetoothGatt, value: Int, status: Int) { + handler.post { + if (callbackGatt !== gatt) return@post + mtu = if (status == BluetoothGatt.GATT_SUCCESS) { + value.coerceAtLeast(DEFAULT_MTU) + } else { + DEFAULT_MTU + } + logger("[BLE] mtu=$mtu status=$status") + discoverServicesOnThread(callbackGatt) + } + } + + override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) { + handler.post { + if (callbackGatt !== gatt) return@post + if (status != BluetoothGatt.GATT_SUCCESS) { + failConnectionOnThread("service discovery failed $status") + return@post + } + val service = callbackGatt.getService(ControllerUuids.SERVICE_UUID) + if (service == null) { + failConnectionOnThread("Sony service missing") + return@post + } + configureServiceOnThread(callbackGatt, service) + } + } + + @Deprecated("Used on Android 12 and below") + override fun onCharacteristicChanged( + callbackGatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + handleCharacteristicValue(callbackGatt, characteristic, characteristic.value) + } + + override fun onCharacteristicChanged( + callbackGatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + handleCharacteristicValue(callbackGatt, characteristic, value) + } + + override fun onDescriptorWrite( + callbackGatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + handler.post { + if (callbackGatt !== gatt || + descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID + ) { + return@post + } + if (status != BluetoothGatt.GATT_SUCCESS) { + failConnectionOnThread("notify subscription failed $status") + return@post + } + val device = callbackGatt.device + logger("[BLE] notification subscribed") + ready = true + listener.onReady(deviceName(device), device.address.orEmpty(), mtu) + drainWritesOnThread() + } + } + + override fun onCharacteristicWrite( + callbackGatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + handler.post { + if (callbackGatt !== gatt || + characteristic.uuid != ControllerUuids.COMMAND_CHARACTERISTIC_UUID + ) { + return@post + } + val payload = activeWrite ?: return@post + activeWrite = null + val success = status == BluetoothGatt.GATT_SUCCESS + listener.onWriteResult(success, payload) + if (!success) { + logger("[BLE] command write failed status=$status") + } + drainWritesOnThread() + } + } + } + + fun start(savedAddress: String = "", forceScan: Boolean = false) { + handler.post { + closing = false + if (isReady()) return@post + if (!forceScan && savedAddress.isNotBlank()) { + val device = runCatching { adapter?.getRemoteDevice(savedAddress) }.getOrNull() + if (device != null) { + logger("[BLE] reconnecting saved Sony address") + connectOnThread(device) + return@post + } + } + if (!forceScan) { + val bondedSony = runCatching { + adapter?.bondedDevices?.firstOrNull { device -> + deviceName(device) == ControllerUuids.TARGET_DEVICE_NAME + } + }.getOrNull() + if (bondedSony != null) { + logger("[BLE] using bonded Sony address") + connectOnThread(bondedSony) + return@post + } + } + startScanOnThread() + } + } + + fun reconnect(reason: String) { + restart(savedAddress = "", reason = reason, forceScan = true) + } + + /** Always tears down the current GATT before attempting recovery. */ + fun restart(savedAddress: String, reason: String, forceScan: Boolean) { + handler.post { + logger("[BLE] restart reason=$reason forceScan=$forceScan") + closing = false + closeGattOnThread() + if (!forceScan && savedAddress.isNotBlank()) { + val device = runCatching { adapter?.getRemoteDevice(savedAddress) }.getOrNull() + if (device != null) { + connectOnThread(device) + return@post + } + } + startScanOnThread(reconnecting = true) + } + } + + fun write(value: ByteArray): Boolean { + if (value.isEmpty() || !ready) return false + handler.post { + if (!ready) { + listener.onWriteResult(false, value) + return@post + } + val dropped = writes.offer(value) + if (dropped != null) { + logger("[BLE] command queue full dropped=${String(dropped).take(80)}") + } + drainWritesOnThread() + } + return true + } + + fun disconnect() { + handler.post { + closing = true + stopScanOnThread() + closeGattOnThread() + writes.clear() + activeWrite = null + listener.onPhaseChanged(ConnectionPhase.DISCONNECTED, reason = "manual stop") + } + } + + fun close() { + handler.post { + closing = true + stopScanOnThread() + closeGattOnThread() + writes.clear() + activeWrite = null + thread.quitSafely() + } + } + + private fun handleCharacteristicValue( + callbackGatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray? + ) { + if (value == null || value.isEmpty()) return + handler.post { + if (callbackGatt !== gatt || + characteristic.uuid != ControllerUuids.STATUS_CHARACTERISTIC_UUID + ) { + return@post + } + listener.onNotifyActivity() + listener.onNotification(value.copyOf()) + } + } + + @SuppressLint("MissingPermission") + private fun startScanOnThread( + reconnecting: Boolean = false, + useBroadFallback: Boolean = false + ) { + if (scanning) return + val bluetoothAdapter = adapter + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = "Bluetooth unavailable" + ) + return + } + val scanner = bluetoothAdapter.bluetoothLeScanner + if (scanner == null) { + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = "BLE scanner unavailable" + ) + return + } + /* + * A few Android stacks evaluate hardware filters before merging Sony's + * primary advertisement (device name) and scan response (service UUID). + * Keep the efficient filtered path first, then use a short unfiltered + * compatibility scan while still validating every result in callback. + */ + val filters = if (useBroadFallback) { + null + } else { + listOf( + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(ControllerUuids.SERVICE_UUID)) + .build(), + ScanFilter.Builder() + .setDeviceName(ControllerUuids.TARGET_DEVICE_NAME) + .build() + ) + } + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + runCatching { + scanner.startScan(filters, settings, scanCallback) + scanning = true + broadScanFallback = useBroadFallback + val token = ++scanToken + listener.onPhaseChanged( + if (reconnecting) ConnectionPhase.RECONNECTING else ConnectionPhase.SCANNING + ) + logger( + "[BLE] scan started reconnecting=$reconnecting " + + "mode=${if (useBroadFallback) "compat" else "filtered"}" + ) + handler.postDelayed({ + if (scanning && token == scanToken && !broadScanFallback) { + logger("[BLE] filtered scan yielded no Sony; enabling compat scan") + stopScanOnThread() + startScanOnThread( + reconnecting = reconnecting, + useBroadFallback = true + ) + } + }, FILTERED_SCAN_TIMEOUT_MS) + handler.postDelayed({ + if (scanning && token == scanToken) { + stopScanOnThread() + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = "scan timeout" + ) + } + }, if (useBroadFallback) COMPAT_SCAN_TIMEOUT_MS else TOTAL_SCAN_GUARD_MS) + }.onFailure { + listener.onPhaseChanged( + ConnectionPhase.DISCONNECTED, + reason = "scan exception ${it.message}" + ) + } + } + + @SuppressLint("MissingPermission") + private fun stopScanOnThread() { + if (!scanning) return + runCatching { adapter?.bluetoothLeScanner?.stopScan(scanCallback) } + scanning = false + broadScanFallback = false + scanToken += 1L + } + + @SuppressLint("MissingPermission") + private fun connectOnThread(device: BluetoothDevice) { + stopScanOnThread() + closeGattOnThread() + commandCharacteristic = null + statusCharacteristic = null + ready = false + mtu = DEFAULT_MTU + val name = deviceName(device) + listener.onPhaseChanged( + ConnectionPhase.CONNECTING, + name, + device.address.orEmpty() + ) + logger("[BLE] connect name=$name address=${device.address}") + gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt(appContext, false, callback, BluetoothDevice.TRANSPORT_LE) + } else { + device.connectGatt(appContext, false, callback) + } + val expectedGatt = gatt + val token = ++connectionToken + handler.postDelayed({ + if (token == connectionToken && expectedGatt === gatt && !ready) { + failConnectionOnThread("connection setup timeout") + } + }, CONNECTION_SETUP_TIMEOUT_MS) + } + + @SuppressLint("MissingPermission") + private fun discoverServicesOnThread(callbackGatt: BluetoothGatt) { + if (callbackGatt !== gatt || commandCharacteristic != null) return + runCatching { callbackGatt.discoverServices() } + .onFailure { failConnectionOnThread("discover exception ${it.message}") } + } + + @SuppressLint("MissingPermission") + private fun configureServiceOnThread( + callbackGatt: BluetoothGatt, + service: BluetoothGattService + ) { + commandCharacteristic = service.getCharacteristic( + ControllerUuids.COMMAND_CHARACTERISTIC_UUID + ) + statusCharacteristic = service.getCharacteristic( + ControllerUuids.STATUS_CHARACTERISTIC_UUID + ) + val status = statusCharacteristic + if (commandCharacteristic == null || status == null) { + failConnectionOnThread("required characteristic missing") + return + } + listener.onPhaseChanged( + ConnectionPhase.SUBSCRIBING, + deviceName(callbackGatt.device), + callbackGatt.device.address.orEmpty() + ) + val enabled = runCatching { + callbackGatt.setCharacteristicNotification(status, true) + }.getOrDefault(false) + val cccd = status.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) + if (!enabled || cccd == null) { + failConnectionOnThread("cannot enable notifications") + return + } + val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + callbackGatt.writeDescriptor( + cccd, + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + ) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + callbackGatt.writeDescriptor(cccd) + } + if (!requested) { + failConnectionOnThread("descriptor write rejected") + } + } + + @SuppressLint("MissingPermission") + private fun drainWritesOnThread() { + if (activeWrite != null) return + val callbackGatt = gatt ?: return + val characteristic = commandCharacteristic ?: return + val value = writes.poll() ?: return + activeWrite = value + val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + callbackGatt.writeCharacteristic( + characteristic, + value, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + characteristic.value = value + @Suppress("DEPRECATION") + callbackGatt.writeCharacteristic(characteristic) + } + if (!requested) { + activeWrite = null + listener.onWriteResult(false, value) + handler.postDelayed(::drainWritesOnThread, WRITE_RETRY_DELAY_MS) + } else { + handler.postDelayed({ + if (activeWrite === value) { + activeWrite = null + logger("[BLE] command write callback timeout") + listener.onWriteResult(false, value) + failConnectionOnThread("command write callback timeout") + } + }, WRITE_CALLBACK_TIMEOUT_MS) + } + } + + private fun isReady(): Boolean = + gatt != null && commandCharacteristic != null && statusCharacteristic != null + + @SuppressLint("MissingPermission") + private fun deviceName(device: BluetoothDevice): String = + runCatching { device.name }.getOrNull() + ?.takeIf(String::isNotBlank) + ?: ControllerUuids.TARGET_DEVICE_NAME + + @SuppressLint("MissingPermission") + private fun failConnectionOnThread(reason: String) { + logger("[BLE] connection failed reason=$reason") + closeGattOnThread() + listener.onPhaseChanged(ConnectionPhase.DISCONNECTED, reason = reason) + } + + @SuppressLint("MissingPermission") + private fun closeGattOnThread() { + val current = gatt + gatt = null + commandCharacteristic = null + statusCharacteristic = null + ready = false + connectionToken += 1L + activeWrite = null + writes.clear() + runCatching { current?.disconnect() } + runCatching { current?.close() } + } + + companion object { + private val CLIENT_CHARACTERISTIC_CONFIG_UUID = + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val DEFAULT_MTU = 23 + private const val REQUESTED_MTU = 247 + private const val MTU_TIMEOUT_MS = 1_500L + private const val FILTERED_SCAN_TIMEOUT_MS = 6_000L + private const val COMPAT_SCAN_TIMEOUT_MS = 10_000L + private const val TOTAL_SCAN_GUARD_MS = 18_000L + private const val CONNECTION_SETUP_TIMEOUT_MS = 12_000L + private const val WRITE_CALLBACK_TIMEOUT_MS = 3_000L + private const val WRITE_RETRY_DELAY_MS = 100L + private const val MAX_QUEUED_WRITES = 128 + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ble/BoundedGattWriteQueue.kt b/ControllerApp/src/main/java/com/example/controllerapp/ble/BoundedGattWriteQueue.kt new file mode 100644 index 0000000..9431a0d --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ble/BoundedGattWriteQueue.kt @@ -0,0 +1,23 @@ +package com.example.controllerapp.ble + +import java.util.ArrayDeque + +class BoundedGattWriteQueue(private val capacity: Int) { + private val values = ArrayDeque() + + init { + require(capacity > 0) + } + + fun offer(value: ByteArray): ByteArray? { + val dropped = if (values.size >= capacity) values.removeFirst() else null + values.addLast(value.copyOf()) + return dropped + } + + fun poll(): ByteArray? = if (values.isEmpty()) null else values.removeFirst() + + fun clear() = values.clear() + + fun size(): Int = values.size +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/classicbluetooth/RfcommServerManager.kt b/ControllerApp/src/main/java/com/example/controllerapp/classicbluetooth/RfcommServerManager.kt index 4697ac2..fc89c5b 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/classicbluetooth/RfcommServerManager.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/classicbluetooth/RfcommServerManager.kt @@ -11,13 +11,16 @@ import java.io.InputStreamReader class RfcommServerManager( private val bluetoothAdapter: BluetoothAdapter, private val logger: (String) -> Unit, - private val onMessageReceived: (String) -> Unit = {} + private val onMessageReceived: (String) -> Unit = {}, + private val onConnectionChanged: (Boolean) -> Unit = {} ) { private var serverSocket: BluetoothServerSocket? = null private var clientSocket: BluetoothSocket? = null private var acceptThread: Thread? = null private var readThread: Thread? = null + @Volatile + private var connected = false @SuppressLint("MissingPermission") fun startServer() { @@ -37,6 +40,7 @@ class RfcommServerManager( if (socket != null) { logger("Client connected") + updateConnection(true) startReadLoop(socket) } } catch (exception: IOException) { @@ -75,11 +79,11 @@ class RfcommServerManager( sendRawMessage(message) } - private fun sendRawMessage(message: String) { + fun sendRawMessage(message: String): Boolean { val socket = clientSocket if (socket == null || !socket.isConnected) { logger("RFCOMM not connected") - return + return false } logger("Sending command: $message") @@ -96,6 +100,7 @@ class RfcommServerManager( name = "RfcommServerCommandThread" start() } + return true } private fun startReadLoop(socket: BluetoothSocket) { @@ -112,6 +117,8 @@ class RfcommServerManager( } } catch (exception: IOException) { logger("RFCOMM read stopped: ${exception.message}") + } finally { + updateConnection(false) } }.apply { name = "RfcommServerReadThread" @@ -136,5 +143,12 @@ class RfcommServerManager( acceptThread = null readThread = null + updateConnection(false) + } + + private fun updateConnection(value: Boolean) { + if (connected == value) return + connected = value + onConnectionChanged(value) } } diff --git a/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerLogStore.kt b/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerLogStore.kt new file mode 100644 index 0000000..159380b --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerLogStore.kt @@ -0,0 +1,86 @@ +package com.example.controllerapp.data + +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ControllerLogStore(context: Context) { + private val directory = File(context.filesDir, "logs").apply { mkdirs() } + val currentFile = File(directory, "controller-current.log") + private val thread = HandlerThread("Controller-Log").apply { start() } + private val handler = Handler(thread.looper) + private val pending = ArrayDeque() + private val listeners = LinkedHashSet<(String) -> Unit>() + private var writer: BufferedWriter? = null + private var flushScheduled = false + + fun append(message: String) { + val line = "${timestamp()} $message" + Log.i("ControllerApp", line) + synchronized(listeners) { + listeners.toList().forEach { it(line) } + } + handler.post { + pending.addLast(line) + if (!flushScheduled) { + flushScheduled = true + handler.postDelayed(::flushOnThread, 350L) + } + } + } + + fun addListener(listener: (String) -> Unit) { + synchronized(listeners) { listeners += listener } + } + + fun removeListener(listener: (String) -> Unit) { + synchronized(listeners) { listeners -= listener } + } + + fun clear() { + handler.post { + pending.clear() + writer?.close() + writer = null + currentFile.delete() + } + } + + fun close() { + handler.post { + flushOnThread() + writer?.close() + writer = null + thread.quitSafely() + } + } + + private fun flushOnThread() { + flushScheduled = false + if (pending.isEmpty()) return + val output = writer ?: BufferedWriter(FileWriter(currentFile, true)).also { + writer = it + } + while (pending.isNotEmpty()) { + output.append(pending.removeFirst()) + output.newLine() + } + output.flush() + } + + private fun timestamp(): String = DATE_FORMAT.get()!!.format(Date()) + + companion object { + private val DATE_FORMAT = object : ThreadLocal() { + override fun initialValue() = + SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + } + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerPreferences.kt b/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerPreferences.kt new file mode 100644 index 0000000..fb34adb --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/data/ControllerPreferences.kt @@ -0,0 +1,115 @@ +package com.example.controllerapp.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.controllerapp.model.AppExperienceMode +import com.example.controllerapp.model.ControllerSettings +import com.example.controllerapp.model.LyricDisplayMode +import com.example.controllerapp.model.PlaybackPerformanceMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.controllerPreferencesDataStore by preferencesDataStore( + name = "controller_preferences" +) + +class ControllerPreferences(private val context: Context) { + val settings: Flow = + context.controllerPreferencesDataStore.data.map { values -> + ControllerSettings( + experienceMode = values[Keys.EXPERIENCE_MODE] + ?.let { runCatching { AppExperienceMode.valueOf(it) }.getOrNull() } + ?: AppExperienceMode.DAILY, + performanceMode = values[Keys.PERFORMANCE_MODE] + ?.let { runCatching { PlaybackPerformanceMode.valueOf(it) }.getOrNull() } + ?: PlaybackPerformanceMode.AUTOMATIC, + autoReconnect = values[Keys.AUTO_RECONNECT] ?: true, + lyricOffsetMs = values[Keys.LYRIC_OFFSET_MS] ?: 600L, + lyricDisplayMode = values[Keys.LYRIC_DISPLAY_MODE] + ?.let { runCatching { LyricDisplayMode.valueOf(it) }.getOrNull() } + ?: LyricDisplayMode.ORIGINAL_TRANSLATION, + artworkDisplaySizeDp = values[Keys.ARTWORK_SIZE_DP] ?: 260, + artworkEnhancementEnabled = values[Keys.ARTWORK_ENHANCEMENT] ?: true + ) + } + + val savedDeviceAddress: Flow = + context.controllerPreferencesDataStore.data.map { + it[Keys.DEVICE_ADDRESS].orEmpty() + } + + suspend fun setExperienceMode(value: AppExperienceMode) = edit { + this[Keys.EXPERIENCE_MODE] = value.name + } + + suspend fun setPerformanceMode(value: PlaybackPerformanceMode) = edit { + this[Keys.PERFORMANCE_MODE] = value.name + } + + suspend fun setAutoReconnect(value: Boolean) = edit { + this[Keys.AUTO_RECONNECT] = value + } + + suspend fun setLyricOffsetMs(value: Long) = edit { + this[Keys.LYRIC_OFFSET_MS] = value.coerceIn(-2_000L, 2_000L) + } + + suspend fun setLyricDisplayMode(value: LyricDisplayMode) = edit { + this[Keys.LYRIC_DISPLAY_MODE] = value.name + } + + suspend fun setArtworkDisplaySizeDp(value: Int) = edit { + this[Keys.ARTWORK_SIZE_DP] = value.coerceIn(200, 260) + } + + suspend fun setArtworkEnhancementEnabled(value: Boolean) = edit { + this[Keys.ARTWORK_ENHANCEMENT] = value + } + + suspend fun setSavedDeviceAddress(value: String) = edit { + if (value.isBlank()) { + remove(Keys.DEVICE_ADDRESS) + } else { + this[Keys.DEVICE_ADDRESS] = value + } + } + + private suspend fun edit(block: MutableMapPreferences.() -> Unit) { + context.controllerPreferencesDataStore.edit { values -> + MutableMapPreferences(values).apply(block).commit() + } + } + + private class MutableMapPreferences( + private val target: androidx.datastore.preferences.core.MutablePreferences + ) { + operator fun set( + key: androidx.datastore.preferences.core.Preferences.Key, + value: T + ) { + target[key] = value + } + + fun remove(key: androidx.datastore.preferences.core.Preferences.Key) { + target.remove(key) + } + + fun commit() = Unit + } + + private object Keys { + val EXPERIENCE_MODE = stringPreferencesKey("experience_mode") + val PERFORMANCE_MODE = stringPreferencesKey("performance_mode") + val AUTO_RECONNECT = booleanPreferencesKey("auto_reconnect") + val LYRIC_OFFSET_MS = longPreferencesKey("lyric_offset_ms") + val LYRIC_DISPLAY_MODE = stringPreferencesKey("lyric_display_mode") + val ARTWORK_SIZE_DP = intPreferencesKey("artwork_size_dp") + val ARTWORK_ENHANCEMENT = booleanPreferencesKey("artwork_enhancement") + val DEVICE_ADDRESS = stringPreferencesKey("device_address") + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/data/NowPlayingSnapshotStore.kt b/ControllerApp/src/main/java/com/example/controllerapp/data/NowPlayingSnapshotStore.kt new file mode 100644 index 0000000..7ba4f1c --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/data/NowPlayingSnapshotStore.kt @@ -0,0 +1,146 @@ +package com.example.controllerapp.data + +import android.content.Context +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricWord +import com.example.controllerapp.model.PlaybackState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +data class RestoredNowPlayingSnapshot( + val playback: PlaybackState, + val lines: List, + val artworkId: String +) + +class NowPlayingSnapshotStore(context: Context) { + private val file = File(context.filesDir, "LastNowPlayingSnapshot-v1.json") + + suspend fun load(nowMs: Long = System.currentTimeMillis()): RestoredNowPlayingSnapshot? = + withContext(Dispatchers.IO) { + val value = runCatching { JSONObject(file.readText()) }.getOrNull() + ?: return@withContext null + val savedAt = value.optLong("savedAt") + if (value.optInt("version") != 1 || + savedAt <= 0L || + nowMs - savedAt !in 0..MAXIMUM_AGE_MS + ) { + return@withContext null + } + val trackId = value.optString("trackId") + val title = value.optString("title") + if (trackId.isBlank() || title.isBlank()) return@withContext null + val lines = buildList { + val array = value.optJSONArray("lines") ?: JSONArray() + repeat(array.length()) lineLoop@{ index -> + val line = array.optJSONObject(index) ?: return@lineLoop + add( + LyricLine( + index = line.optInt("index"), + timeMs = line.optLong("timeMs"), + durationMs = line.optLong("durationMs"), + text = line.optString("text"), + translation = line.optString("translation").ifBlank { null }, + romanization = line.optString("romanization").ifBlank { null }, + words = buildList { + val words = line.optJSONArray("words") ?: JSONArray() + repeat(words.length()) wordLoop@{ wordIndex -> + val word = words.optJSONObject(wordIndex) ?: return@wordLoop + add( + LyricWord( + index = wordIndex, + startMs = word.optLong("startMs"), + durationMs = word.optLong("durationMs"), + text = word.optString("text") + ) + ) + } + } + ) + ) + } + } + RestoredNowPlayingSnapshot( + playback = PlaybackState( + trackId = trackId, + generation = value.optLong("generation"), + title = title, + artist = value.optString("artist"), + album = value.optString("album"), + isPlaying = false, + positionMs = value.optLong("positionMs"), + durationMs = value.optLong("durationMs"), + restoredSnapshot = true + ), + lines = lines, + artworkId = value.optString("artworkId") + ) + } + + suspend fun save( + playback: PlaybackState, + lines: List, + artworkId: String + ) = withContext(Dispatchers.IO) { + if (playback.trackId.isBlank() || playback.title.isBlank() || playback.title == "-") { + return@withContext + } + val value = JSONObject() + .put("version", 1) + .put("trackId", playback.trackId) + .put("generation", playback.generation) + .put("title", playback.title) + .put("artist", playback.artist) + .put("album", playback.album) + .put("positionMs", playback.positionMs) + .put("durationMs", playback.durationMs) + .put("artworkId", artworkId) + .put("savedAt", System.currentTimeMillis()) + .put( + "lines", + JSONArray().also { array -> + lines.take(5).forEach { line -> + array.put( + JSONObject() + .put("index", line.index) + .put("timeMs", line.timeMs) + .put("durationMs", line.durationMs) + .put("text", line.text) + .put("translation", line.translation.orEmpty()) + .put("romanization", line.romanization.orEmpty()) + .put( + "words", + JSONArray().also { words -> + line.words.forEach { word -> + words.put( + JSONObject() + .put("startMs", word.startMs) + .put("durationMs", word.durationMs) + .put("text", word.text) + ) + } + } + ) + ) + } + } + ) + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeText(value.toString()) + if (!temp.renameTo(file)) { + file.delete() + temp.renameTo(file) + } + } + + suspend fun clear() = withContext(Dispatchers.IO) { + file.delete() + } + + companion object { + const val MAXIMUM_AGE_MS = 24 * 60 * 60 * 1_000L + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/data/PlaybackHistoryDatabase.kt b/ControllerApp/src/main/java/com/example/controllerapp/data/PlaybackHistoryDatabase.kt new file mode 100644 index 0000000..f1518c6 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/data/PlaybackHistoryDatabase.kt @@ -0,0 +1,98 @@ +package com.example.controllerapp.data + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import com.example.controllerapp.model.PlaybackHistorySession +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "playback_history") +data class PlaybackHistoryEntity( + @PrimaryKey val sessionId: Long, + val trackKey: String, + val title: String, + val artist: String, + val album: String, + val artworkId: String?, + val startedAt: Long, + val endedAt: Long?, + val listenedMs: Long, + val durationMs: Long, + val completed: Boolean, + val skipped: Boolean, + val countedPlay: Boolean +) { + fun toModel() = PlaybackHistorySession( + sessionId = sessionId, + trackKey = trackKey, + title = title, + artist = artist, + album = album, + artworkId = artworkId, + startedAt = startedAt, + endedAt = endedAt, + listenedMs = listenedMs, + durationMs = durationMs, + completed = completed, + skipped = skipped, + countedPlay = countedPlay + ) + + companion object { + fun from(value: PlaybackHistorySession) = PlaybackHistoryEntity( + sessionId = value.sessionId, + trackKey = value.trackKey, + title = value.title, + artist = value.artist, + album = value.album, + artworkId = value.artworkId, + startedAt = value.startedAt, + endedAt = value.endedAt, + listenedMs = value.listenedMs, + durationMs = value.durationMs, + completed = value.completed, + skipped = value.skipped, + countedPlay = value.countedPlay + ) + } +} + +@Dao +interface PlaybackHistoryDao { + @Query("SELECT * FROM playback_history ORDER BY sessionId DESC LIMIT :limit") + fun observeRecent(limit: Int = 500): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(values: List) + + @Query("DELETE FROM playback_history") + suspend fun clear() + + @Query("SELECT MAX(sessionId) FROM playback_history") + suspend fun latestSessionId(): Long? +} + +@Database( + entities = [PlaybackHistoryEntity::class], + version = 1, + exportSchema = false +) +abstract class PlaybackHistoryDatabase : RoomDatabase() { + abstract fun historyDao(): PlaybackHistoryDao + + companion object { + fun create(context: Context): PlaybackHistoryDatabase = + Room.databaseBuilder( + context.applicationContext, + PlaybackHistoryDatabase::class.java, + "controller_history.db" + ).fallbackToDestructiveMigration().build() + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt b/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt new file mode 100644 index 0000000..3fd2ee1 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt @@ -0,0 +1,330 @@ +package com.example.controllerapp.media + +import android.content.ComponentCallbacks2 +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import com.example.controllerapp.model.ArtworkQuality +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import kotlin.math.abs +import kotlin.math.max + +data class CachedArtwork( + val bitmap: Bitmap, + val quality: ArtworkQuality, + val createdAtMs: Long, + val requiresRefresh: Boolean +) + +class ArtworkCache(context: Context) { + private val directory = File(context.filesDir, "album_art_cache").apply { mkdirs() } + private val memory = object : LruCache(32 * 1024) { + override fun sizeOf(key: String, value: CachedArtwork): Int = + (value.bitmap.allocationByteCount / 1024).coerceAtLeast(1) + } + + suspend fun load( + artworkId: String, + quality: ArtworkQuality, + maximumPixelSize: Int + ): CachedArtwork? = withContext(Dispatchers.IO) { + loadOnIo(artworkId, quality, maximumPixelSize) + } + + /** + * The player always prefers HQ, then Preview. Keeping this selection in + * the cache prevents the controller from doing two sequential disk reads + * every time a known song becomes active. + */ + suspend fun loadBest(artworkId: String, maximumPixelSize: Int): CachedArtwork? = + withContext(Dispatchers.IO) { + peekBest(artworkId, maximumPixelSize) + ?: loadOnIo(artworkId, ArtworkQuality.HQ, maximumPixelSize) + ?: loadOnIo(artworkId, ArtworkQuality.PREVIEW, maximumPixelSize) + } + + /** Fast path for the media reducer; no file I/O or bitmap decoding. */ + fun peekBest(artworkId: String, maximumPixelSize: Int): CachedArtwork? = + ArtworkQuality.entries + .asSequence() + .filter { it == ArtworkQuality.HQ || it == ArtworkQuality.PREVIEW } + .sortedByDescending(ArtworkQuality::rank) + .mapNotNull { quality -> memory.get(key(artworkId, quality, maximumPixelSize)) } + .firstOrNull() + + private fun loadOnIo( + artworkId: String, + quality: ArtworkQuality, + maximumPixelSize: Int + ): CachedArtwork? { + val key = key(artworkId, quality, maximumPixelSize) + memory.get(key)?.let { + return it + } + val imageFile = imageFile(artworkId, quality) + val metadataFile = metadataFile(artworkId, quality) + if (!imageFile.isFile || !metadataFile.isFile) return null + val metadata = runCatching { JSONObject(metadataFile.readText()) }.getOrNull() + ?: return null + val createdAt = metadata.optLong("createdAtMs") + val ageMs = System.currentTimeMillis() - createdAt + if (createdAt <= 0L || ageMs < 0L || ageMs > HARD_TTL_MS) { + imageFile.delete() + metadataFile.delete() + return null + } + val bitmap = decodeDownsampled(imageFile.readBytes(), maximumPixelSize) + ?: return null + if (ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { + bitmap.recycle() + imageFile.delete() + metadataFile.delete() + return null + } + val cached = CachedArtwork( + bitmap = bitmap, + quality = quality, + createdAtMs = createdAt, + requiresRefresh = ageMs > metadata.optLong( + "refreshAfterMs", + STABLE_REFRESH_MS + ) + ) + memory.put(key, cached) + trimDecodedEntries() + return cached + } + + suspend fun store( + artworkId: String, + quality: ArtworkQuality, + data: ByteArray, + bitmap: Bitmap, + maximumPixelSize: Int, + source: String = "ble" + ) = withContext(Dispatchers.IO) { + if (ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) return@withContext + directory.mkdirs() + val image = imageFile(artworkId, quality) + val metadata = metadataFile(artworkId, quality) + val imageTemp = File(image.parentFile, "${image.name}.tmp") + val metadataTemp = File(metadata.parentFile, "${metadata.name}.tmp") + FileOutputStream(imageTemp).use { it.write(data) } + val refreshAfter = if (source == "notificationLargeIcon") { + TRANSIENT_REFRESH_MS + } else { + STABLE_REFRESH_MS + } + metadataTemp.writeText( + JSONObject() + .put("version", 1) + .put("artworkId", artworkId) + .put("quality", quality.name) + .put("source", source) + .put("createdAtMs", System.currentTimeMillis()) + .put("refreshAfterMs", refreshAfter) + .put("width", bitmap.width) + .put("height", bitmap.height) + .put("bytes", data.size) + .toString() + ) + if (!imageTemp.renameTo(image)) { + image.delete() + imageTemp.renameTo(image) + } + if (!metadataTemp.renameTo(metadata)) { + metadata.delete() + metadataTemp.renameTo(metadata) + } + memory.put( + key(artworkId, quality, maximumPixelSize), + CachedArtwork( + bitmap = bitmap, + quality = quality, + createdAtMs = System.currentTimeMillis(), + requiresRefresh = false + ) + ) + trimDecodedEntries() + trimDisk() + } + + suspend fun remove(artworkId: String) = withContext(Dispatchers.IO) { + ArtworkQuality.entries.forEach { quality -> + imageFile(artworkId, quality).delete() + metadataFile(artworkId, quality).delete() + } + memory.evictAll() + } + + fun trimMemory(level: Int) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + memory.evictAll() + } + } + + fun clearDecoded() = memory.evictAll() + + fun cacheStats(): Pair { + val files = directory.listFiles { file -> file.extension == "jpg" }.orEmpty() + return files.size to files.sumOf(File::length) + } + + private fun trimDecodedEntries() { + while (memory.snapshot().size > MAX_MEMORY_ENTRIES) { + val eldest = memory.snapshot().keys.firstOrNull() ?: return + memory.remove(eldest) + } + } + + private fun trimDisk() { + val files = directory.listFiles { file -> file.extension == "jpg" } + ?.sortedBy(File::lastModified) + .orEmpty() + var bytes = files.sumOf(File::length) + var count = files.size + files.forEach { file -> + if (count <= MAX_DISK_FILES && bytes <= MAX_DISK_BYTES) return + bytes -= file.length() + count -= 1 + file.delete() + File(file.parentFile, "${file.nameWithoutExtension}.json").delete() + } + } + + private fun imageFile(artworkId: String, quality: ArtworkQuality) = + File(directory, "${hash(artworkId)}_${quality.name.lowercase()}.jpg") + + private fun metadataFile(artworkId: String, quality: ArtworkQuality) = + File(directory, "${hash(artworkId)}_${quality.name.lowercase()}.json") + + private fun key(artworkId: String, quality: ArtworkQuality, maximumPixelSize: Int) = + "$artworkId|${quality.name}|$maximumPixelSize" + + companion object { + private const val TRANSIENT_REFRESH_MS = 30 * 60 * 1_000L + private const val STABLE_REFRESH_MS = 24 * 60 * 60 * 1_000L + private const val HARD_TTL_MS = 30 * 24 * 60 * 60 * 1_000L + private const val MAX_DISK_FILES = 500 + private const val MAX_DISK_BYTES = 300L * 1024 * 1024 + private const val MAX_MEMORY_ENTRIES = 40 + + fun decodeDownsampled(data: ByteArray, maximumPixelSize: Int): Bitmap? { + if (data.isEmpty() || maximumPixelSize <= 0) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(data, 0, data.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (max(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maximumPixelSize) { + sample *= 2 + } + return BitmapFactory.decodeByteArray( + data, + 0, + data.size, + BitmapFactory.Options().apply { + inSampleSize = sample + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + ) + } + + private fun hash(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } +} + +object ArtworkPlaceholderPolicy { + fun isLikelyPlaceholder(bitmap: Bitmap): Boolean { + if (bitmap.width <= 16 && bitmap.height <= 16) return true + val sampleWidth = minOf(24, bitmap.width) + val sampleHeight = minOf(24, bitmap.height) + val sample = if (sampleWidth == bitmap.width && sampleHeight == bitmap.height) { + bitmap + } else { + Bitmap.createScaledBitmap(bitmap, sampleWidth, sampleHeight, true) + } + return try { + val pixels = IntArray(sampleWidth * sampleHeight) + sample.getPixels(pixels, 0, sampleWidth, 0, 0, sampleWidth, sampleHeight) + isLikelyPlaceholder(bitmap.width, bitmap.height, pixels) + } finally { + if (sample !== bitmap) sample.recycle() + } + } + + fun isLikelyPlaceholder(width: Int, height: Int, pixels: IntArray): Boolean { + if (width <= 0 || height <= 0 || pixels.isEmpty()) return true + val buckets = HashMap() + var visible = 0 + var colorful = 0 + pixels.forEach { pixel -> + val alpha = pixel ushr 24 + if (alpha < 24) return@forEach + val red = pixel ushr 16 and 0xff + val green = pixel ushr 8 and 0xff + val blue = pixel and 0xff + val maximum = max(red, max(green, blue)) + val minimum = minOf(red, green, blue) + if (maximum > 0 && (maximum - minimum).toFloat() / maximum > 0.12f) { + colorful += 1 + } + val bucket = ((red / 32) shl 10) or ((green / 32) shl 5) or (blue / 32) + buckets[bucket] = (buckets[bucket] ?: 0) + 1 + visible += 1 + } + if (visible == 0) return true + val knownSize = width in 200..240 && height in 200..240 + val nearlySquare = abs(width - height) <= max(width, height) / 20 + val dominant = buckets.values.maxOrNull() ?: 0 + return knownSize && + nearlySquare && + buckets.size <= 8 && + colorful * 100 <= visible && + dominant * 100 >= visible * 70 + } +} + +object ArtworkEnhancer { + suspend fun enhance(bitmap: Bitmap, targetSize: Int = 780): Bitmap = + withContext(Dispatchers.Default) { + if (bitmap.width >= targetSize && bitmap.height >= targetSize) { + return@withContext bitmap + } + val scaled = Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true) + val width = scaled.width + val height = scaled.height + val source = IntArray(width * height) + scaled.getPixels(source, 0, width, 0, 0, width, height) + val output = source.copyOf() + for (y in 1 until height - 1) { + for (x in 1 until width - 1) { + val index = y * width + x + val center = source[index] + fun channel(shift: Int): Int { + val value = + 5 * (center ushr shift and 0xff) - + (source[index - 1] ushr shift and 0xff) - + (source[index + 1] ushr shift and 0xff) - + (source[index - width] ushr shift and 0xff) - + (source[index + width] ushr shift and 0xff) + return value.coerceIn(0, 255) + } + output[index] = + (center ushr 24 shl 24) or + (channel(16) shl 16) or + (channel(8) shl 8) or + channel(0) + } + } + Bitmap.createBitmap(output, width, height, Bitmap.Config.ARGB_8888) + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt new file mode 100644 index 0000000..e4bd4a4 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt @@ -0,0 +1,27 @@ +package com.example.controllerapp.media + +import com.example.controllerapp.model.PlaybackPerformanceMode + +object PlaybackClockPolicy { + fun refreshIntervalMs( + uiVisible: Boolean, + isPlaying: Boolean, + performanceMode: PlaybackPerformanceMode, + systemPowerSaveMode: Boolean = false + ): Long? { + if (!uiVisible || !isPlaying) return null + return when (performanceMode) { + PlaybackPerformanceMode.SMOOTH -> 50L + PlaybackPerformanceMode.POWER_SAVING -> 200L + PlaybackPerformanceMode.AUTOMATIC -> if (systemPowerSaveMode) 200L else 100L + } + } + + fun animationsEnabled( + performanceMode: PlaybackPerformanceMode, + systemPowerSaveMode: Boolean, + systemAnimationsEnabled: Boolean + ): Boolean = systemAnimationsEnabled && + performanceMode != PlaybackPerformanceMode.POWER_SAVING && + !(performanceMode == PlaybackPerformanceMode.AUTOMATIC && systemPowerSaveMode) +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt b/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt new file mode 100644 index 0000000..14bc7ca --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt @@ -0,0 +1,272 @@ +package com.example.controllerapp.model + +import android.graphics.Bitmap + +enum class AppExperienceMode { + DAILY, + DEBUG +} + +enum class PlaybackPerformanceMode { + AUTOMATIC, + SMOOTH, + POWER_SAVING +} + +enum class LyricDisplayMode { + ORIGINAL, + ORIGINAL_TRANSLATION, + ORIGINAL_ROMANIZATION, + ALL; + + val showsTranslation: Boolean + get() = this == ORIGINAL_TRANSLATION || this == ALL + + val showsRomanization: Boolean + get() = this == ORIGINAL_ROMANIZATION || this == ALL +} + +data class ControllerSettings( + val experienceMode: AppExperienceMode = AppExperienceMode.DAILY, + val performanceMode: PlaybackPerformanceMode = PlaybackPerformanceMode.AUTOMATIC, + val autoReconnect: Boolean = true, + val lyricOffsetMs: Long = 600L, + val lyricDisplayMode: LyricDisplayMode = LyricDisplayMode.ORIGINAL_TRANSLATION, + val artworkDisplaySizeDp: Int = 260, + val artworkEnhancementEnabled: Boolean = true +) + +enum class ConnectionPhase { + DISCONNECTED, + SCANNING, + CONNECTING, + DISCOVERING, + SUBSCRIBING, + CONNECTED, + RECONNECTING +} + +enum class ConnectionHealth { + DISCONNECTED, + HEALTHY, + SUSPECT, + STALE +} + +data class ConnectionState( + val phase: ConnectionPhase = ConnectionPhase.DISCONNECTED, + val health: ConnectionHealth = ConnectionHealth.DISCONNECTED, + val deviceName: String = "Sony PlayerAgent", + val deviceAddress: String = "", + val mtu: Int = 23, + val characteristicReady: Boolean = false, + val generation: Long = 0L, + val reconnectAttempt: Int = 0, + val lastReconnectReason: String = "", + val lastNotifyElapsedMs: Long = 0L, + val serverProtocolVersion: Int = 1, + val serverSupportsV2: Boolean = false +) { + val connected: Boolean + get() = phase == ConnectionPhase.CONNECTED && characteristicReady +} + +data class PlaybackState( + val trackId: String = "", + val generation: Long = 0L, + val title: String = "-", + val artist: String = "-", + val album: String = "-", + val isPlaying: Boolean = false, + val positionMs: Long = 0L, + val durationMs: Long = 0L, + val volumeCurrent: Int = 0, + val volumeMax: Int = 0, + val receivedAtElapsedMs: Long = 0L, + val restoredSnapshot: Boolean = false +) + +data class LyricWord( + val index: Int, + val startMs: Long, + val durationMs: Long, + val text: String +) + +data class LyricLine( + val index: Int, + val timeMs: Long, + val durationMs: Long, + val text: String, + val translation: String? = null, + val romanization: String? = null, + val words: List = emptyList() +) + +enum class LyricLoadingStage { + IDLE, + WAITING_QQ_QRC, + WINDOW_READY, + FULL_LYRICS, + READY, + FAILED +} + +data class LyricsState( + val trackId: String = "", + val generation: Long = 0L, + val currentText: String = "", + val windowLines: List = emptyList(), + val partialFullLines: List = emptyList(), + val fullLines: List = emptyList(), + val isFinal: Boolean = false, + val currentLineIndex: Int = -1, + val currentWordIndex: Int = -1, + val currentWordSequence: Long = -1L, + val currentWordPositionMs: Long = -1L, + val loadingStage: LyricLoadingStage = LyricLoadingStage.IDLE, + val receivedChunks: Int = 0, + val expectedChunks: Int = 0, + val transferId: String = "", + val protocolFormat: String = "", + val retryCount: Int = 0, + val failureReason: String = "" +) { + /** + * The best lyrics currently available to the player UI. + * + * Keeping the three sources separate is important: a late five-line window must never + * replace a completed full-lyrics transfer. Callers that only need display data can keep + * using [lines], while the full-lyrics screen can inspect [isFinal] explicitly. + */ + val lines: List + get() = when { + fullLines.isNotEmpty() -> fullLines + partialFullLines.isNotEmpty() -> partialFullLines + else -> windowLines + } +} + +enum class ArtworkQuality(val rank: Int) { + PLACEHOLDER(0), + PREVIEW(1), + HQ(2), + ENHANCED(3) +} + +enum class ArtworkLoadingStage { + IDLE, + PREVIEW, + PREVIEW_READY, + HQ, + HQ_READY, + FAILED +} + +data class ArtworkState( + val artworkId: String = "", + val bitmap: Bitmap? = null, + val quality: ArtworkQuality = ArtworkQuality.PLACEHOLDER, + val loadingStage: ArtworkLoadingStage = ArtworkLoadingStage.IDLE, + val receivedChunks: Int = 0, + val expectedChunks: Int = 0, + val restoredSnapshot: Boolean = false, + val cacheRequiresRefresh: Boolean = false, + val failureReason: String = "", + val enhancementMessage: String = "" +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ArtworkState) return false + return artworkId == other.artworkId && + bitmap === other.bitmap && + quality == other.quality && + loadingStage == other.loadingStage && + receivedChunks == other.receivedChunks && + expectedChunks == other.expectedChunks && + restoredSnapshot == other.restoredSnapshot && + cacheRequiresRefresh == other.cacheRequiresRefresh && + failureReason == other.failureReason && + enhancementMessage == other.enhancementMessage + } + + override fun hashCode(): Int { + var result = artworkId.hashCode() + result = 31 * result + System.identityHashCode(bitmap) + result = 31 * result + quality.hashCode() + return result + } +} + +data class LyricDiagnosticState( + val status: String = "", + val reason: String = "", + val suggestion: String = "", + val details: Map = emptyMap() +) + +data class DiagnosticsState( + val recentLogs: List = emptyList(), + val sonyLogs: String = "", + val mediaFieldDump: String = "", + val lyricDiagnostic: LyricDiagnosticState = LyricDiagnosticState(), + val remoteTransferInProgress: Boolean = false, + val lastIssue: String = "", + val selfHealingActions: List = emptyList() +) + +data class PlaybackHistorySession( + val sessionId: Long, + val trackKey: String, + val title: String, + val artist: String, + val album: String, + val artworkId: String?, + val startedAt: Long, + val endedAt: Long?, + val listenedMs: Long, + val durationMs: Long, + val completed: Boolean, + val skipped: Boolean, + val countedPlay: Boolean +) + +data class PlaybackTopTrack( + val trackKey: String, + val title: String, + val artist: String, + val listenedMs: Long, + val playCount: Int +) + +data class PlaybackTopArtist( + val artist: String, + val listenedMs: Long, + val playCount: Int +) + +data class DailyListenStat( + val dateKey: String, + val listenedMs: Long, + val playCount: Int +) + +data class PlaybackStats( + val range: String, + val totalListenMs: Long, + val playCount: Int, + val uniqueTrackCount: Int, + val completionRate: Double, + val skipRate: Double, + val topTracks: List, + val topArtists: List, + val dailyTrend: List +) + +data class HistoryState( + val sessions: List = emptyList(), + val stats: Map = emptyMap(), + val loading: Boolean = false, + val status: String = "", + val hasMore: Boolean = true +) diff --git a/ControllerApp/src/main/java/com/example/controllerapp/protocol/ControllerProtocolCodec.kt b/ControllerApp/src/main/java/com/example/controllerapp/protocol/ControllerProtocolCodec.kt new file mode 100644 index 0000000..deb1a6c --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/ControllerProtocolCodec.kt @@ -0,0 +1,202 @@ +package com.example.controllerapp.protocol + +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricWord +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.zip.CRC32 +import java.util.zip.Inflater + +data class BinaryChunk( + val magic: Int, + val kindCode: Int, + val index: Int, + val total: Int, + val payload: ByteArray +) + +object ControllerProtocolCodec { + const val HEADER_SIZE = 6 + const val ALBUM_ART_MAGIC = 0xA1 + const val FULL_LYRICS_MAGIC = 0xA2 + + fun decodeBinaryChunk(data: ByteArray, expectedMagic: Int): BinaryChunk? { + if (data.size <= HEADER_SIZE || (data[0].toInt() and 0xff) != expectedMagic) { + return null + } + val kind = data[1].toInt() and 0xff + val index = ((data[2].toInt() and 0xff) shl 8) or + (data[3].toInt() and 0xff) + val total = ((data[4].toInt() and 0xff) shl 8) or + (data[5].toInt() and 0xff) + if (total <= 0 || index !in 0 until total) return null + return BinaryChunk( + magic = expectedMagic, + kindCode = kind, + index = index, + total = total, + payload = data.copyOfRange(HEADER_SIZE, data.size) + ) + } + + fun missingIndexes(chunks: Map, expectedCount: Int): List = + if (expectedCount <= 0) { + emptyList() + } else { + (0 until expectedCount).filterNot(chunks::containsKey) + } + + fun reassemble(chunks: Map, expectedCount: Int): ByteArray? { + if (expectedCount <= 0 || missingIndexes(chunks, expectedCount).isNotEmpty()) { + return null + } + val output = ByteArrayOutputStream() + repeat(expectedCount) { index -> output.write(chunks[index] ?: return null) } + return output.toByteArray() + } + + fun crc32(value: ByteArray): Long = CRC32().apply { update(value) }.value + + fun crc32Hex(value: ByteArray): String = "%08x".format(crc32(value)) + + fun parseHexCrc(value: String?): Long? = + value?.trim()?.takeIf(String::isNotEmpty)?.toLongOrNull(16) + + fun zlibDecompress(input: ByteArray, expectedSize: Int): ByteArray? { + if (input.isEmpty() || expectedSize <= 0 || expectedSize > 512 * 1024) { + return null + } + val inflater = Inflater(false) + return try { + inflater.setInput(input) + val output = ByteArray(expectedSize) + val count = inflater.inflate(output) + if (!inflater.finished() || count != expectedSize) null else output + } catch (_: Exception) { + null + } finally { + inflater.end() + } + } + + fun decodeLyricLine(value: JSONObject): LyricLine? { + val index = value.optInt("index", -1) + if (index < 0) return null + val wordsArray = value.optJSONArray("words") + val words = buildList { + if (wordsArray != null) { + repeat(wordsArray.length()) { wordIndex -> + val word = wordsArray.optJSONObject(wordIndex) ?: return@repeat + add( + LyricWord( + index = wordIndex, + startMs = word.optLong("startMs"), + durationMs = word.optLong("durationMs"), + text = word.optString("text") + ) + ) + } + } + } + return LyricLine( + index = index, + timeMs = value.optLong("timeMs"), + durationMs = value.optLong("durationMs"), + text = value.optString("text"), + translation = sanitizeSecondary( + value.takeIf { it.has("translation") && !it.isNull("translation") } + ?.optString("translation") + ), + romanization = sanitizeSecondary( + value.takeIf { it.has("romanization") && !it.isNull("romanization") } + ?.optString("romanization") + ), + words = words + ) + } + + fun decodeLyricPayload(data: ByteArray): Triple>? { + val value = runCatching { JSONObject(String(data, Charsets.UTF_8)) }.getOrNull() + ?: return null + val trackId = value.optString("trackId") + val generation = value.optLong("generation") + val lineValues = value.optJSONArray("lines") ?: return null + val lines = buildList { + repeat(lineValues.length()) { index -> + decodeLyricLine(lineValues.optJSONObject(index) ?: return@repeat)?.let(::add) + } + } + return Triple(trackId, generation, lines) + } + + fun missingArray(indexes: Collection): JSONArray = + JSONArray().also { array -> indexes.forEach(array::put) } + + fun sanitizeSecondary(value: String?): String? { + val text = value?.trim().orEmpty() + if (text.isEmpty() || text.all { it == '/' }) return null + return text.takeUnless { + it.lowercase() in setOf( + "--", + "---", + "null", + "nil", + "none", + "暂无", + "暂无翻译", + "暂无罗马音" + ) + } + } +} + +data class CurrentWordOrderingFence( + val generation: Long = -1L, + val sequence: Long = -1L, + val positionMs: Long = -1L +) { + fun accept( + incomingGeneration: Long, + incomingSequence: Long, + incomingPositionMs: Long + ): CurrentWordOrderingFence? { + val hasOrdering = incomingGeneration > 0L && incomingSequence > 0L + if (hasOrdering) { + if (incomingGeneration < generation || + (incomingGeneration == generation && incomingSequence <= sequence) + ) { + return null + } + } + if (!hasOrdering && + positionMs >= 0L && + incomingPositionMs < positionMs && + positionMs - incomingPositionMs <= 1_500L + ) { + return null + } + return CurrentWordOrderingFence( + generation = if (incomingGeneration > 0L) incomingGeneration else generation, + sequence = if (incomingSequence > 0L) incomingSequence else sequence, + positionMs = incomingPositionMs + ) + } +} + +data class ServerCapabilities( + val negotiated: Boolean = false, + val protocolVersion: Int = 1, + val albumArtBinary: Boolean = false, + val fullLyricsZlib: Boolean = false, + val lyricWindow: Boolean = false, + val ping: Boolean = false, + val transferRetry: Boolean = false +) + +object CapabilityPolicy { + const val ACK_TIMEOUT_MS = 300L + + fun fallbackIfUnacknowledged(value: ServerCapabilities): ServerCapabilities = + if (value.negotiated) value else ServerCapabilities() +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt new file mode 100644 index 0000000..d0ac539 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt @@ -0,0 +1,135 @@ +package com.example.controllerapp.protocol + +import com.example.controllerapp.model.LyricLine + +object LyricTimeline { + fun currentLinePosition( + lines: List, + positionMs: Long, + serverLineIndex: Int = -1 + ): Int { + if (lines.isEmpty()) return -1 + val byTime = lines.indexOfLast { it.timeMs <= positionMs } + val serverPosition = lines.indexOfFirst { it.index == serverLineIndex } + return when { + serverPosition >= 0 && + lines[serverPosition].timeMs <= positionMs + SERVER_LEAD_TOLERANCE_MS -> + serverPosition + byTime >= 0 -> byTime + else -> 0 + } + } + + fun currentWordPosition(line: LyricLine, positionMs: Long): Int = + line.words.indexOfLast { positionMs >= it.startMs }.coerceAtLeast(0) + + fun lineProgress(line: LyricLine, positionMs: Long): Float { + val duration = line.durationMs.coerceAtLeast(1L) + return ((positionMs - line.timeMs).toFloat() / duration.toFloat()).coerceIn(0f, 1f) + } + + /** + * Returns a 0..1 highlight amount for every UTF-16 character in the original line. + * + * QQ QRC normally supplies word timing. Distributing each word duration across its + * characters avoids the old whole-word jump while preserving spaces and punctuation from + * [LyricLine.text]. Lines without word timing gracefully fall back to their line duration. + */ + fun characterProgresses(line: LyricLine, positionMs: Long): FloatArray { + if (line.text.isEmpty()) return FloatArray(0) + if (line.words.isEmpty()) { + return distributedCharacterProgress(line.text.length, lineProgress(line, positionMs)) + } + + val resolvedWordStarts = line.words.map { word -> + resolveWordStartMs(line, word.startMs) + } + if (resolvedWordStarts.any { it == null }) { + return distributedCharacterProgress(line.text.length, lineProgress(line, positionMs)) + } + val wordStarts = resolvedWordStarts.filterNotNull() + + val result = FloatArray(line.text.length) + var searchFrom = 0 + var mappedWords = 0 + var previousEnd = 0 + var previousProgress = 0f + + line.words.forEachIndexed { index, word -> + if (word.text.isEmpty() || searchFrom >= line.text.length) return@forEachIndexed + val wordStart = line.text.indexOf(word.text, startIndex = searchFrom) + if (wordStart < 0) return@forEachIndexed + val wordEnd = (wordStart + word.text.length).coerceAtMost(line.text.length) + val durationMs = effectiveWordDurationMs(line, index, wordStarts) + val wordProgress = ( + (positionMs - wordStarts[index]).toFloat() / durationMs.toFloat() + ).coerceIn(0f, 1f) + + val separatorProgress = if (mappedWords == 0) wordProgress else previousProgress + for (characterIndex in previousEnd until wordStart) { + result[characterIndex] = separatorProgress + } + val characterSweep = wordProgress * (wordEnd - wordStart) + for (characterIndex in wordStart until wordEnd) { + result[characterIndex] = ( + characterSweep - (characterIndex - wordStart) + ).coerceIn(0f, 1f) + } + + mappedWords += 1 + previousEnd = wordEnd + previousProgress = wordProgress + searchFrom = wordEnd + } + + if (mappedWords == 0) { + return distributedCharacterProgress(line.text.length, lineProgress(line, positionMs)) + } + for (characterIndex in previousEnd until line.text.length) { + result[characterIndex] = previousProgress + } + return result + } + + private fun effectiveWordDurationMs( + line: LyricLine, + wordPosition: Int, + wordStarts: List + ): Long { + val word = line.words[wordPosition] + if (word.durationMs > 0L) return word.durationMs + val wordStartMs = wordStarts[wordPosition] + val nextStartMs = wordStarts.getOrNull(wordPosition + 1) + if (nextStartMs != null && nextStartMs > wordStartMs) { + return nextStartMs - wordStartMs + } + val lineEndMs = line.timeMs + line.durationMs + return (lineEndMs - wordStartMs).coerceAtLeast(1L) + } + + private fun resolveWordStartMs(line: LyricLine, rawStartMs: Long): Long? { + val lineEndMs = line.timeMs + line.durationMs.coerceAtLeast(1L) + if (rawStartMs in (line.timeMs - WORD_START_EARLY_TOLERANCE_MS).. + (lineEndMs + WORD_START_LATE_TOLERANCE_MS) + ) { + return rawStartMs + } + if (rawStartMs >= 0L && + rawStartMs <= line.durationMs + WORD_START_LATE_TOLERANCE_MS + ) { + return line.timeMs + rawStartMs + } + return null + } + + private fun distributedCharacterProgress(length: Int, progress: Float): FloatArray { + val characterSweep = progress.coerceIn(0f, 1f) * length + return FloatArray(length) { index -> + (characterSweep - index).coerceIn(0f, 1f) + } + } + + private const val SERVER_LEAD_TOLERANCE_MS = 1_000L + private const val WORD_START_EARLY_TOLERANCE_MS = 120L + private const val WORD_START_LATE_TOLERANCE_MS = 2_000L +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricsPublicationPolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricsPublicationPolicy.kt new file mode 100644 index 0000000..a4fde2e --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricsPublicationPolicy.kt @@ -0,0 +1,78 @@ +package com.example.controllerapp.protocol + +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricLoadingStage +import com.example.controllerapp.model.LyricsState + +/** + * Pure reducer for the three lyric sources exposed by Sony. + * + * A short window is latency-first data, a partial list is transfer progress, and a final list is + * authoritative. Keeping this ordering in one reducer prevents a late BLE window from replacing + * an already completed full-lyrics response. + */ +object LyricsPublicationPolicy { + fun publishWindow( + state: LyricsState, + trackId: String, + generation: Long, + lines: List, + currentLineIndex: Int + ): LyricsState { + val sorted = lines.sortedBy(LyricLine::index) + return state.copy( + trackId = trackId, + generation = generation, + windowLines = sorted, + currentLineIndex = currentLineIndex, + loadingStage = if (state.isFinal) { + LyricLoadingStage.READY + } else { + LyricLoadingStage.WINDOW_READY + }, + receivedChunks = if (state.isFinal) state.receivedChunks else sorted.size, + expectedChunks = if (state.isFinal) state.expectedChunks else sorted.size, + failureReason = "" + ) + } + + fun publishPartial( + state: LyricsState, + trackId: String, + generation: Long, + lines: List + ): LyricsState { + if (state.isFinal) return state + return state.copy( + trackId = trackId, + generation = generation, + partialFullLines = lines.sortedBy(LyricLine::index), + loadingStage = LyricLoadingStage.FULL_LYRICS + ) + } + + fun publishFull( + state: LyricsState, + trackId: String, + generation: Long, + lines: List, + currentLineIndex: Int, + format: String + ): LyricsState { + val sorted = lines.sortedBy(LyricLine::index) + return state.copy( + trackId = trackId, + generation = generation, + partialFullLines = emptyList(), + fullLines = sorted, + isFinal = true, + currentLineIndex = currentLineIndex, + loadingStage = LyricLoadingStage.READY, + receivedChunks = sorted.size, + expectedChunks = sorted.size, + transferId = "", + protocolFormat = format, + failureReason = "" + ) + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt new file mode 100644 index 0000000..92a45bb --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt @@ -0,0 +1,52 @@ +package com.example.controllerapp.protocol + +internal object MediaIdentityPolicy { + fun isNewMedia( + currentTrackId: String, + currentGeneration: Long, + incomingTrackId: String, + incomingGeneration: Long + ): Boolean { + if (currentTrackId != incomingTrackId) return true + return incomingGeneration > 0L && + currentGeneration > 0L && + incomingGeneration != currentGeneration + } + + fun generationMatches(currentGeneration: Long, incomingGeneration: Long): Boolean { + return currentGeneration <= 0L || + incomingGeneration <= 0L || + currentGeneration == incomingGeneration + } + + /** + * A binary media transfer pins the Sony generation in its start and end + * packets. Sony can legitimately advance the runtime generation for the + * same track while that small transfer is in flight (for example, when the + * QRC fast path becomes ready). In that case the bytes still belong to the + * current track and must not be discarded just because the live generation + * has moved forward. A different track id remains an unconditional fence. + */ + fun transferStillBelongsToCurrentTrack( + currentTrackId: String, + transferTrackId: String, + currentGeneration: Long, + transferGeneration: Long + ): Boolean { + if (currentTrackId.isBlank() || currentTrackId != transferTrackId) return false + return currentGeneration <= 0L || + transferGeneration <= 0L || + currentGeneration >= transferGeneration + } + + /** + * Sony can advance its media generation while stabilizing metadata for the same track. + * A newer transfer is therefore adoptable, while an older generation is always stale. + */ + fun canAdoptIncomingGeneration( + currentGeneration: Long, + incomingGeneration: Long + ): Boolean = currentGeneration <= 0L || + incomingGeneration <= 0L || + incomingGeneration >= currentGeneration +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt b/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt new file mode 100644 index 0000000..90a5561 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt @@ -0,0 +1,525 @@ +package com.example.controllerapp.service + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.Manifest +import android.bluetooth.BluetoothManager +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import com.example.controllerapp.ControllerApplication +import com.example.controllerapp.ControllerCommandGateway +import com.example.controllerapp.ControllerRepository +import com.example.controllerapp.ControllerServiceActions +import com.example.controllerapp.MainActivity +import com.example.controllerapp.ble.BleGattTransport +import com.example.controllerapp.ble.BleGattTransportListener +import com.example.controllerapp.classicbluetooth.RfcommServerManager +import com.example.controllerapp.model.ConnectionHealth +import com.example.controllerapp.model.ConnectionPhase +import com.example.controllerapp.model.PlaybackState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class ControllerConnectionService : + Service(), + BleGattTransportListener, + ControllerServiceActions { + + private val binder = LocalBinder() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var repository: ControllerRepository + private lateinit var commands: ControllerCommandGateway + private lateinit var transport: BleGattTransport + private lateinit var mediaSession: MediaSessionCompat + private var rfcommManager: RfcommServerManager? = null + private var reconnectJob: Job? = null + private var reconnectAttempt = 0 + private var manualStop = false + private var healthProbeAtMs = 0L + private var healthFailures = 0 + + inner class LocalBinder : Binder() { + val service: ControllerConnectionService + get() = this@ControllerConnectionService + } + + override fun onCreate() { + super.onCreate() + // Promote immediately. Some Android 15 vendor builds revoke the foreground-start + // allowance as soon as a locked Activity loses TOP state, so BLE/MediaSession setup must + // not run before this call. + createNotificationChannel() + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + buildBootstrapNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } else { + 0 + } + ) + val controllerApplication = application as ControllerApplication + repository = controllerApplication.repository + commands = controllerApplication.commandGateway + transport = BleGattTransport( + context = this, + logger = repository.logStore::append, + listener = this + ) + repository.attachTransport { payload -> + rfcommManager?.sendRawMessage(String(payload, Charsets.UTF_8)) + ?: transport.write(payload) + } + repository.attachServiceActions(this) + createMediaSession() + observeNotificationState() + startHealthMonitor() + scope.launch { + val address = repository.preferences.savedDeviceAddress.first() + startConnection(address, forceScan = false) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_PREVIOUS -> commands.previous() + ACTION_PLAY_PAUSE -> commands.playPause() + ACTION_NEXT -> commands.next() + ACTION_RECONNECT -> requestReconnect("notification", forceScan = false) + ACTION_STOP -> stopConnection() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onDestroy() { + reconnectJob?.cancel() + repository.attachTransport(null) + repository.attachServiceActions(null) + stopRfcommOnly() + transport.close() + mediaSession.release() + scope.cancel() + super.onDestroy() + } + + override fun onPhaseChanged( + phase: ConnectionPhase, + deviceName: String, + address: String, + reason: String + ) { + repository.updateConnectionPhase(phase, deviceName, address, reason) + if (phase == ConnectionPhase.DISCONNECTED && !manualStop) { + scheduleReconnect(reason.ifBlank { "link lost" }) + } + } + + override fun onReady(deviceName: String, address: String, mtu: Int) { + reconnectJob?.cancel() + reconnectAttempt = 0 + healthProbeAtMs = 0L + healthFailures = 0 + repository.onTransportReady(deviceName, address, mtu) + scope.launch { repository.preferences.setSavedDeviceAddress(address) } + } + + override fun onNotification(value: ByteArray) { + healthProbeAtMs = 0L + healthFailures = 0 + repository.handleNotification(value) + } + + override fun onNotifyActivity() { + healthProbeAtMs = 0L + healthFailures = 0 + repository.markNotifyActivity() + } + + override fun onWriteResult(success: Boolean, payload: ByteArray) { + repository.onWriteResult(success, payload) + } + + override fun requestReconnect(reason: String, forceScan: Boolean) { + stopRfcommOnly() + manualStop = false + reconnectJob?.cancel() + reconnectAttempt = 0 + repository.setReconnectAttempt(0, reason) + repository.recordSelfHealing("重新连接:$reason") + scope.launch { + val address = repository.preferences.savedDeviceAddress.first() + transport.restart( + savedAddress = address, + reason = reason, + forceScan = forceScan || address.isBlank() + ) + } + } + + override fun stopConnection() { + manualStop = true + reconnectJob?.cancel() + stopRfcommOnly() + transport.disconnect() + } + + override fun startLegacyRfcomm() { + if (!hasBluetoothPermission()) { + repository.updateConnectionPhase( + ConnectionPhase.DISCONNECTED, + reason = "Bluetooth permission missing" + ) + return + } + reconnectJob?.cancel() + manualStop = true + stopRfcommOnly() + transport.disconnect() + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null) { + repository.updateConnectionPhase( + ConnectionPhase.DISCONNECTED, + reason = "Bluetooth unavailable" + ) + return + } + lateinit var manager: RfcommServerManager + manager = RfcommServerManager( + bluetoothAdapter = adapter, + logger = { repository.logStore.append("[RFCOMM] $it") }, + onMessageReceived = { message -> + repository.markNotifyActivity() + repository.handleNotification(message.toByteArray(Charsets.UTF_8)) + }, + onConnectionChanged = connectionChanged@{ connected -> + if (rfcommManager !== manager) return@connectionChanged + if (connected) { + repository.onTransportReady("Sony RFCOMM", "RFCOMM", 23) + repository.recordSelfHealing("已切换到手动 RFCOMM 兼容链路") + } else { + repository.updateConnectionPhase( + ConnectionPhase.DISCONNECTED, + deviceName = "Sony RFCOMM", + reason = "RFCOMM waiting for old Sony" + ) + } + } + ) + rfcommManager = manager + repository.updateConnectionPhase( + ConnectionPhase.CONNECTING, + deviceName = "Sony RFCOMM", + reason = "manual legacy compatibility" + ) + manager.startServer() + } + + override fun stopLegacyRfcomm() { + stopRfcommOnly() + manualStop = false + repository.recordSelfHealing("已退出 RFCOMM 兼容链路,恢复 BLE V2") + scope.launch { + delay(250L) + val address = repository.preferences.savedDeviceAddress.first() + startConnection(address, forceScan = address.isBlank()) + } + } + + private fun startConnection(savedAddress: String, forceScan: Boolean) { + if (rfcommManager != null) return + if (!hasBluetoothPermission()) { + repository.updateConnectionPhase( + ConnectionPhase.DISCONNECTED, + reason = "Bluetooth permission missing" + ) + return + } + manualStop = false + transport.start(savedAddress, forceScan) + } + + private fun stopRfcommOnly() { + val active = rfcommManager + rfcommManager = null + active?.stopServer() + } + + private fun hasBluetoothPermission(): Boolean { + val required = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + listOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + return required.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + } + + private fun scheduleReconnect(reason: String) { + if (!repository.settings.value.autoReconnect || reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + reconnectAttempt += 1 + repository.setReconnectAttempt(reconnectAttempt, reason) + delay(ReconnectPolicy.delayMs(reconnectAttempt)) + val address = repository.preferences.savedDeviceAddress.first() + startConnection( + address, + forceScan = ReconnectPolicy.shouldForceScan(reconnectAttempt, address) + ) + } + } + + private fun startHealthMonitor() { + scope.launch { + while (true) { + delay(5_000L) + val connection = repository.connection.value + if (!connection.connected) { + healthProbeAtMs = 0L + healthFailures = 0 + continue + } + val now = SystemClock.elapsedRealtime() + val silentFor = now - connection.lastNotifyElapsedMs + val threshold = if (repository.playback.value.isPlaying) 15_000L else 30_000L + if (silentFor < threshold) { + healthProbeAtMs = 0L + healthFailures = 0 + continue + } + if (healthProbeAtMs == 0L) { + repository.markHealth(ConnectionHealth.SUSPECT, "notify silent") + repository.healthProbe() + healthProbeAtMs = now + continue + } + if (now - healthProbeAtMs >= 5_000L) { + healthFailures += 1 + if (healthFailures >= 2) { + repository.markHealth(ConnectionHealth.STALE, "health probe timeout") + repository.recordSelfHealing("连续两次健康探测失败,重建当前 BLE 连接") + requestReconnect("health probe timeout", forceScan = false) + } else { + repository.healthProbe() + healthProbeAtMs = now + } + } + } + } + } + + private fun createMediaSession() { + mediaSession = MediaSessionCompat(this, "SonyController").apply { + setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { + commands.playPause() + } + + override fun onPause() { + commands.playPause() + } + + override fun onSkipToPrevious() { + commands.previous() + } + + override fun onSkipToNext() { + commands.next() + } + + override fun onSeekTo(pos: Long) { + commands.seekTo(pos) + } + }) + isActive = true + } + } + + @SuppressLint("MissingPermission") + @OptIn(FlowPreview::class) + private fun observeNotificationState() { + scope.launch { + combine( + repository.connection, + repository.playback, + repository.artwork + ) { _, _, _ -> Unit }.debounce(NOTIFICATION_UPDATE_DEBOUNCE_MS).collect { + val notification = buildNotification() + runCatching { + NotificationManagerCompat.from(this@ControllerConnectionService) + .notify(NOTIFICATION_ID, notification) + } + } + } + } + + private fun buildNotification(): Notification { + val playback = repository.playback.value + updateMediaSession(playback) + val openIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val previous = actionIntent(ACTION_PREVIOUS, 1) + val playPause = actionIntent(ACTION_PLAY_PAUSE, 2) + val next = actionIntent(ACTION_NEXT, 3) + val reconnect = actionIntent(ACTION_RECONNECT, 4) + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentTitle( + playback.title.takeUnless { it == "-" } + ?: if (repository.connection.value.connected) "等待 QQ 音乐播放" else "Sony 未连接" + ) + .setContentText(playback.artist.takeUnless { it == "-" } ?: connectionSubtitle()) + .setContentIntent(openIntent) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSession.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + ) + repository.artwork.value.bitmap?.let(builder::setLargeIcon) + if (repository.connection.value.connected) { + builder + .addAction(android.R.drawable.ic_media_previous, "上一首", previous) + .addAction( + if (playback.isPlaying) android.R.drawable.ic_media_pause + else android.R.drawable.ic_media_play, + if (playback.isPlaying) "暂停" else "播放", + playPause + ) + .addAction(android.R.drawable.ic_media_next, "下一首", next) + } else { + builder.addAction(android.R.drawable.stat_sys_data_bluetooth, "重新连接", reconnect) + } + return builder.build() + } + + private fun buildBootstrapNotification(): Notification { + val openIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentTitle("正在连接 Sony") + .setContentText("QQ 音乐控制服务正在启动") + .setContentIntent(openIntent) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .build() + } + + private fun updateMediaSession(playback: PlaybackState) { + val state = if (playback.isPlaying) { + PlaybackStateCompat.STATE_PLAYING + } else { + PlaybackStateCompat.STATE_PAUSED + } + mediaSession.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SEEK_TO + ) + .setState(state, repository.displayedPositionMs(), if (playback.isPlaying) 1f else 0f) + .build() + ) + mediaSession.setMetadata( + MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, playback.title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, playback.artist) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, playback.album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, playback.durationMs) + .apply { + repository.artwork.value.bitmap?.let { + putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it) + } + } + .build() + ) + } + + private fun connectionSubtitle(): String { + val state = repository.connection.value + return when (state.phase) { + ConnectionPhase.SCANNING -> "正在查找 Sony" + ConnectionPhase.CONNECTING, + ConnectionPhase.DISCOVERING, + ConnectionPhase.SUBSCRIBING, + ConnectionPhase.RECONNECTING -> "正在连接 Sony" + else -> state.lastReconnectReason.ifBlank { "点击重新连接" } + } + } + + private fun actionIntent(action: String, requestCode: Int): PendingIntent = + PendingIntent.getService( + this, + requestCode, + Intent(this, ControllerConnectionService::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + "Sony 播放控制", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "维持 Sony BLE 连接并提供后台播放控制" + setShowBadge(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + companion object { + const val ACTION_PREVIOUS = "com.example.controllerapp.action.PREVIOUS" + const val ACTION_PLAY_PAUSE = "com.example.controllerapp.action.PLAY_PAUSE" + const val ACTION_NEXT = "com.example.controllerapp.action.NEXT" + const val ACTION_RECONNECT = "com.example.controllerapp.action.RECONNECT" + const val ACTION_STOP = "com.example.controllerapp.action.STOP" + private const val CHANNEL_ID = "sony_controller_connection" + private const val NOTIFICATION_ID = 8201 + private const val NOTIFICATION_UPDATE_DEBOUNCE_MS = 250L + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/service/ReconnectPolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/service/ReconnectPolicy.kt new file mode 100644 index 0000000..19a2465 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/service/ReconnectPolicy.kt @@ -0,0 +1,11 @@ +package com.example.controllerapp.service + +object ReconnectPolicy { + fun delayMs(attempt: Int): Long { + val exponent = (attempt - 1).coerceIn(0, 5) + return (1_000L shl exponent).coerceAtMost(30_000L) + } + + fun shouldForceScan(attempt: Int, savedAddress: String): Boolean = + savedAddress.isBlank() || attempt.coerceAtLeast(1) % 3 == 0 +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt new file mode 100644 index 0000000..892b57d --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt @@ -0,0 +1,2168 @@ +package com.example.controllerapp.ui + +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.text.format.DateUtils +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.NavigateBefore +import androidx.compose.material.icons.filled.NavigateNext +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +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 androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.example.controllerapp.ControllerViewModel +import com.example.controllerapp.model.AppExperienceMode +import com.example.controllerapp.model.ArtworkLoadingStage +import com.example.controllerapp.model.ArtworkQuality +import com.example.controllerapp.model.ArtworkState +import com.example.controllerapp.model.ConnectionHealth +import com.example.controllerapp.model.ConnectionPhase +import com.example.controllerapp.model.ConnectionState +import com.example.controllerapp.model.ControllerSettings +import com.example.controllerapp.model.DiagnosticsState +import com.example.controllerapp.model.HistoryState +import com.example.controllerapp.model.LyricDisplayMode +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricLoadingStage +import com.example.controllerapp.model.LyricsState +import com.example.controllerapp.model.PlaybackPerformanceMode +import com.example.controllerapp.model.PlaybackState +import com.example.controllerapp.protocol.LyricTimeline +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.max + +private object Routes { + const val PLAYER = "player" + const val LYRICS = "lyrics" + const val HISTORY = "history" + const val SETTINGS = "settings" + const val HEALTH = "health" + const val TRACK_DIAGNOSTIC = "track-diagnostic" + const val LYRIC_DIAGNOSTIC = "lyric-diagnostic" + const val DEBUG_TOOLS = "debug-tools" +} + +@Composable +fun ControllerApp(viewModel: ControllerViewModel) { + val navController = rememberNavController() + NavHost(navController, startDestination = Routes.PLAYER) { + composable(Routes.PLAYER) { + PlayerScreen(viewModel, navController) + } + composable(Routes.LYRICS) { + FullLyricsScreen(viewModel, navController) + } + composable(Routes.HISTORY) { + HistoryScreen(viewModel, navController) + } + composable(Routes.SETTINGS) { + SettingsScreen(viewModel, navController) + } + composable(Routes.HEALTH) { + HealthScreen(viewModel, navController) + } + composable(Routes.TRACK_DIAGNOSTIC) { + TrackDiagnosticScreen(viewModel, navController) + } + composable(Routes.LYRIC_DIAGNOSTIC) { + LyricDiagnosticScreen(viewModel, navController) + } + composable(Routes.DEBUG_TOOLS) { + DebugToolsScreen(viewModel, navController) + } + } +} + +@Composable +private fun PlayerScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val connection by viewModel.connection.collectAsStateWithLifecycle() + val playback by viewModel.playback.collectAsStateWithLifecycle() + val lyrics by viewModel.lyrics.collectAsStateWithLifecycle() + val artwork by viewModel.artwork.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val position by viewModel.displayedPositionMs.collectAsStateWithLifecycle() + val reduceAnimations by viewModel.reduceAnimations.collectAsStateWithLifecycle() + + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + ArtworkAtmosphereBackground( + bitmap = artwork.bitmap, + overlayColors = listOf( + Color(0xd8090b10), + Color(0xaa090b10), + Color(0xff090b10) + ) + ) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface + ) { + Column( + Modifier.fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + ) { + PlayerHeader(connection, settings, navController) { + viewModel.reconnect(forceScan = connection.deviceAddress.isBlank()) + } + PlayerBody( + connection = connection, + playback = playback, + lyrics = lyrics, + artwork = artwork, + settings = settings, + reduceAnimations = reduceAnimations, + positionMs = position, + onOpenLyrics = { navController.navigate(Routes.LYRICS) }, + onPrevious = viewModel::previous, + onPlayPause = viewModel::playPause, + onNext = viewModel::next, + onSeek = viewModel::seekTo, + onVolume = viewModel::setVolume, + onRetryLyrics = viewModel::retryLyrics, + onRetryArtwork = viewModel::retryArtwork + ) + } + } + } +} + +@Composable +private fun ArtworkAtmosphereBackground( + bitmap: Bitmap?, + overlayColors: List +) { + bitmap?.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S }?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + .graphicsLayer { + scaleX = 1.14f + scaleY = 1.14f + alpha = 0.76f + } + .blur( + radius = 34.dp, + edgeTreatment = BlurredEdgeTreatment.Unbounded + ) + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient(overlayColors) + ) + ) +} + +@Composable +private fun PlayerHeader( + connection: ConnectionState, + settings: ControllerSettings, + navController: NavHostController, + onReconnect: () -> Unit +) { + var menuExpanded by remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AssistChip( + onClick = onReconnect, + label = { + Text(connectionLabel(connection), maxLines = 1) + }, + leadingIcon = { + Icon( + Icons.Default.Bluetooth, + null, + tint = when (connection.health) { + ConnectionHealth.HEALTHY -> Color(0xff8bd6a3) + ConnectionHealth.SUSPECT -> Color(0xffffd166) + ConnectionHealth.STALE -> MaterialTheme.colorScheme.error + ConnectionHealth.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + ) + Spacer(Modifier.weight(1f)) + Text( + "QQ 音乐", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, "打开菜单") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + MenuEntry("播放历史", Icons.Default.History) { + menuExpanded = false + navController.navigate(Routes.HISTORY) + } + MenuEntry("设置", Icons.Default.Settings) { + menuExpanded = false + navController.navigate(Routes.SETTINGS) + } + MenuEntry("系统健康", Icons.Default.HealthAndSafety) { + menuExpanded = false + navController.navigate(Routes.HEALTH) + } + if (settings.experienceMode == AppExperienceMode.DEBUG) { + MenuEntry("调试工具", Icons.Default.BugReport) { + menuExpanded = false + navController.navigate(Routes.DEBUG_TOOLS) + } + } + } + } + } +} + +@Composable +private fun MenuEntry( + text: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit +) { + DropdownMenuItem( + text = { Text(text) }, + leadingIcon = { Icon(icon, null) }, + onClick = onClick + ) +} + +@Composable +private fun PlayerBody( + connection: ConnectionState, + playback: PlaybackState, + lyrics: LyricsState, + artwork: ArtworkState, + settings: ControllerSettings, + reduceAnimations: Boolean, + positionMs: Long, + onOpenLyrics: () -> Unit, + onPrevious: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, + onSeek: (Long) -> Unit, + onVolume: (Int) -> Unit, + onRetryLyrics: () -> Unit, + onRetryArtwork: () -> Unit +) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val wide = maxWidth >= 700.dp && maxWidth > maxHeight + if (wide) { + Row( + Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(36.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ArtworkPanel( + artwork, + playback, + settings, + Modifier.weight(0.8f).fillMaxHeight() + ) + PlayerControlsPanel( + connection, + playback, + lyrics, + artwork, + settings, + reduceAnimations, + positionMs, + onOpenLyrics, + onPrevious, + onPlayPause, + onNext, + onSeek, + onVolume, + onRetryLyrics, + onRetryArtwork, + Modifier.weight(1.2f).verticalScroll(rememberScrollState()) + ) + } + } else { + Column( + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 22.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ArtworkPanel( + artwork, + playback, + settings, + Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + PlayerControlsPanel( + connection, + playback, + lyrics, + artwork, + settings, + reduceAnimations, + positionMs, + onOpenLyrics, + onPrevious, + onPlayPause, + onNext, + onSeek, + onVolume, + onRetryLyrics, + onRetryArtwork, + Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun ArtworkPanel( + artwork: ArtworkState, + playback: PlaybackState, + settings: ControllerSettings, + modifier: Modifier = Modifier +) { + val maxArtwork = settings.artworkDisplaySizeDp.dp + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + Modifier.size(maxArtwork) + .shadow(28.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + artwork.bitmap?.let { + Image( + it.asImageBitmap(), + contentDescription = "${playback.title} 的封面", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } ?: Icon( + Icons.Default.Image, + contentDescription = "暂无封面", + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.42f) + ) + if (artwork.loadingStage == ArtworkLoadingStage.PREVIEW || + artwork.loadingStage == ArtworkLoadingStage.HQ + ) { + CircularProgressIndicator( + Modifier.size(32.dp), + strokeWidth = 2.dp + ) + } + if (playback.restoredSnapshot || artwork.restoredSnapshot) { + Surface( + color = Color(0xaa000000), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(10.dp) + ) { + Text( + "上次播放 · 等待同步", + Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + fontSize = 12.sp + ) + } + } + } + } +} + +@Composable +private fun PlayerControlsPanel( + connection: ConnectionState, + playback: PlaybackState, + lyrics: LyricsState, + artwork: ArtworkState, + settings: ControllerSettings, + reduceAnimations: Boolean, + positionMs: Long, + onOpenLyrics: () -> Unit, + onPrevious: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, + onSeek: (Long) -> Unit, + onVolume: (Int) -> Unit, + onRetryLyrics: () -> Unit, + onRetryArtwork: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + playback.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + Text( + listOf(playback.artist, playback.album) + .filter { it.isNotBlank() && it != "-" } + .joinToString(" · ") + .ifBlank { "等待 QQ 音乐" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + NaturalSpectrum( + playback = playback, + lyrics = lyrics, + settings = settings, + positionMs = positionMs, + animated = !reduceAnimations + ) + CompactLyrics( + lyrics = lyrics, + settings = settings, + positionMs = positionMs, + onClick = onOpenLyrics + ) + PlaybackProgress(playback, positionMs, connection.connected, onSeek) + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + enabled = connection.connected, + onClick = onPrevious, + modifier = Modifier.semantics { contentDescription = "上一首" } + ) { + Icon(Icons.Default.SkipPrevious, null, Modifier.size(34.dp)) + } + FilledIconButton( + enabled = connection.connected, + onClick = onPlayPause, + modifier = Modifier.size(66.dp) + .semantics { contentDescription = if (playback.isPlaying) "暂停" else "播放" } + ) { + Icon( + if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + null, + Modifier.size(38.dp) + ) + } + IconButton( + enabled = connection.connected, + onClick = onNext, + modifier = Modifier.semantics { contentDescription = "下一首" } + ) { + Icon(Icons.Default.SkipNext, null, Modifier.size(34.dp)) + } + } + VolumeControl(playback, connection.connected, onVolume) + AnimatedVisibility(settings.experienceMode == AppExperienceMode.DEBUG) { + DebugTransferStatus(lyrics, artwork, onRetryLyrics, onRetryArtwork) + } + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun NaturalSpectrum( + playback: PlaybackState, + lyrics: LyricsState, + settings: ControllerSettings, + positionMs: Long, + animated: Boolean +) { + val lyricPositionMs = positionMs + settings.lyricOffsetMs + val linePosition = currentLineIndex( + lyrics.lines, + lyricPositionMs, + lyrics.currentLineIndex + ) + val line = lyrics.lines.getOrNull(linePosition) + val wordPosition = line?.let { LyricTimeline.currentWordPosition(it, lyricPositionMs) } ?: -1 + val engine = remember(playback.trackId) { NaturalSpectrumEngine() } + val framePosition = if (animated) positionMs else 0L + val levels = remember( + playback.trackId, + framePosition, + playback.isPlaying, + line?.index, + wordPosition, + animated + ) { + engine.levels( + SpectrumFrame( + trackSeed = playback.trackId.ifBlank { "waiting" }, + positionMs = framePosition, + playing = playback.isPlaying && animated, + lyricProgress = line?.let { + LyricTimeline.lineProgress(it, lyricPositionMs) + } ?: 0f, + wordSignature = if (animated && line != null) { + "${line.index}:$wordPosition" + } else { + "" + } + ) + ) + } + val accent = MaterialTheme.colorScheme.primary + val opacity = when { + playback.isPlaying && animated -> 0.72f + playback.isPlaying -> 0.32f + else -> 0.22f + } + Canvas( + Modifier.fillMaxWidth(0.58f) + .height(32.dp) + .padding(vertical = 4.dp) + .testTag("natural_spectrum") + ) { + if (levels.isEmpty()) return@Canvas + val spacing = size.width / levels.size + val gap = (spacing * 0.30f).coerceIn(2f, 4f) + val barWidth = (spacing - gap).coerceAtLeast(2.4f) + val centerY = size.height / 2f + drawLine( + brush = Brush.horizontalGradient( + listOf(Color.Transparent, accent.copy(alpha = opacity * 0.28f), Color.Transparent) + ), + start = Offset(0f, centerY), + end = Offset(size.width, centerY), + strokeWidth = 1.2f + ) + levels.forEachIndexed { index, level -> + val height = (size.height * (0.10f + level * 0.84f)).coerceAtMost(size.height) + val top = centerY - height / 2f + val left = index * spacing + (spacing - barWidth) / 2f + drawRoundRect( + brush = Brush.verticalGradient( + colors = listOf( + accent.copy(alpha = opacity), + accent.copy(alpha = opacity * 0.42f) + ), + startY = top, + endY = top + height + ), + topLeft = Offset(left, top), + size = Size(barWidth, height), + cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f) + ) + } + } +} + +@Composable +private fun CompactLyrics( + lyrics: LyricsState, + settings: ControllerSettings, + positionMs: Long, + onClick: () -> Unit +) { + val currentIndex = currentLineIndex( + lyrics.lines, + positionMs + settings.lyricOffsetMs, + lyrics.currentLineIndex + ) + val previous = lyrics.lines.getOrNull(currentIndex - 1) + val current = lyrics.lines.getOrNull(currentIndex) + val next = lyrics.lines.getOrNull(currentIndex + 1) + Box( + modifier = Modifier.fillMaxWidth() + .height(172.dp) + .clickable(onClick = onClick) + .testTag("compact_lyrics") + ) { + Column( + Modifier.fillMaxSize().padding(horizontal = 18.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + Text( + previous?.text.orEmpty(), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.52f), + fontSize = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + if (current != null) { + WordHighlightedText(current, positionMs + settings.lyricOffsetMs) + lyricAuxiliaryLines(current, settings.lyricDisplayMode).take(2).forEach { text -> + Text( + text, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } else { + Text( + lyrics.currentText.ifBlank { lyricStageText(lyrics.loadingStage) }, + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Text( + next?.text.orEmpty(), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.52f), + fontSize = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun WordHighlightedText( + line: LyricLine, + positionMs: Long, + modifier: Modifier = Modifier, + fontSizeValue: Int = if (line.text.length > 42) 16 else 20, + lineHeightValue: Int = if (line.text.length > 42) 21 else 26, + textAlign: TextAlign = TextAlign.Center, + highlightColor: Color = Color.White, + pendingColor: Color = Color.White.copy(alpha = 0.45f) +) { + val characterProgresses = LyricTimeline.characterProgresses(line, positionMs) + val value = buildAnnotatedString { + line.text.forEachIndexed { index, character -> + withStyle( + SpanStyle( + color = lerp( + pendingColor, + highlightColor, + characterProgresses.getOrElse(index) { 0f } + ) + ) + ) { + append(character) + } + } + } + Text( + value, + modifier = modifier, + fontSize = fontSizeValue.sp, + lineHeight = lineHeightValue.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = textAlign + ) +} + +@Composable +private fun PlaybackProgress( + playback: PlaybackState, + positionMs: Long, + enabled: Boolean, + onSeek: (Long) -> Unit +) { + var dragged by remember(playback.trackId) { mutableStateOf(null) } + val maximum = playback.durationMs.coerceAtLeast(1L).toFloat() + val shown = dragged ?: positionMs.coerceIn(0L, maximum.toLong()).toFloat() + Column(Modifier.fillMaxWidth()) { + Slider( + value = shown.coerceIn(0f, maximum), + valueRange = 0f..maximum, + enabled = enabled && playback.durationMs > 0L, + onValueChange = { dragged = it }, + onValueChangeFinished = { + dragged?.let { onSeek(it.toLong()) } + dragged = null + } + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatDuration(shown.toLong()), style = MaterialTheme.typography.labelSmall) + Text(formatDuration(playback.durationMs), style = MaterialTheme.typography.labelSmall) + } + } +} + +@Composable +private fun VolumeControl( + playback: PlaybackState, + enabled: Boolean, + onVolume: (Int) -> Unit +) { + var value by remember(playback.volumeCurrent, playback.volumeMax) { + mutableFloatStateOf(playback.volumeCurrent.toFloat()) + } + var dragging by remember { mutableStateOf(false) } + LaunchedEffect(value, dragging) { + if (dragging) { + delay(90L) + onVolume(value.toInt()) + } + } + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.AutoMirrored.Filled.VolumeUp, "音量", Modifier.size(20.dp)) + Slider( + value = value.coerceIn(0f, playback.volumeMax.coerceAtLeast(1).toFloat()), + valueRange = 0f..playback.volumeMax.coerceAtLeast(1).toFloat(), + enabled = enabled && playback.volumeMax > 0, + onValueChange = { + dragging = true + value = it + }, + onValueChangeFinished = { + onVolume(value.toInt()) + dragging = false + }, + modifier = Modifier.weight(1f) + ) + Text("${value.toInt()}", Modifier.width(34.dp), textAlign = TextAlign.End) + } +} + +@Composable +private fun DebugTransferStatus( + lyrics: LyricsState, + artwork: ArtworkState, + onRetryLyrics: () -> Unit, + onRetryArtwork: () -> Unit +) { + Card( + Modifier.fillMaxWidth().padding(top = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.86f) + ) + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + TransferRow( + "完整歌词", + lyricStageText(lyrics.loadingStage), + lyrics.receivedChunks, + lyrics.expectedChunks, + lyrics.failureReason, + onRetryLyrics + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) + TransferRow( + "封面", + artwork.loadingStage.name, + artwork.receivedChunks, + artwork.expectedChunks, + artwork.failureReason, + onRetryArtwork + ) + } + } +} + +@Composable +private fun TransferRow( + name: String, + stage: String, + received: Int, + expected: Int, + failure: String, + retry: () -> Unit +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("$name · $stage", style = MaterialTheme.typography.labelLarge) + if (expected > 0) { + LinearProgressIndicator( + progress = { + (received.toFloat() / expected).coerceIn(0f, 1f) + }, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp) + ) + } + if (failure.isNotBlank()) { + Text(failure, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } + } + TextButton(onClick = retry) { Text("重试") } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FullLyricsScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val connection by viewModel.connection.collectAsStateWithLifecycle() + val playback by viewModel.playback.collectAsStateWithLifecycle() + val lyrics by viewModel.lyrics.collectAsStateWithLifecycle() + val artwork by viewModel.artwork.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val position by viewModel.displayedPositionMs.collectAsStateWithLifecycle() + val displayLines = lyrics.lines + val current = currentLineIndex( + displayLines, + position + settings.lyricOffsetMs, + lyrics.currentLineIndex + ) + val listState = rememberLazyListState() + var following by remember { mutableStateOf(true) } + var selectedLinePosition by remember { mutableStateOf(null) } + var programmaticScroll by remember { mutableStateOf(false) } + var resumeFollowToken by remember { mutableIntStateOf(0) } + val isDragged by listState.interactionSource.collectIsDraggedAsState() + val nearestVisibleLine by remember(displayLines) { + derivedStateOf { + val layout = listState.layoutInfo + val viewportCenter = (layout.viewportStartOffset + layout.viewportEndOffset) / 2 + LyricBrowsePolicy.nearestLine( + layout.visibleItemsInfo.map { item -> + LyricViewportItem( + linePosition = item.index, + centerPx = item.offset + item.size / 2 + ) + }, + viewportCenterPx = viewportCenter + ) + } + } + val lyricViewportHeight by remember { + derivedStateOf { + (listState.layoutInfo.viewportEndOffset - + listState.layoutInfo.viewportStartOffset).coerceAtLeast(0) + } + } + val lyricTopPaddingPx = with(LocalDensity.current) { 132.dp.roundToPx() } + LaunchedEffect(lyrics.trackId, lyrics.isFinal) { + if (lyrics.trackId.isNotBlank() && !lyrics.isFinal) { + viewModel.ensureFullLyrics() + } + } + LaunchedEffect(lyrics.trackId) { + following = true + selectedLinePosition = null + resumeFollowToken = 0 + } + LaunchedEffect(current, following, lyrics.isFinal, lyricViewportHeight) { + if (following && lyricViewportHeight > 0 && current >= 0 && current < displayLines.size) { + programmaticScroll = true + try { + listState.animateScrollToItem( + current, + scrollOffset = -( + lyricViewportHeight / 2 - 56 - lyricTopPaddingPx + ).coerceAtLeast(0) + ) + } finally { + programmaticScroll = false + } + } + } + LaunchedEffect(isDragged) { + if (isDragged && !programmaticScroll) { + following = false + resumeFollowToken += 1 + } else if (!following && !programmaticScroll) { + resumeFollowToken += 1 + } + } + LaunchedEffect(nearestVisibleLine, following) { + if (!following && nearestVisibleLine != null) { + selectedLinePosition = nearestVisibleLine + } + } + LaunchedEffect(resumeFollowToken) { + if (resumeFollowToken > 0) { + delay(4_000L) + following = true + selectedLinePosition = null + } + } + + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + ArtworkAtmosphereBackground( + bitmap = artwork.bitmap, + overlayColors = listOf( + Color(0xe8090b10), + Color(0xc8090b10), + Color(0xff090b10) + ) + ) + Scaffold( + containerColor = Color.Transparent, + topBar = { + ControllerTopBar( + title = "歌词", + navController = navController, + transparent = true, + action = { + TextButton( + onClick = { + following = true + selectedLinePosition = null + }, + enabled = !following + ) { + Text(if (following) "跟随中" else "回到当前") + } + } + ) + } + ) { padding -> + CompositionLocalProvider(LocalContentColor provides Color.White) { + Column( + Modifier.fillMaxSize() + .padding(padding) + .padding(horizontal = 20.dp) + ) { + Text( + playback.title.ifBlank { "等待 QQ 音乐" }, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + playback.artist, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.58f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(12.dp)) + LyricModeSelector( + selected = settings.lyricDisplayMode, + onSelected = viewModel::updateLyricMode + ) + secondaryMissingMessage( + lines = displayLines, + mode = settings.lyricDisplayMode, + isFinal = lyrics.isFinal + )?.let { message -> + Text( + message, + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.56f), + modifier = Modifier.padding(top = 7.dp) + ) + } + AnimatedVisibility(!lyrics.isFinal && displayLines.isNotEmpty()) { + FullLyricsLoadingBanner(lyrics, settings, viewModel::retryLyrics) + } + + if (displayLines.isEmpty()) { + EmptyState( + title = lyricStageText(lyrics.loadingStage), + message = lyrics.failureReason.ifBlank { + "正在等待 Sony 解析 QQ 音乐歌词" + }, + action = "重新请求", + onAction = viewModel::retryLyrics, + modifier = Modifier.weight(1f) + ) + } else { + Box(Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = 132.dp, + bottom = 178.dp, + start = 2.dp, + end = 2.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize().testTag("full_lyrics_list") + ) { + itemsIndexed(displayLines, key = { _, line -> line.index }) { + linePosition, line -> + val isCurrent = linePosition == current + val isBrowseSelected = !following && + linePosition == selectedLinePosition + FullLyricRow( + line = line, + positionMs = position + settings.lyricOffsetMs, + displayMode = settings.lyricDisplayMode, + isCurrent = isCurrent, + isBrowseSelected = isBrowseSelected, + connected = connection.connected, + onSelect = { + val result = LyricBrowsePolicy.onLineTapped( + browsing = !following, + selectedLinePosition = selectedLinePosition, + tappedLinePosition = linePosition, + lineTimeMs = line.timeMs, + connected = connection.connected + ) + following = false + selectedLinePosition = result.selectedLinePosition + resumeFollowToken += 1 + result.seekPositionMs?.let { seekPosition -> + viewModel.seekTo(seekPosition) + following = true + selectedLinePosition = null + } + }, + onSeek = { + if (connection.connected) { + viewModel.seekTo(line.timeMs) + following = true + selectedLinePosition = null + } + } + ) + } + } + if (!following) { + Box( + Modifier.align(Alignment.CenterStart) + .width(3.dp) + .height(48.dp) + .clip(CircleShape) + .background(Color(0xff8ee6a3)) + ) + Surface( + onClick = { + following = true + selectedLinePosition = null + }, + shape = RoundedCornerShape(50), + color = Color.Black.copy(alpha = 0.46f), + modifier = Modifier.align(Alignment.TopEnd).padding(top = 8.dp) + ) { + Text( + "回到当前歌词", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } + } + FullLyricsControls( + isPlaying = playback.isPlaying, + enabled = connection.connected, + onPrevious = viewModel::previous, + onPlayPause = viewModel::playPause, + onNext = viewModel::next + ) + } + } + } + } +} + +@Composable +private fun LyricModeSelector( + selected: LyricDisplayMode, + onSelected: (LyricDisplayMode) -> Unit +) { + val options = listOf( + LyricDisplayMode.ORIGINAL to "原文", + LyricDisplayMode.ORIGINAL_TRANSLATION to "翻译", + LyricDisplayMode.ORIGINAL_ROMANIZATION to "罗马音", + LyricDisplayMode.ALL to "全部" + ) + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.07f)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + options.forEach { (mode, label) -> + Surface( + onClick = { onSelected(mode) }, + shape = RoundedCornerShape(11.dp), + color = if (selected == mode) { + Color.White.copy(alpha = 0.17f) + } else { + Color.Transparent + }, + contentColor = Color.White, + modifier = Modifier.weight(1f) + ) { + Text( + label, + fontSize = 12.sp, + fontWeight = if (selected == mode) FontWeight.Bold else FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } +} + +@Composable +private fun FullLyricsLoadingBanner( + lyrics: LyricsState, + settings: ControllerSettings, + retry: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(lyricStageText(lyrics.loadingStage), fontSize = 12.sp, fontWeight = FontWeight.Medium) + if (settings.experienceMode == AppExperienceMode.DEBUG) { + Text( + if (lyrics.expectedChunks > 0) { + "${lyrics.receivedChunks}/${lyrics.expectedChunks} 分片 · ${lyrics.protocolFormat}" + } else { + "${lyrics.partialFullLines.size} 行 · ${lyrics.protocolFormat}" + }, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.50f) + ) + } + } + TextButton(onClick = retry) { Text("重试", fontSize = 12.sp) } + } +} + +@Composable +private fun FullLyricRow( + line: LyricLine, + positionMs: Long, + displayMode: LyricDisplayMode, + isCurrent: Boolean, + isBrowseSelected: Boolean, + connected: Boolean, + onSelect: () -> Unit, + onSeek: () -> Unit +) { + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(15.dp)) + .clickable(onClick = onSelect) + .background( + when { + isBrowseSelected -> Color.White.copy(alpha = 0.105f) + isCurrent -> Color(0xff8ee6a3).copy(alpha = 0.075f) + else -> Color.Transparent + } + ) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (isCurrent) { + WordHighlightedText( + line = line, + positionMs = positionMs, + modifier = Modifier.fillMaxWidth(), + fontSizeValue = if (line.text.length > 48) 21 else 26, + lineHeightValue = if (line.text.length > 48) 27 else 33, + textAlign = TextAlign.Start, + highlightColor = Color(0xff8ee6a3), + pendingColor = Color.White.copy(alpha = if (isBrowseSelected) 0.60f else 0.34f) + ) + } else { + Text( + line.text, + fontSize = if (line.text.length > 48) 17.sp else if (isBrowseSelected) 22.sp else 20.sp, + lineHeight = if (line.text.length > 48) 23.sp else 28.sp, + fontWeight = if (isBrowseSelected) FontWeight.SemiBold else FontWeight.Medium, + color = Color.White.copy(alpha = if (isBrowseSelected) 0.92f else 0.43f), + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + lyricAuxiliaryLines(line, displayMode).forEach { auxiliary -> + Text( + auxiliary, + fontSize = if (isCurrent) 15.sp else 14.sp, + lineHeight = if (isCurrent) 20.sp else 19.sp, + fontWeight = if (isBrowseSelected) FontWeight.SemiBold else FontWeight.Medium, + color = Color.White.copy( + alpha = when { + isCurrent -> 0.70f + isBrowseSelected -> 0.62f + else -> 0.32f + } + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + if (isBrowseSelected) { + Spacer(Modifier.width(10.dp)) + Surface( + onClick = onSeek, + enabled = connected, + shape = RoundedCornerShape(50), + color = Color.White.copy(alpha = 0.14f), + modifier = Modifier.testTag("lyric_seek_time_${line.index}") + .semantics { contentDescription = "跳转到 ${formatDuration(line.timeMs)}" } + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(Icons.Default.PlayArrow, null, Modifier.size(13.dp)) + Text( + formatDuration(line.timeMs), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } +} + +@Composable +private fun FullLyricsControls( + isPlaying: Boolean, + enabled: Boolean, + onPrevious: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 14.dp), + horizontalArrangement = Arrangement.spacedBy(30.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(enabled = enabled, onClick = onPrevious) { + Icon(Icons.Default.SkipPrevious, "上一首", Modifier.size(30.dp)) + } + FilledIconButton( + enabled = enabled, + onClick = onPlayPause, + modifier = Modifier.size(58.dp) + ) { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + if (isPlaying) "暂停" else "播放", + Modifier.size(32.dp) + ) + } + IconButton(enabled = enabled, onClick = onNext) { + Icon(Icons.Default.SkipNext, "下一首", Modifier.size(30.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HistoryScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val history by viewModel.history.collectAsStateWithLifecycle() + var range by remember { mutableStateOf("TODAY") } + LaunchedEffect(Unit) { viewModel.syncHistory() } + Scaffold( + topBar = { + ControllerTopBar( + title = "播放历史", + navController = navController, + action = { + IconButton(onClick = viewModel::syncHistory) { + Icon(Icons.Default.Sync, "同步历史") + } + } + ) + } + ) { padding -> + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = padding.calculateTopPadding() + 12.dp, + bottom = padding.calculateBottomPadding() + 28.dp, + start = 16.dp, + end = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + StatsRangeSelector(range) { range = it } + } + history.stats[range]?.let { stats -> + item { StatsOverview(stats) } + } + item { + Text( + "最近播放", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + if (history.sessions.isEmpty()) { + item { + Text( + history.status.ifBlank { if (history.loading) "正在同步…" else "暂无历史" }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 28.dp) + ) + } + } + items(history.sessions, key = { it.sessionId }) { session -> + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HistoryArtwork(session.artworkId, viewModel) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + session.title.ifBlank { "未知歌曲" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium + ) + Text( + "${session.artist} · ${formatDate(session.startedAt)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + maxLines = 1 + ) + } + Column(horizontalAlignment = Alignment.End) { + Text(formatDuration(session.listenedMs)) + Text( + when { + session.completed -> "已完播" + session.skipped -> "已跳过" + else -> "播放中断" + }, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + if (history.sessions.isNotEmpty() && history.hasMore) { + item { + OutlinedButton( + onClick = viewModel::loadMoreHistory, + enabled = !history.loading, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (history.loading) "正在加载…" else "加载更多") + } + } + } + } + } +} + +@Composable +private fun HistoryArtwork( + artworkId: String?, + viewModel: ControllerViewModel +) { + var bitmap by remember(artworkId) { mutableStateOf(null) } + LaunchedEffect(artworkId) { + bitmap = artworkId?.let { viewModel.historyArtwork(it) } + } + Box( + Modifier.size(48.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + bitmap?.let { + Image( + it.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } ?: Icon( + Icons.Default.GraphicEq, + null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } +} + +@Composable +private fun StatsRangeSelector(selected: String, onSelected: (String) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("TODAY" to "今天", "WEEK" to "本周", "MONTH" to "本月").forEach { item -> + if (selected == item.first) { + Button(onClick = { onSelected(item.first) }, modifier = Modifier.weight(1f)) { + Text(item.second) + } + } else { + OutlinedButton( + onClick = { onSelected(item.first) }, + modifier = Modifier.weight(1f) + ) { + Text(item.second) + } + } + } + } +} + +@Composable +private fun StatsOverview(stats: com.example.controllerapp.model.PlaybackStats) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Stat("听歌时长", formatDuration(stats.totalListenMs)) + Stat("播放", "${stats.playCount} 次") + Stat("歌曲", "${stats.uniqueTrackCount} 首") + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Stat("完播率", "${(stats.completionRate * 100).toInt()}%") + Stat("跳过率", "${(stats.skipRate * 100).toInt()}%") + Stat("趋势", "${stats.dailyTrend.size} 天") + } + stats.topTracks.firstOrNull()?.let { + Text( + "常听歌曲:${it.title} · ${it.artist}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + stats.topArtists.firstOrNull()?.let { + Text( + "常听歌手:${it.artist}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun Stat(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, fontWeight = FontWeight.SemiBold) + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val settings by viewModel.settings.collectAsStateWithLifecycle() + var lyricOffset by remember(settings.lyricOffsetMs) { + mutableFloatStateOf(settings.lyricOffsetMs.toFloat()) + } + var artworkSize by remember(settings.artworkDisplaySizeDp) { + mutableFloatStateOf(settings.artworkDisplaySizeDp.toFloat()) + } + Scaffold(topBar = { ControllerTopBar("设置", navController) }) { padding -> + Column( + Modifier.fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + SettingSection("显示模式") { + ChoiceRow( + values = AppExperienceMode.entries, + selected = settings.experienceMode, + label = { if (it == AppExperienceMode.DAILY) "日常" else "调试" }, + onSelected = viewModel::updateExperienceMode + ) + } + SettingSection("性能模式") { + ChoiceRow( + values = PlaybackPerformanceMode.entries, + selected = settings.performanceMode, + label = { + when (it) { + PlaybackPerformanceMode.AUTOMATIC -> "自动" + PlaybackPerformanceMode.SMOOTH -> "流畅" + PlaybackPerformanceMode.POWER_SAVING -> "省电" + } + }, + onSelected = viewModel::updatePerformanceMode + ) + } + SettingSwitch( + "自动重连", + "连接意外中断时使用指数退避恢复", + settings.autoReconnect, + viewModel::updateAutoReconnect + ) + SettingSection("歌词显示") { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + LyricDisplayMode.entries.forEach { mode -> + val label = when (mode) { + LyricDisplayMode.ORIGINAL -> "原文" + LyricDisplayMode.ORIGINAL_TRANSLATION -> "原文 + 翻译" + LyricDisplayMode.ORIGINAL_ROMANIZATION -> "原文 + 罗马音" + LyricDisplayMode.ALL -> "原文 + 翻译 + 罗马音" + } + SelectableSetting( + label, + selected = settings.lyricDisplayMode == mode + ) { viewModel.updateLyricMode(mode) } + } + } + } + SettingSection("歌词偏移 · ${lyricOffset.toLong()} ms") { + Slider( + value = lyricOffset, + valueRange = -2_000f..2_000f, + onValueChange = { lyricOffset = it }, + onValueChangeFinished = { + viewModel.updateLyricOffset(lyricOffset.toLong()) + } + ) + } + SettingSection("封面大小 · ${artworkSize.toInt()} dp") { + Slider( + value = artworkSize, + valueRange = 200f..260f, + onValueChange = { artworkSize = it }, + onValueChangeFinished = { + viewModel.updateArtworkSize(artworkSize.toInt()) + } + ) + } + SettingSwitch( + "本地封面增强", + "HQ 到达后在后台轻量锐化,不阻塞基础封面", + settings.artworkEnhancementEnabled, + viewModel::updateArtworkEnhancement + ) + OutlinedButton( + onClick = viewModel::clearArtworkCache, + modifier = Modifier.fillMaxWidth() + ) { + Text("清理当前歌曲封面缓存") + } + } + } +} + +@Composable +private fun SettingSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + content() + } +} + +@Composable +private fun ChoiceRow( + values: List, + selected: T, + label: (T) -> String, + onSelected: (T) -> Unit +) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + values.forEach { item -> + if (item == selected) { + Button(onClick = { onSelected(item) }, modifier = Modifier.weight(1f)) { + Text(label(item), maxLines = 1) + } + } else { + OutlinedButton( + onClick = { onSelected(item) }, + modifier = Modifier.weight(1f) + ) { + Text(label(item), maxLines = 1) + } + } + } + } +} + +@Composable +private fun SettingSwitch( + title: String, + subtitle: String, + checked: Boolean, + onChecked: (Boolean) -> Unit +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Medium) + Text( + subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + Switch(checked = checked, onCheckedChange = onChecked) + } +} + +@Composable +private fun SelectableSetting(label: String, selected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surface + }, + modifier = Modifier.fillMaxWidth() + ) { + Row(Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + if (selected) { + Text("已选择", color = MaterialTheme.colorScheme.primary, fontSize = 12.sp) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HealthScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val connection by viewModel.connection.collectAsStateWithLifecycle() + val diagnostics by viewModel.diagnostics.collectAsStateWithLifecycle() + Scaffold(topBar = { ControllerTopBar("系统健康", navController) }) { padding -> + Column( + Modifier.fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatusCard( + "BLE 连接", + connectionLabel(connection), + "MTU ${connection.mtu} · generation ${connection.generation}" + ) + StatusCard( + "协议", + if (connection.serverSupportsV2) "Sony BLE V2" else "兼容协议", + "协议版本 ${connection.serverProtocolVersion}" + ) + StatusCard( + "健康探测", + connection.health.name, + connection.lastReconnectReason.ifBlank { "连接活动正常" } + ) + if (diagnostics.lastIssue.isNotBlank()) { + StatusCard("最近异常", diagnostics.lastIssue, "自愈仅影响当前连接或当前歌曲") + } + Button( + onClick = { viewModel.reconnect(false) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Refresh, null) + Spacer(Modifier.width(8.dp)) + Text("立即检查并重连") + } + } + } +} + +@Composable +private fun StatusCard(title: String, value: String, detail: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(title, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + Text(value, fontWeight = FontWeight.SemiBold, fontSize = 18.sp) + Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackDiagnosticScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val playback by viewModel.playback.collectAsStateWithLifecycle() + val artwork by viewModel.artwork.collectAsStateWithLifecycle() + val lyrics by viewModel.lyrics.collectAsStateWithLifecycle() + Scaffold(topBar = { ControllerTopBar("当前歌曲诊断", navController) }) { padding -> + Column( + Modifier.fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DiagnosticValue("trackId", playback.trackId) + DiagnosticValue("标题", playback.title) + DiagnosticValue("歌手", playback.artist) + DiagnosticValue("专辑", playback.album) + DiagnosticValue("歌词窗口", "${lyrics.windowLines.size} 行") + DiagnosticValue("完整歌词", "${lyrics.fullLines.size} 行 · final=${lyrics.isFinal}") + DiagnosticValue("歌词状态", lyrics.loadingStage.name) + DiagnosticValue("歌词协议", lyrics.protocolFormat) + DiagnosticValue("transferId", lyrics.transferId) + DiagnosticValue("传输进度", "${lyrics.receivedChunks}/${lyrics.expectedChunks}") + DiagnosticValue("重试次数", lyrics.retryCount.toString()) + DiagnosticValue("封面 ID", artwork.artworkId) + DiagnosticValue("封面质量", artwork.quality.name) + DiagnosticValue("封面状态", artwork.loadingStage.name) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = viewModel::retryLyrics, modifier = Modifier.weight(1f)) { + Text("重试歌词") + } + OutlinedButton(onClick = viewModel::retryArtwork, modifier = Modifier.weight(1f)) { + Text("重试封面") + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LyricDiagnosticScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val diagnostic by viewModel.diagnostics.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { viewModel.requestLyricDiagnostic() } + val lyric = diagnostic.lyricDiagnostic + Scaffold(topBar = { ControllerTopBar("歌词诊断", navController) }) { padding -> + Column( + Modifier.fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DiagnosticValue("状态", lyric.status.ifBlank { "等待 Sony 返回" }) + DiagnosticValue("原因", lyric.reason) + DiagnosticValue("建议", lyric.suggestion) + lyric.details.forEach { (key, value) -> DiagnosticValue(key, value) } + Button( + onClick = viewModel::requestLyricDiagnostic, + modifier = Modifier.fillMaxWidth() + ) { + Text("重新诊断") + } + } + } +} + +@Composable +private fun DiagnosticValue(label: String, value: String) { + Column( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(13.dp) + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp) + Text(value.ifBlank { "—" }, maxLines = 8, overflow = TextOverflow.Ellipsis) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DebugToolsScreen( + viewModel: ControllerViewModel, + navController: NavHostController +) { + val diagnostics by viewModel.diagnostics.collectAsStateWithLifecycle() + val context = LocalContext.current + Scaffold(topBar = { ControllerTopBar("调试工具", navController) }) { padding -> + Column( + Modifier.fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DebugAction("强制重新扫描", Icons.Default.Bluetooth) { viewModel.reconnect(true) } + DebugAction("同步播放状态", Icons.Default.Refresh, viewModel::requestPlaybackState) + DebugAction("请求完整歌词", Icons.AutoMirrored.Filled.LibraryBooks, viewModel::requestFullLyrics) + DebugAction("请求 HQ 封面", Icons.Default.Image) { + viewModel.requestArtwork(ArtworkQuality.HQ) + } + DebugAction("当前歌曲诊断", Icons.Default.GraphicEq) { + navController.navigate(Routes.TRACK_DIAGNOSTIC) + } + DebugAction("歌词诊断", Icons.Default.BugReport) { + navController.navigate(Routes.LYRIC_DIAGNOSTIC) + } + DebugAction("获取 Sony 日志", Icons.Default.ContentCopy, viewModel::requestSonyLogs) + DebugAction("获取 Media Dump", Icons.Default.ContentCopy, viewModel::requestMediaDump) + DebugAction("分享 Android 日志", Icons.Default.Share) { + val file = viewModel.logFile() + if (file.exists()) { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.files", + file + ) + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + "分享诊断日志" + ) + ) + } + } + OutlinedButton( + onClick = viewModel::clearLogs, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Close, null) + Spacer(Modifier.width(8.dp)) + Text("清理本地日志") + } + Text( + "RFCOMM 兼容入口", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + "仅用于旧 Sony 版本。启动时会主动停止 BLE V2,退出后恢复已保存设备的 BLE 连接。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + DebugAction( + "启动 RFCOMM 兼容模式", + Icons.Default.Bluetooth, + viewModel::startLegacyRfcomm + ) + DebugAction( + "停止 RFCOMM 并恢复 BLE", + Icons.Default.Refresh, + viewModel::stopLegacyRfcomm + ) + if (diagnostics.sonyLogs.isNotBlank()) { + DiagnosticText("Sony 日志", diagnostics.sonyLogs) + } + if (diagnostics.mediaFieldDump.isNotBlank()) { + DiagnosticText("Media Dump", diagnostics.mediaFieldDump) + } + if (diagnostics.recentLogs.isNotEmpty()) { + DiagnosticText("Android 最近日志", diagnostics.recentLogs.takeLast(80).joinToString("\n")) + } + } + } +} + +@Composable +private fun DebugAction( + text: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, null) + Spacer(Modifier.width(12.dp)) + Text(text, Modifier.weight(1f)) + Icon(Icons.Default.ChevronRight, null) + } + } +} + +@Composable +private fun DiagnosticText(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text( + text, + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(12.dp), + fontSize = 11.sp, + lineHeight = 15.sp + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ControllerTopBar( + title: String, + navController: NavHostController, + transparent: Boolean = false, + action: @Composable (() -> Unit)? = null +) { + CenterAlignedTopAppBar( + title = { Text(title) }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface, + scrolledContainerColor = if (transparent) { + Color.Black.copy(alpha = 0.72f) + } else { + MaterialTheme.colorScheme.surface + }, + navigationIconContentColor = if (transparent) Color.White else MaterialTheme.colorScheme.onSurface, + titleContentColor = if (transparent) Color.White else MaterialTheme.colorScheme.onSurface, + actionIconContentColor = if (transparent) Color.White else MaterialTheme.colorScheme.onSurface + ), + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") + } + }, + actions = { + action?.invoke() + } + ) +} + +@Composable +private fun EmptyState( + title: String, + message: String, + action: String, + onAction: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.AutoMirrored.Filled.LibraryBooks, + null, + Modifier.size(54.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(18.dp)) + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + Button(onClick = onAction) { Text(action) } + } +} + +private fun connectionLabel(value: ConnectionState): String = + when (value.phase) { + ConnectionPhase.CONNECTED -> when (value.health) { + ConnectionHealth.HEALTHY -> "Sony 已连接" + ConnectionHealth.SUSPECT -> "连接待确认" + ConnectionHealth.STALE -> "连接无响应" + ConnectionHealth.DISCONNECTED -> "Sony 已断开" + } + ConnectionPhase.SCANNING -> "正在查找 Sony" + ConnectionPhase.CONNECTING -> "正在连接" + ConnectionPhase.DISCOVERING -> "正在发现服务" + ConnectionPhase.SUBSCRIBING -> "正在订阅" + ConnectionPhase.RECONNECTING -> "正在重连 ${value.reconnectAttempt}" + ConnectionPhase.DISCONNECTED -> "Sony 未连接" + } + +private fun lyricStageText(value: LyricLoadingStage): String = + when (value) { + LyricLoadingStage.IDLE -> "等待播放" + LyricLoadingStage.WAITING_QQ_QRC -> "等待 QQ 音乐歌词" + LyricLoadingStage.WINDOW_READY -> "当前歌词已就绪" + LyricLoadingStage.FULL_LYRICS -> "正在接收完整歌词" + LyricLoadingStage.READY -> "完整歌词已就绪" + LyricLoadingStage.FAILED -> "歌词获取失败" + } + +private fun currentLineIndex( + lines: List, + positionMs: Long, + serverIndex: Int +): Int = LyricTimeline.currentLinePosition(lines, positionMs, serverIndex) + +private fun lyricAuxiliaryLines(line: LyricLine, mode: LyricDisplayMode): List = + buildList { + if (mode.showsTranslation) line.translation?.takeIf(String::isNotBlank)?.let(::add) + if (mode.showsRomanization) line.romanization?.takeIf(String::isNotBlank)?.let(::add) + } + +private fun secondaryMissingMessage( + lines: List, + mode: LyricDisplayMode, + isFinal: Boolean +): String? { + if (!isFinal || lines.isEmpty() || mode == LyricDisplayMode.ORIGINAL) return null + val translationMissing = mode.showsTranslation && lines.none { + !it.translation.isNullOrBlank() + } + val romanizationMissing = mode.showsRomanization && lines.none { + !it.romanization.isNullOrBlank() + } + return when { + translationMissing && romanizationMissing -> "该歌曲暂无翻译和罗马音" + translationMissing -> "该歌曲暂无翻译" + romanizationMissing -> "该歌曲暂无罗马音" + else -> null + } +} + +private fun formatDuration(milliseconds: Long): String = + DateUtils.formatElapsedTime(milliseconds.coerceAtLeast(0L) / 1_000L) + +private fun formatDate(milliseconds: Long): String = + SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(milliseconds)) diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerTheme.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerTheme.kt new file mode 100644 index 0000000..59cba02 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerTheme.kt @@ -0,0 +1,26 @@ +package com.example.controllerapp.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val ControllerColors = darkColorScheme( + primary = Color(0xffb9c7ff), + onPrimary = Color(0xff102158), + secondary = Color(0xffc3c6dd), + background = Color(0xff090b10), + surface = Color(0xff11141c), + surfaceVariant = Color(0xff20232d), + onSurface = Color(0xfff0f1f8), + onSurfaceVariant = Color(0xffc5c6d0), + error = Color(0xffffb4ab) +) + +@Composable +fun ControllerTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = ControllerColors, + content = content + ) +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/LyricBrowsePolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/LyricBrowsePolicy.kt new file mode 100644 index 0000000..0809409 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/LyricBrowsePolicy.kt @@ -0,0 +1,36 @@ +package com.example.controllerapp.ui + +internal data class LyricViewportItem( + val linePosition: Int, + val centerPx: Int +) + +internal data class LyricTapResult( + val selectedLinePosition: Int, + val seekPositionMs: Long? = null +) + +internal object LyricBrowsePolicy { + fun nearestLine( + items: List, + viewportCenterPx: Int + ): Int? = items.minByOrNull { item -> + kotlin.math.abs(item.centerPx - viewportCenterPx) + }?.linePosition + + fun onLineTapped( + browsing: Boolean, + selectedLinePosition: Int?, + tappedLinePosition: Int, + lineTimeMs: Long, + connected: Boolean + ): LyricTapResult { + val confirmsCurrentSelection = browsing && + selectedLinePosition == tappedLinePosition && + connected + return LyricTapResult( + selectedLinePosition = tappedLinePosition, + seekPositionMs = lineTimeMs.takeIf { confirmsCurrentSelection } + ) + } +} diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/NaturalSpectrumEngine.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/NaturalSpectrumEngine.kt new file mode 100644 index 0000000..438ed64 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/NaturalSpectrumEngine.kt @@ -0,0 +1,211 @@ +package com.example.controllerapp.ui + +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin + +internal data class SpectrumFrame( + val trackSeed: String, + val positionMs: Long, + val playing: Boolean, + val lyricProgress: Float, + val wordSignature: String +) + +/** + * Deterministic pseudo spectrum driven by playback and lyric timing. + * + * Sony does not expose PCM/FFT data over BLE, so this deliberately avoids pretending to be a + * real analyser. It uses stable per-track frequency bands, attack/release smoothing and small + * word-boundary pulses to produce natural motion without making every song share one sine wave. + */ +internal class NaturalSpectrumEngine( + private val barCount: Int = DEFAULT_BAR_COUNT +) { + private var levels = FloatArray(barCount) { IDLE_BASELINE } + private var stableSeed = 0L + private var lastTrackSeed = "" + private var lastPositionMs: Long? = null + private var lastWordSignature = "" + private var wordPulse = 0f + + fun levels(frame: SpectrumFrame): FloatArray { + updateTrack(frame.trackSeed) + val deltaSeconds = lastPositionMs + ?.let { abs(frame.positionMs - it).coerceIn(16L, 200L) / 1_000f } + ?: 0.05f + lastPositionMs = frame.positionMs + updateWordPulse(frame.wordSignature, frame.playing, deltaSeconds) + + val raw = rawTargets(frame) + val smoothed = smoothBands(raw) + if (!frame.playing) { + levels = smoothed + return levels.copyOf() + } + for (index in levels.indices) { + val coefficient = when { + smoothed[index] > levels[index] -> ATTACK + frame.playing -> RELEASE + else -> PAUSE_RELEASE + } + val adjusted = 1f - (1f - coefficient).pow(deltaSeconds * 60f) + levels[index] += (smoothed[index] - levels[index]) * adjusted + } + return levels.copyOf() + } + + private fun updateTrack(trackSeed: String) { + if (trackSeed == lastTrackSeed) return + lastTrackSeed = trackSeed + stableSeed = stableHash(trackSeed) + levels.fill(IDLE_BASELINE) + lastPositionMs = null + lastWordSignature = "" + wordPulse = 0f + } + + private fun updateWordPulse(signature: String, playing: Boolean, deltaSeconds: Float) { + if (playing && signature.isNotBlank() && lastWordSignature.isNotBlank() && + signature != lastWordSignature + ) { + wordPulse = 1f + } + if (signature.isNotBlank()) lastWordSignature = signature + wordPulse = max(0f, wordPulse - deltaSeconds / WORD_PULSE_SECONDS) + } + + private fun rawTargets(frame: SpectrumFrame): FloatArray { + val seconds = frame.positionMs.coerceAtLeast(0L) / 1_000.0 + val seedPhase = unitNoise((stableSeed and 0x7fff).toInt()) * Math.PI * 2.0 + val phase = seconds + seedPhase + val global = if (frame.playing) 0.32 + 0.05 * sin(seconds * 1.17) else 0.08 + val motion = if (frame.playing) 1.0 else 0.12 + return FloatArray(barCount) { index -> + val x = index.toDouble() / max(barCount - 1, 1).toDouble() + val band = bandProfile(x) + val noise = unitNoise(index + (stableSeed and 0x3ff).toInt()) + val localPhase = noise * Math.PI * 2.0 + val group = floor(x * 9.0) + val low = sin(phase * band.lowRate + localPhase * 0.62 + group * 0.38) + val mid = sin( + phase * band.midRate + localPhase * 1.17 + + sin(phase * 0.62 + group) * 0.46 + ) + val high = sin(phase * band.highRate + localPhase * 2.30 + group * 1.15) + val beat = ((sin(phase * band.beatRate + localPhase * 0.70) + 1.0) / 2.0) + .pow(band.beatShape) + val lyricAccent = if (frame.playing) { + sin(frame.lyricProgress.coerceIn(0f, 1f) * Math.PI * 2.0 + localPhase * 0.25) * 0.025 + } else { + 0.0 + } + val energy = IDLE_BASELINE + + global * band.weight + + motion * low * band.lowAmount + + motion * mid * band.midAmount + + motion * high * band.highAmount + + motion * beat * band.beatAmount + + wordPulse * band.wordAmount + + lyricAccent + energy.coerceIn(0.04, 1.0).toFloat() + } + } + + private fun smoothBands(raw: FloatArray): FloatArray { + if (raw.size < 3) return raw + return FloatArray(raw.size) { index -> + val x = index.toFloat() / max(raw.lastIndex, 1).toFloat() + val boundary = abs(x - 0.28f) < 0.035f || abs(x - 0.76f) < 0.035f + val neighborWeight = if (boundary) 0.06f else 0.13f + raw[index] * (1f - neighborWeight * 2f) + + raw[max(0, index - 1)] * neighborWeight + + raw[min(raw.lastIndex, index + 1)] * neighborWeight + } + } + + private fun bandProfile(x: Double): BandProfile = when { + x < 0.28 -> BandProfile( + weight = 0.90, + lowRate = 1.45, + lowAmount = 0.18, + midRate = 2.25, + midAmount = 0.12, + highRate = 4.0, + highAmount = 0.035, + beatRate = 1.95, + beatShape = 2.4, + beatAmount = 0.16, + wordAmount = 0.04 + ) + x < 0.76 -> BandProfile( + weight = 0.72, + lowRate = 1.10, + lowAmount = 0.11, + midRate = 3.25, + midAmount = 0.18, + highRate = 7.8, + highAmount = 0.07, + beatRate = 2.55, + beatShape = 2.9, + beatAmount = 0.12, + wordAmount = 0.15 + ) + else -> BandProfile( + weight = 0.46, + lowRate = 0.85, + lowAmount = 0.055, + midRate = 3.8, + midAmount = 0.09, + highRate = 10.8, + highAmount = 0.13, + beatRate = 3.4, + beatShape = 3.2, + beatAmount = 0.08, + wordAmount = 0.05 + ) + } + + private fun unitNoise(value: Int): Double { + var x = value.toLong() xor stableSeed + x = (x xor (x ushr 30)) * -4658895280553007687L + x = (x xor (x ushr 27)) * -7723592293110705685L + x = x xor (x ushr 31) + return (x and 0x7fff_ffffL).toDouble() / 0x7fff_ffffL.toDouble() + } + + private fun stableHash(value: String): Long { + var hash = -3750763034362895579L + value.forEach { char -> + hash = hash xor char.code.toLong() + hash *= 1099511628211L + } + return hash + } + + private data class BandProfile( + val weight: Double, + val lowRate: Double, + val lowAmount: Double, + val midRate: Double, + val midAmount: Double, + val highRate: Double, + val highAmount: Double, + val beatRate: Double, + val beatShape: Double, + val beatAmount: Double, + val wordAmount: Double + ) + + private companion object { + const val DEFAULT_BAR_COUNT = 36 + const val IDLE_BASELINE = 0.09f + const val ATTACK = 0.38f + const val RELEASE = 0.13f + const val PAUSE_RELEASE = 0.08f + const val WORD_PULSE_SECONDS = 0.20f + } +} diff --git a/ControllerApp/src/main/res/values/styles.xml b/ControllerApp/src/main/res/values/styles.xml index 9cf05c6..62bca44 100644 --- a/ControllerApp/src/main/res/values/styles.xml +++ b/ControllerApp/src/main/res/values/styles.xml @@ -1,8 +1,11 @@ - diff --git a/ControllerApp/src/main/res/xml/file_paths.xml b/ControllerApp/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d00120c --- /dev/null +++ b/ControllerApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/ControllerApp/src/test/java/com/example/controllerapp/ble/BlePoliciesTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/ble/BlePoliciesTest.kt new file mode 100644 index 0000000..e0244f4 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/ble/BlePoliciesTest.kt @@ -0,0 +1,35 @@ +package com.example.controllerapp.ble + +import com.example.controllerapp.service.ReconnectPolicy +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlePoliciesTest { + @Test + fun `write queue stays FIFO and drops only oldest pending command`() { + val queue = BoundedGattWriteQueue(2) + assertNull(queue.offer(byteArrayOf(1))) + assertNull(queue.offer(byteArrayOf(2))) + assertArrayEquals(byteArrayOf(1), queue.offer(byteArrayOf(3))) + assertEquals(2, queue.size()) + assertArrayEquals(byteArrayOf(2), queue.poll()) + assertArrayEquals(byteArrayOf(3), queue.poll()) + assertNull(queue.poll()) + } + + @Test + fun `reconnect backoff is bounded and periodically forces scan`() { + assertEquals(1_000L, ReconnectPolicy.delayMs(1)) + assertEquals(2_000L, ReconnectPolicy.delayMs(2)) + assertEquals(16_000L, ReconnectPolicy.delayMs(5)) + assertEquals(30_000L, ReconnectPolicy.delayMs(6)) + assertEquals(30_000L, ReconnectPolicy.delayMs(100)) + assertTrue(ReconnectPolicy.shouldForceScan(1, "")) + assertFalse(ReconnectPolicy.shouldForceScan(2, "AA:BB")) + assertTrue(ReconnectPolicy.shouldForceScan(3, "AA:BB")) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/media/ArtworkPlaceholderPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/media/ArtworkPlaceholderPolicyTest.kt new file mode 100644 index 0000000..e2e3049 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/media/ArtworkPlaceholderPolicyTest.kt @@ -0,0 +1,34 @@ +package com.example.controllerapp.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtworkPlaceholderPolicyTest { + @Test + fun `QQ 228 gray placeholder is rejected`() { + val gray = 0xff777777.toInt() + assertTrue( + ArtworkPlaceholderPolicy.isLikelyPlaceholder( + 228, + 228, + IntArray(24 * 24) { gray } + ) + ) + } + + @Test + fun `colorful square and stable large grayscale image are retained`() { + val colorful = IntArray(24 * 24) { index -> + if (index % 2 == 0) 0xffff3300.toInt() else 0xff0055ff.toInt() + } + assertFalse(ArtworkPlaceholderPolicy.isLikelyPlaceholder(228, 228, colorful)) + assertFalse( + ArtworkPlaceholderPolicy.isLikelyPlaceholder( + 780, + 780, + IntArray(24 * 24) { 0xff777777.toInt() } + ) + ) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt new file mode 100644 index 0000000..47642f7 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt @@ -0,0 +1,83 @@ +package com.example.controllerapp.media + +import com.example.controllerapp.model.PlaybackPerformanceMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlaybackClockPolicyTest { + @Test + fun `clock is suspended while paused or UI is hidden`() { + assertNull( + PlaybackClockPolicy.refreshIntervalMs( + uiVisible = false, + isPlaying = true, + performanceMode = PlaybackPerformanceMode.SMOOTH + ) + ) + assertNull( + PlaybackClockPolicy.refreshIntervalMs( + uiVisible = true, + isPlaying = false, + performanceMode = PlaybackPerformanceMode.AUTOMATIC + ) + ) + } + + @Test + fun `visible playback respects performance mode`() { + assertEquals( + 50L, + PlaybackClockPolicy.refreshIntervalMs( + true, + true, + PlaybackPerformanceMode.SMOOTH + ) + ) + assertEquals( + 100L, + PlaybackClockPolicy.refreshIntervalMs( + true, + true, + PlaybackPerformanceMode.AUTOMATIC + ) + ) + assertEquals( + 200L, + PlaybackClockPolicy.refreshIntervalMs( + true, + true, + PlaybackPerformanceMode.POWER_SAVING + ) + ) + assertEquals( + 200L, + PlaybackClockPolicy.refreshIntervalMs( + true, + true, + PlaybackPerformanceMode.AUTOMATIC, + systemPowerSaveMode = true + ) + ) + } + + @Test + fun `automatic mode respects battery saver and reduced motion`() { + assertEquals( + false, + PlaybackClockPolicy.animationsEnabled( + PlaybackPerformanceMode.AUTOMATIC, + systemPowerSaveMode = true, + systemAnimationsEnabled = true + ) + ) + assertEquals( + false, + PlaybackClockPolicy.animationsEnabled( + PlaybackPerformanceMode.SMOOTH, + systemPowerSaveMode = false, + systemAnimationsEnabled = false + ) + ) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt new file mode 100644 index 0000000..1dc3c86 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt @@ -0,0 +1,130 @@ +package com.example.controllerapp.protocol + +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricWord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class CapabilityAndTimelineTest { + @Test + fun `capability timeout preserves acknowledged V2 and otherwise falls back`() { + val v2 = ServerCapabilities( + negotiated = true, + protocolVersion = 2, + albumArtBinary = true, + fullLyricsZlib = true, + lyricWindow = true, + ping = true, + transferRetry = true + ) + assertSame(v2, CapabilityPolicy.fallbackIfUnacknowledged(v2)) + val fallback = CapabilityPolicy.fallbackIfUnacknowledged( + ServerCapabilities(fullLyricsZlib = true) + ) + assertFalse(fallback.negotiated) + assertEquals(1, fallback.protocolVersion) + assertFalse(fallback.fullLyricsZlib) + } + + @Test + fun `current word fence rejects stale sequence but accepts seek and generation change`() { + val first = CurrentWordOrderingFence().accept(4, 10, 10_000)!! + assertNull(first.accept(4, 10, 10_200)) + assertNull(first.accept(3, 99, 11_000)) + val afterSeek = first.accept(4, 11, 9_200)!! + assertEquals(9_200L, afterSeek.positionMs) + val newGeneration = afterSeek.accept(5, 1, 0)!! + assertEquals(5L, newGeneration.generation) + assertEquals(1L, newGeneration.sequence) + } + + @Test + fun `window line uses global server index without treating it as list offset`() { + val lines = (7..11).map { index -> + LyricLine(index, index * 1_000L, 900, "line $index") + } + assertEquals(2, LyricTimeline.currentLinePosition(lines, 9_100, 9)) + assertEquals(0, LyricTimeline.currentLinePosition(lines, 6_000, 7)) + } + + @Test + fun `word timeline follows QRC boundaries`() { + val line = LyricLine( + index = 0, + timeMs = 0, + durationMs = 1_000, + text = "hello", + words = listOf( + LyricWord(0, 0, 400, "he"), + LyricWord(1, 400, 600, "llo") + ) + ) + assertEquals(0, LyricTimeline.currentWordPosition(line, 399)) + assertEquals(1, LyricTimeline.currentWordPosition(line, 400)) + assertEquals(0.5f, LyricTimeline.lineProgress(line, 500), 0.001f) + assertEquals(0f, LyricTimeline.lineProgress(line, -100), 0.001f) + assertEquals(1f, LyricTimeline.lineProgress(line, 2_000), 0.001f) + } + + @Test + fun `karaoke character progress uses word duration and preserves separators`() { + val line = LyricLine( + index = 1, + timeMs = 1_000, + durationMs = 1_200, + text = "hello world", + words = listOf( + LyricWord(0, 1_000, 500, "hello"), + LyricWord(1, 1_600, 500, "world") + ) + ) + + val midwayFirstWord = LyricTimeline.characterProgresses(line, 1_250) + assertEquals(1f, midwayFirstWord[0], 0.001f) + assertEquals(1f, midwayFirstWord[1], 0.001f) + assertEquals(0.5f, midwayFirstWord[2], 0.001f) + assertEquals(0f, midwayFirstWord[4], 0.001f) + assertEquals(0f, midwayFirstWord[6], 0.001f) + + val betweenWords = LyricTimeline.characterProgresses(line, 1_550) + assertEquals(1f, betweenWords[4], 0.001f) + assertEquals(1f, betweenWords[5], 0.001f) + assertEquals(0f, betweenWords[6], 0.001f) + } + + @Test + fun `karaoke progress falls back to line timing without words`() { + val line = LyricLine( + index = 2, + timeMs = 2_000, + durationMs = 1_000, + text = "歌词" + ) + + val midway = LyricTimeline.characterProgresses(line, 2_250) + assertEquals(0.5f, midway[0], 0.001f) + assertEquals(0f, midway[1], 0.001f) + assertTrue(LyricTimeline.characterProgresses(line, 3_000).all { it == 1f }) + } + + @Test + fun `karaoke ignores invalid doubled QRC word clock and uses line clock`() { + val line = LyricLine( + index = 3, + timeMs = 100_000, + durationMs = 4_000, + text = "abcd", + words = listOf(LyricWord(0, 200_000, 500, "abcd")) + ) + + val midway = LyricTimeline.characterProgresses(line, 102_000) + assertEquals(1f, midway[0], 0.001f) + assertEquals(1f, midway[1], 0.001f) + assertEquals(0f, midway[2], 0.001f) + assertEquals(0f, midway[3], 0.001f) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/protocol/ControllerProtocolCodecTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/ControllerProtocolCodecTest.kt new file mode 100644 index 0000000..a97fdbb --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/ControllerProtocolCodecTest.kt @@ -0,0 +1,128 @@ +package com.example.controllerapp.protocol + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.zip.Deflater +import kotlin.random.Random + +class ControllerProtocolCodecTest { + @Test + fun `A1 and A2 headers decode unsigned indexes`() { + val a1 = byteArrayOf( + 0xA1.toByte(), 3, 0, 2, 0, 4, 9, 8 + ) + val art = ControllerProtocolCodec.decodeBinaryChunk( + a1, + ControllerProtocolCodec.ALBUM_ART_MAGIC + )!! + assertEquals(3, art.kindCode) + assertEquals(2, art.index) + assertEquals(4, art.total) + assertArrayEquals(byteArrayOf(9, 8), art.payload) + + val a2 = byteArrayOf( + 0xA2.toByte(), 1, 0x01, 0x02, 0x01, 0x03, 7 + ) + val lyric = ControllerProtocolCodec.decodeBinaryChunk( + a2, + ControllerProtocolCodec.FULL_LYRICS_MAGIC + )!! + assertEquals(258, lyric.index) + assertEquals(259, lyric.total) + assertNull( + ControllerProtocolCodec.decodeBinaryChunk( + a2, + ControllerProtocolCodec.ALBUM_ART_MAGIC + ) + ) + } + + @Test + fun `zlib vector validates CRC and decodes lyrics`() { + val raw = JSONObject() + .put("format", "zlib-json-v1") + .put("trackId", "track-1") + .put("generation", 42) + .put( + "lines", + JSONArray() + .put( + JSONObject() + .put("index", 0) + .put("timeMs", 100) + .put("durationMs", 900) + .put("text", "第一行") + ) + .put( + JSONObject() + .put("index", 1) + .put("timeMs", 1_000) + .put("durationMs", 900) + .put("text", "第二行") + ) + ) + .toString() + .toByteArray() + val compressed = compress(raw) + assertEquals( + ControllerProtocolCodec.crc32Hex(compressed).toLong(16), + ControllerProtocolCodec.crc32(compressed) + ) + val decoded = ControllerProtocolCodec.zlibDecompress(compressed, raw.size)!! + assertArrayEquals(raw, decoded) + val lyrics = ControllerProtocolCodec.decodeLyricPayload(decoded)!! + assertEquals("track-1", lyrics.first) + assertEquals(42L, lyrics.second) + assertEquals(listOf("第一行", "第二行"), lyrics.third.map { it.text }) + } + + @Test + fun `reassembly survives duplicate and out of order chunks and detects loss`() { + val random = Random(20260730L) + repeat(100) { + val source = ByteArray(1_024 + it) { random.nextInt(256).toByte() } + val chunks = source.toList().chunked(37).map { part -> part.toByteArray() } + val shuffled = chunks.indices.shuffled(random) + val received = HashMap() + shuffled.forEach { index -> + received[index] = chunks[index] + if (index % 3 == 0) received[index] = chunks[index] + } + assertArrayEquals( + source, + ControllerProtocolCodec.reassemble(received, chunks.size) + ) + val missingIndex = chunks.lastIndex / 2 + received.remove(missingIndex) + assertEquals( + listOf(missingIndex), + ControllerProtocolCodec.missingIndexes(received, chunks.size) + ) + assertNull(ControllerProtocolCodec.reassemble(received, chunks.size)) + } + } + + @Test + fun `damaged compressed body is rejected`() { + val raw = "fixed-vector".repeat(50).toByteArray() + val compressed = compress(raw) + compressed[compressed.lastIndex / 2] = + (compressed[compressed.lastIndex / 2].toInt() xor 0x40).toByte() + assertTrue(ControllerProtocolCodec.zlibDecompress(compressed, raw.size) == null) + } + + private fun compress(value: ByteArray): ByteArray { + val deflater = Deflater() + deflater.setInput(value) + deflater.finish() + val output = ByteArray(value.size * 2) + val count = deflater.deflate(output) + deflater.end() + return output.copyOf(count) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/protocol/LyricsPublicationPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/LyricsPublicationPolicyTest.kt new file mode 100644 index 0000000..5d5bd9e --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/LyricsPublicationPolicyTest.kt @@ -0,0 +1,106 @@ +package com.example.controllerapp.protocol + +import com.example.controllerapp.model.LyricLine +import com.example.controllerapp.model.LyricLoadingStage +import com.example.controllerapp.model.LyricsState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class LyricsPublicationPolicyTest { + private val full = (0 until 60).map { index -> + LyricLine(index, index * 1_000L, 900L, "full-$index") + } + + @Test + fun `window remains useful without pretending to be full lyrics`() { + val window = full.subList(20, 25) + val result = LyricsPublicationPolicy.publishWindow( + state = LyricsState(trackId = "song"), + trackId = "song", + generation = 7L, + lines = window, + currentLineIndex = 22 + ) + + assertEquals(window, result.windowLines) + assertEquals(window, result.lines) + assertFalse(result.isFinal) + assertEquals(LyricLoadingStage.WINDOW_READY, result.loadingStage) + } + + @Test + fun `late five line window cannot replace completed lyrics or progress`() { + val completed = LyricsPublicationPolicy.publishFull( + state = LyricsState(trackId = "song"), + trackId = "song", + generation = 7L, + lines = full, + currentLineIndex = 21, + format = "zlib-json-v1" + ) + val result = LyricsPublicationPolicy.publishWindow( + state = completed, + trackId = "song", + generation = 7L, + lines = full.subList(20, 25), + currentLineIndex = 22 + ) + + assertTrue(result.isFinal) + assertEquals(full, result.lines) + assertEquals(60, result.receivedChunks) + assertEquals(60, result.expectedChunks) + assertEquals(LyricLoadingStage.READY, result.loadingStage) + } + + @Test + fun `partial transfer is visible then atomically replaced by final lyrics`() { + val initial = LyricsState( + trackId = "song", + windowLines = full.subList(5, 10) + ) + val partial = LyricsPublicationPolicy.publishPartial( + initial, + "song", + 9L, + full.take(12) + ) + assertEquals(12, partial.lines.size) + assertFalse(partial.isFinal) + + val completed = LyricsPublicationPolicy.publishFull( + partial, + "song", + 9L, + full, + currentLineIndex = 8, + format = "legacy" + ) + assertTrue(completed.isFinal) + assertTrue(completed.partialFullLines.isEmpty()) + assertEquals(full, completed.lines) + } + + @Test + fun `partial data received after final is ignored`() { + val completed = LyricsPublicationPolicy.publishFull( + LyricsState(), + "song", + 3L, + full, + currentLineIndex = 0, + format = "legacy" + ) + val result = LyricsPublicationPolicy.publishPartial( + completed, + "song", + 3L, + full.take(3) + ) + + assertSame(completed, result) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt new file mode 100644 index 0000000..c750b89 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt @@ -0,0 +1,64 @@ +package com.example.controllerapp.protocol + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaIdentityPolicyTest { + @Test + fun repeatedTrackWithNewGenerationIsNewMedia() { + assertTrue(MediaIdentityPolicy.isNewMedia("same", 4L, "same", 5L)) + } + + @Test + fun legacyPeerWithoutGenerationKeepsTrackCompatibility() { + assertFalse(MediaIdentityPolicy.isNewMedia("same", 4L, "same", 0L)) + assertTrue(MediaIdentityPolicy.generationMatches(4L, 0L)) + } + + @Test + fun transferGenerationRejectsStaleReplay() { + assertFalse(MediaIdentityPolicy.generationMatches(5L, 4L)) + assertTrue(MediaIdentityPolicy.generationMatches(5L, 5L)) + } + + @Test + fun newerSameTrackTransferCanAdvanceMetadataGenerationButOlderCannot() { + assertTrue(MediaIdentityPolicy.canAdoptIncomingGeneration(1L, 2L)) + assertTrue(MediaIdentityPolicy.canAdoptIncomingGeneration(2L, 2L)) + assertFalse(MediaIdentityPolicy.canAdoptIncomingGeneration(2L, 1L)) + assertTrue(MediaIdentityPolicy.canAdoptIncomingGeneration(2L, 0L)) + } + + @Test + fun artworkTransferSurvivesSameTrackGenerationAdvance() { + assertTrue( + MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = "track-a", + transferTrackId = "track-a", + currentGeneration = 8L, + transferGeneration = 3L + ) + ) + } + + @Test + fun artworkTransferRejectsDifferentTrackOrFutureGeneration() { + assertFalse( + MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = "track-b", + transferTrackId = "track-a", + currentGeneration = 8L, + transferGeneration = 3L + ) + ) + assertFalse( + MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = "track-a", + transferTrackId = "track-a", + currentGeneration = 3L, + transferGeneration = 8L + ) + ) + } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/ui/LyricVisualPoliciesTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/ui/LyricVisualPoliciesTest.kt new file mode 100644 index 0000000..5f9849c --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/ui/LyricVisualPoliciesTest.kt @@ -0,0 +1,68 @@ +package com.example.controllerapp.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LyricVisualPoliciesTest { + @Test + fun `first lyric tap selects and second connected tap confirms seek`() { + val first = LyricBrowsePolicy.onLineTapped( + browsing = false, + selectedLinePosition = null, + tappedLinePosition = 8, + lineTimeMs = 42_300, + connected = true + ) + assertEquals(8, first.selectedLinePosition) + assertNull(first.seekPositionMs) + + val confirmed = LyricBrowsePolicy.onLineTapped( + browsing = true, + selectedLinePosition = first.selectedLinePosition, + tappedLinePosition = 8, + lineTimeMs = 42_300, + connected = true + ) + assertEquals(42_300L, confirmed.seekPositionMs) + } + + @Test + fun `disconnected lyric selection never seeks`() { + val result = LyricBrowsePolicy.onLineTapped(true, 3, 3, 8_000, false) + assertEquals(3, result.selectedLinePosition) + assertNull(result.seekPositionMs) + } + + @Test + fun `nearest viewport line follows visual center`() { + assertEquals( + 4, + LyricBrowsePolicy.nearestLine( + listOf( + LyricViewportItem(3, 120), + LyricViewportItem(4, 205), + LyricViewportItem(5, 310) + ), + viewportCenterPx = 220 + ) + ) + } + + @Test + fun `natural spectrum is bounded varied and track specific`() { + val firstEngine = NaturalSpectrumEngine(36) + val first = firstEngine.levels( + SpectrumFrame("track-a", 12_000, true, 0.42f, "4:2") + ) + val second = NaturalSpectrumEngine(36).levels( + SpectrumFrame("track-b", 12_000, true, 0.42f, "4:2") + ) + + assertTrue(first.all { it in 0f..1f }) + assertTrue(first.maxOrNull()!! - first.minOrNull()!! > 0.08f) + assertFalse(first.contentEquals(second)) + } +} diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440.zip" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440.zip" new file mode 100644 index 0000000..0a0f3e4 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440.zip" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.html" new file mode 100644 index 0000000..4e9e642 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.html" @@ -0,0 +1,6 @@ +
FlowTest Demo / TEST
Quality Command Center
项目:FlowTest Demo ▾
环境:TEST ▾
新建运行
⌁
A

Quality Command Center

面向当前发布版本的质量状态、风险、覆盖和建议执行入口

查看发布 v3.0.0-rc.1
运行建议测试
+
Release Risk
24 LOW
较昨日 -8
Impacted APIs
18
16 已覆盖 · 2 未覆盖
Contract Health
97.8%
2 个待验证 Provider
Performance
+6.2%
P95 相对基线
Flaky Tests
3
1 个关键流程
+

当前版本质量趋势

最近 14 次发布
蓝:覆盖率红:失败率最后更新 2 分钟前

Release Gate

4 / 5 通过
关键测试失败
0
Breaking Contract
0
Impact Coverage
92%
P95 Regression
+6.2%
Flaky Critical
1
+

受影响服务

P
Payment Service
9 API · 14 Tests
MEDIUM
I
Invoice Service
6 API · 9 Tests
LOW
R
Report Service
3 API · 4 Tests
LOW

Top Failure Clusters

Token 获取失败
影响 41 个测试
41
Payment timeout
影响 12 个测试
12
独立断言失败
无级联影响
4

建议执行

Impact Based Regression
23 Cases · 7 Workflows · 2 Contracts · 1 Performance Baseline
预计耗时
11m 40s
运行建议集合
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.png" new file mode 100644 index 0000000..ff523e7 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.html" new file mode 100644 index 0000000..8c4b03a --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.html" @@ -0,0 +1,6 @@ +
FlowTest Demo / TEST
Service Catalog
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Service Catalog

统一查看 REST、GraphQL、gRPC、Kafka 与契约、测试和依赖关系

导入 Schema
新增服务
+
搜索服务名称、Owner、Tag...
P
Payment Service
REST 28gRPC 6Kafka 4
GOOD
I
Invoice Service
REST 42Kafka 2
MEDIUM
U
User Service
REST 35GraphQL 12
GOOD
R
Report Service
REST 18
GOOD
+

Payment Service

payments · Owner: Billing Team · production
Health GOOD Risk LOW
Overview
Operations
Schemas
Dependencies
Contracts
Tests
Performance
Changes
Operations
38
REST 28 · gRPC 6 · Kafka 4
Contracts
12
11 verified
Workflows
17
15 active
Cases
84
Coverage 94%
P95
214ms
-3.1% vs baseline
+

Recent Changes

VersionChangeImpactStatus
v88POST /refund 新增 reasonCode9 testscovered
v87PaymentResponse.amount 类型调整4 contractsreview
v86gRPC Pay 增加 trace_id3 workflowscovered

Quality Coverage

Functional
96%
Contract
92%
Negative
78%
Performance
81%
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.png" new file mode 100644 index 0000000..1856cff Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.html" new file mode 100644 index 0000000..46608f1 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.html" @@ -0,0 +1,15 @@ +
FlowTest Demo / TEST
Unified Protocol Workbench
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Unified Protocol Workbench

在同一个工作台调试 REST、GraphQL、gRPC、Kafka 与 WebSocket

保存为 Case
发送 / 执行
+
REST
GraphQL
gRPC
Kafka
WebSocket
PaymentService ▾
Pay (Unary) ▾
grpc-test.internal:9443
mTLS ▾
Request
Metadata
Auth
Binding
Assertions
{ + "order_id": "{{orderId}}", + "amount": 128.50, + "currency": "CNY", + "trace_id": "{{uuid()}}" +}
KeyValueSource
authorizationBearer {{token}}Environment
x-request-id{{uuid()}}Runtime
Response OK
213 ms · 1 message
Message
Headers
Timeline
Assertions
{ + "payment_id": "PAY-20260810-00192", + "status": "SUCCESS", + "order_id": "ORD-883021", + "amount": 128.50, + "processed_at": "2026-08-10T14:22:04Z" +}

Assertions

gRPC Status == OK
Passed
$.status == "SUCCESS"
Passed
response.time < 500 ms
213 ms
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.png" new file mode 100644 index 0000000..acb1078 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.html" new file mode 100644 index 0000000..d97f96c --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.html" @@ -0,0 +1,19 @@ +
FlowTest Demo / TEST
Workflow Studio
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Workflow Studio · BUILD

支付与开票完整业务链路 · Published v18 · Draft v19

BUILDDATAIMPACTRUN
版本 Diff
运行
+
搜索节点...
HTTP Request
GraphQL Request
gRPC Unary
Kafka Produce
Kafka Consume
WebSocket Exchange
Condition
ForEach
SubFlow
Delay
SQL Query
Redis Read
Dataset
Assert
Contract Verify
+
−
+
↶
↷
⌗
+
开始
+
REST
用户登录
POST /auth/login
2 outputs · 3 assertions
+
REST
查询账单
GET /bill/detail
+
CONDITION
存在未缴账单?
$.data.amount > 0
+
FOREACH
遍历收费项
max 20 concurrency 5
+
gRPC
创建支付
PaymentService.Pay
+
KAFKA
等待支付成功
payment-success
+
SUBFLOW
开票流程
invoice-flow@v12
+
结束
+
+

节点配置

REST
Request
Binding
Assertion
用户登录
POST /auth/login ▾
继承 Workflow: TEST
{ + "username": "{{username}}", + "password": "{{password}}" +}
NameExpression
token$.data.token
userId$.data.userId
Fail Fast
保存节点
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.png" new file mode 100644 index 0000000..18d481d Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.html" new file mode 100644 index 0000000..229cedd --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.html" @@ -0,0 +1,5 @@ +
FlowTest Demo / TEST
Workflow Impact & Run
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Workflow Studio · IMPACT / RUN

同一张画布查看变更影响、覆盖缺口与本次执行路径

BUILDDATAIMPACTRUN
运行受影响节点
+
Detected change · PaymentResponse.reasonCode 新增必填字段
影响 5 个节点、2 个 SubFlow、11 条断言
REST
查询账单
Not impacted
gRPC · CHANGED
创建支付
PaymentService.Pay
Changed schema
ASSERT · AFFECTED
支付响应校验
11 assertions
KAFKA · UNCOVERED
等待支付成功
payment-success
Coverage gap
SUBFLOW · AFFECTED
开票流程
invoice-flow@v12
REST
查询发票
Indirect impact
Run Timeline · #982317 / 8 passed
✓
Login
210ms
✓
Bill
183ms
✓
Pay
242ms
!
Kafka
timeout
○
Invoice
+

Impact Summary

62
MEDIUM RISK
1 coverage gap
2 affected contracts
5 affected workflow nodes

Recommended Actions

更新支付响应断言
补充 Kafka reasonCode 校验
重新验证 Invoice Contract

Failure Detail

Kafka Await Timeout
Topic: payment-success
Correlation: order_id = ORD-883021
Timeout: 10s
Received: 0 / 1
重放失败节点
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.png" new file mode 100644 index 0000000..ebce621 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.html" new file mode 100644 index 0000000..1c9b4e2 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.html" @@ -0,0 +1,8 @@ +
FlowTest Demo / TEST
Performance Lab
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Performance Lab

声明式负载模型编译到隔离 k6 Runner,结果可进入 Quality Gate

基线:2026-08-03
开始性能测试
+

Scenario Builder

Payment Critical Workflow ▾
perf-users-10k.csv ▾
00:00 0 → 20 VU 30s +00:30 20 → 100 VU 2m +02:30 100 VU 5m +07:30 100 → 0 VU 1m
Max 120 VU

Thresholds

MetricRuleCurrent
P95< 800 ms612 ms
P99< 1500 ms1.21 s
Error Rate< 1%0.82%
HTTP 5xx< 0.1%0.14%
+
Current VU
100
Stage 3 / 4
RPS
1,248
+5.8%
P95
612ms
+6.2% vs baseline
Error Rate
0.82%
threshold < 1%

Latency & Throughput

RUNNING 67%
蓝:P95 Latency绿:RPSelapsed 05:21 / 08:00

Baseline Comparison

Median
+2.1%
P95
+6.2%
P99
+9.3%
RPS
+5.8%

Status Distribution

99.86%
2xx 4,823,420
4xx 2,131
5xx 6,752
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.png" new file mode 100644 index 0000000..15279c5 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.html" new file mode 100644 index 0000000..e1733a8 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.html" @@ -0,0 +1,5 @@ +
FlowTest Demo / TEST
Contract Hub
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Contract Hub

统一管理 OpenAPI、Pact、Provider Verification 与 Deployment Compatibility

验证 Provider
检查可发布性
+

Consumer / Provider Compatibility Matrix

Safe to Deploy: NO
Consumer
Payment v88
Payment v89
Payment v90
Payment v91 RC
Web v42
✓
✓
✕
✕
Mobile v31
✓
✓
✓
✕
Order v77
✓
✓
✓
✓
阻断原因:Web v42 与 Mobile v31 尚未验证 Payment v91 RC;Web v42 的 `refund.reasonCode` 契约不兼容。

Provider Verification

Order → Payment
verified 3m ago
PASS
Web → Payment
2 interactions failed
FAIL
Mobile → Payment
not verified against v91
PENDING

Contract Health

96.4%
27 contracts · 2 failing · 1 pending
REST / OpenAPI
98%
Pact
92%
+

Breaking Changes

ServiceChangeConsumersRisk
Paymentrefund.reasonCode: optional → requiredWeb, MobileHIGH
InvoiceinvoiceNo maxLength 32 → 24ReportMEDIUM
User新增非必填字段 locale4 consumersSAFE

Deployment Check

NO
Payment v91 RC cannot deploy
2 incompatible consumers
1 pending verification
0 critical provider failures
查看阻断明细
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.png" new file mode 100644 index 0000000..b34447c Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.html" new file mode 100644 index 0000000..5e1c711 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.html" @@ -0,0 +1,7 @@ +
FlowTest Demo / TEST
Change Impact Analysis
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Change Impact Analysis

从 Git / OpenAPI / Proto / GraphQL Diff 推导受影响资产与 Smart Test Selection

重新分析
运行推荐测试
+

1. Changes

8 changed
POST /invoice/open
sellerTaxNo → required
PaymentService.Pay
reason_code added
GET /user/info
locale added (optional)
Before
sellerTaxNo?: string
reasonCode?: string
After
sellerTaxNo: string
reasonCode: string
+

2. Impacted

34 assets
W
Invoice Full Flow
Workflow · direct
HIGH
C
Invoice Negative Cases
Case Suite · direct
MEDIUM
P
Web → Payment
Pact · direct
HIGH
R
Report Regression
Workflow · indirect
MEDIUM
Explainability
Report Regression 被选中,因为 Invoice Service → Report Service 存在 REST 依赖,且该 Workflow 包含 `invoiceNo` 映射。
+

3. Recommended Tests

92% impact coverage
TypeCountEst.
☑Cases232m 10s
☑Workflows75m 40s
☑Contracts248s
☑Perf Baseline18m
Selection Strategy
FullImpact BasedCritical
Estimated total
11m 40s
相比 Full Regression 节省 72%
运行 33 个测试资产
+

Coverage Gap

Changed Operation
Functional
Contract
Negative
Performance
POST /invoice/open
100%
75%
40%
60%
PaymentService.Pay
100%
100%
70%
100%
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.png" new file mode 100644 index 0000000..52ec6f7 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.html" new file mode 100644 index 0000000..6270d25 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.html" @@ -0,0 +1,6 @@ +
FlowTest Demo / TEST
Quality Insights
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Quality Insights

统一分析覆盖、失败、Flaky、契约、性能与 Release Risk 趋势

最近 30 天 ▾
所有服务 ▾
+
Quality Score
91.8
+2.4
Release Risk
24
LOW
Coverage
89.6%
+3.1%
Flaky
3
1 critical
Contract Health
97.8%
last 30d
+

Quality Trend

蓝:Coverage橙:Release Risk

Coverage by Service

Service
Functional
Contract
Negative
Perf
Payment
96%
92%
78%
81%
Invoice
93%
76%
48%
62%
Report
90%
88%
70%
35%
+

Failure Clusters

Auth token failure
41 cascading failures
41
Payment timeout
12 affected tests
12
Schema mismatch
8 assertions
8

High Risk Services

I
Invoice
Risk 68 · Negative gap
HIGH
P
Payment
Risk 44 · Perf regression
MEDIUM
R
Report
Risk 31
LOW

Untested Changes

2 API changes lack Negative coverage
POST /invoice/open
POST /invoice/red
1 service lacks Performance baseline
Report Service
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.png" new file mode 100644 index 0000000..52771d4 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.html" new file mode 100644 index 0000000..580498a --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.html" @@ -0,0 +1,5 @@ +
FlowTest Demo / TEST
AI Change Set Review
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

AI Quality Copilot · Change Set Review

AI 只生成 Draft Change Set;发布、执行和权限变更仍由人工控制

Model: quality-gateway / gpt-compatible
+

Schema Change Detected

Draft
POST /invoice/open
sellerTaxNo 从 optional 变为 required
AI 建议
新增 Negative Cases
+3
更新 Existing Cases
~6
更新 Workflow Bindings
~2
新增 Assertion
+1

AI Audit

Prompt Template: schema-maintenance@v4
Input Hash: sha256: 6bbf...21af
Samples: Disabled
Secrets Sent: 0
Tokens: 4,238
Generated: 2026-08-10 21:42
+

Change Set #AI-2088

Cases (9)
Workflows (2)
Assertions (1)
Case: 开票 - sellerTaxNo 缺失
New Negative Case
Current
无此测试用例
Proposed Draft
POST /invoice/open
remove $.sellerTaxNo
assert $.code == "PARAM_REQUIRED"
assert HTTP 400
Case: 企业开票成功
Update Existing Case
Current
"companyName": "FlowTest Ltd"
"taxNo": "9144..."
Proposed Draft
"companyName": "FlowTest Ltd"
"taxNo": "9144..."
"sellerTaxNo": "91440300..."
安全边界:接受后只生成 Draft Version,不修改 Published Case,不创建 Credential,不自动运行测试。
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.png" new file mode 100644 index 0000000..c18bb9a Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.html" new file mode 100644 index 0000000..4ec108a --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.html" @@ -0,0 +1,5 @@ +
FlowTest Demo / TEST
Execution Fabric
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Execution Fabric

管理 Worker Pool、Runner Capability、队列、Lease/Fencing 和动态容量

调度策略
注册 Worker
+
General
4 / 4
42 running
Protocol
3 / 3
17 running
Data
2 / 2
8 running
Performance
2 / 2
2 running
Environment
1 / 2
1 draining
AI
1 / 1
3 queued
+

Workers

搜索 Worker / Pool...
WorkerPoolCapabilitiesCPUMemoryRunningQueueHeartbeatStatus
worker-07Protocolgrpc,kafka,ws42%61%13422sREADY
worker-08Protocolgrpc,kafka,ws51%58%15421sREADY
worker-11Performancek678%66%142sREADY
worker-15Environmentcompose,testcontainers22%44%001sDRAINING

Queue Throughput

last 60 min

worker-07

v3.0.0-rc.1 · zone: intranet-a
CPU
42%
Memory
61%
Slots
13/20
grpc.unary kafka.consume websocket.exchange
Drain Worker

Lease / Fencing

Active Leases
61
Recovered (24h)
3
Duplicate Final State
0
Expired Fence Rejects
7
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.png" new file mode 100644 index 0000000..e54877a Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.html" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.html" new file mode 100644 index 0000000..501fbf0 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.html" @@ -0,0 +1,5 @@ +
FlowTest Demo / TEST
Environment Lab
项目:FlowTest Demo ▾
环境:TEST ▾
⌁
A

Environment Lab

基于受控 Template 创建临时测试环境,并自动 Seed、Health Check、TTL 销毁

Template 管理
创建临时环境
+

Environment Templates

B
Billing Integration Stack
PostgreSQL 17 · Redis 8 · Mock · billing-api
TTL 2hSeed v12Health 4 checks
P
Payment Contract Stack
PostgreSQL 17 · Kafka · Mock Provider
TTL 1hPact
R
Report Regression Stack
MySQL 8 · Redis · report-api
TTL 3hDataset seed

Active Environment · env-8129

READY
✓
Provision
✓
Health
✓
Seed
4
Run
5
Destroy
ComponentEndpointStatus
billing-apihttp://172.20.8.14:8080healthy
PostgreSQL172.20.8.15:5432healthy
Redis172.20.8.16:6379healthy
Mockhttp://172.20.8.17:8090healthy
TTL:剩余 01:37:12,运行中的 Test Plan 完成后可自动销毁。
+

Recent Instances

IDTemplateOwnerDurationStatus
env-8129Billing IntegrationAdmin23mREADY
env-8126Payment ContractCI51mDESTROYED
env-8121Report RegressionTester1h 08mDESTROYED

Safety Controls

仅管理员注册 Template
Enabled
Network CIDR Allowlist
Enabled
Credential 隔离
Enabled
失败自动清理
Enabled
\ No newline at end of file diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.png" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.png" new file mode 100644 index 0000000..3df1c00 Binary files /dev/null and "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.png" differ diff --git "a/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/README.md" "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/README.md" new file mode 100644 index 0000000..7bad472 --- /dev/null +++ "b/FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/README.md" @@ -0,0 +1,39 @@ +# FlowTest V3.0 高清 UI 模块设计图 + +分辨率:2560×1440;每张图仅展示一个核心模块。 + +## 01. 01_quality_command_center +![](01_quality_command_center.png) + +## 02. 02_service_catalog +![](02_service_catalog.png) + +## 03. 03_protocol_workbench +![](03_protocol_workbench.png) + +## 04. 04_workflow_studio_build +![](04_workflow_studio_build.png) + +## 05. 05_workflow_impact_run +![](05_workflow_impact_run.png) + +## 06. 06_performance_lab +![](06_performance_lab.png) + +## 07. 07_contract_hub +![](07_contract_hub.png) + +## 08. 08_change_impact_analysis +![](08_change_impact_analysis.png) + +## 09. 09_quality_insights +![](09_quality_insights.png) + +## 10. 10_ai_change_set_review +![](10_ai_change_set_review.png) + +## 11. 11_execution_fabric +![](11_execution_fabric.png) + +## 12. 12_environment_lab +![](12_environment_lab.png) diff --git a/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj b/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj index 4bdc143..8253a25 100644 --- a/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj +++ b/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj @@ -36,6 +36,15 @@ A10000000000000000000027 /* PreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000030 /* PreferencesStore.swift */; }; A10000000000000000000028 /* AlbumArtReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000031 /* AlbumArtReceiver.swift */; }; A10000000000000000000029 /* SelfHealingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000032 /* SelfHealingEngine.swift */; }; + A10000000000000000000033 /* ArtworkImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000033 /* ArtworkImageCache.swift */; }; + A10000000000000000000034 /* LastNowPlayingSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000034 /* LastNowPlayingSnapshotStore.swift */; }; + A10000000000000000000035 /* MediaLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000035 /* MediaLoadingState.swift */; }; + A10000000000000000000036 /* BLEObservableStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000036 /* BLEObservableStateModels.swift */; }; + A10000000000000000000037 /* BLEBinaryChunkCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000037 /* BLEBinaryChunkCodec.swift */; }; + A10000000000000000000038 /* FullLyricsCacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000038 /* FullLyricsCacheStore.swift */; }; + A10000000000000000000039 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000039 /* Localizable.xcstrings */; }; + A10000000000000000000040 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000040 /* InfoPlist.xcstrings */; }; + A10000000000000000000041 /* PlayerProductUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000041 /* PlayerProductUI.swift */; }; B10000000000000000000001 /* SonyMusicActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* SonyMusicActivityAttributes.swift */; }; B10000000000000000000002 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* LiveActivityManager.swift */; }; B10000000000000000000003 /* SonyMusicLiveActivityExtensionBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* SonyMusicLiveActivityExtensionBundle.swift */; }; @@ -49,6 +58,8 @@ B10000000000000000000011 /* PlayPauseLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* PlayPauseLiveActivityIntent.swift */; }; B10000000000000000000012 /* PreviousLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* PreviousLiveActivityIntent.swift */; }; B10000000000000000000013 /* NextLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000018 /* NextLiveActivityIntent.swift */; }; + B10000000000000000000014 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000039 /* Localizable.xcstrings */; }; + C10000000000000000000001 /* PerformanceStabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* PerformanceStabilityTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -59,6 +70,13 @@ remoteGlobalIDString = B50000000000000000000001; remoteInfo = SonyMusicLiveActivityExtension; }; + C80000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A60000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = sonyMusic; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -106,8 +124,17 @@ A20000000000000000000028 /* AppExperienceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExperienceMode.swift; sourceTree = ""; }; A20000000000000000000029 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; A20000000000000000000030 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = ""; }; + A20000000000000000000041 /* PlayerProductUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProductUI.swift; sourceTree = ""; }; A20000000000000000000031 /* AlbumArtReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumArtReceiver.swift; sourceTree = ""; }; A20000000000000000000032 /* SelfHealingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfHealingEngine.swift; sourceTree = ""; }; + A20000000000000000000033 /* ArtworkImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtworkImageCache.swift; sourceTree = ""; }; + A20000000000000000000034 /* LastNowPlayingSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastNowPlayingSnapshotStore.swift; sourceTree = ""; }; + A20000000000000000000035 /* MediaLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoadingState.swift; sourceTree = ""; }; + A20000000000000000000036 /* BLEObservableStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEObservableStateModels.swift; sourceTree = ""; }; + A20000000000000000000037 /* BLEBinaryChunkCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEBinaryChunkCodec.swift; sourceTree = ""; }; + A20000000000000000000038 /* FullLyricsCacheStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullLyricsCacheStore.swift; sourceTree = ""; }; + A20000000000000000000039 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + A20000000000000000000040 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; B20000000000000000000001 /* SonyMusicActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonyMusicActivityAttributes.swift; sourceTree = ""; }; B20000000000000000000002 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; B20000000000000000000004 /* SonyMusicLiveActivityExtensionBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonyMusicLiveActivityExtensionBundle.swift; sourceTree = ""; }; @@ -116,6 +143,8 @@ B20000000000000000000007 /* SonyMusicLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SonyMusicLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000008 /* LiveActivityArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityArtworkView.swift; sourceTree = ""; }; B20000000000000000000009 /* SonyMusicLiveActivityExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SonyMusicLiveActivityExtension.entitlements; sourceTree = ""; }; + C20000000000000000000001 /* PerformanceStabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceStabilityTests.swift; sourceTree = ""; }; + C20000000000000000000002 /* IOSBleFeasibilityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IOSBleFeasibilityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -133,6 +162,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C30000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -141,6 +177,7 @@ children = ( A40000000000000000000002 /* IOSBleFeasibility */, B40000000000000000000001 /* SonyMusicLiveActivityExtension */, + C40000000000000000000001 /* IOSBleFeasibilityTests */, A40000000000000000000003 /* Products */, ); sourceTree = ""; @@ -150,9 +187,18 @@ children = ( A20000000000000000000001 /* IOSBleFeasibilityApp.swift */, A20000000000000000000002 /* ContentView.swift */, + A20000000000000000000041 /* PlayerProductUI.swift */, A20000000000000000000008 /* FullLyricsView.swift */, A20000000000000000000009 /* DebugToolsView.swift */, A20000000000000000000031 /* AlbumArtReceiver.swift */, + A20000000000000000000033 /* ArtworkImageCache.swift */, + A20000000000000000000034 /* LastNowPlayingSnapshotStore.swift */, + A20000000000000000000035 /* MediaLoadingState.swift */, + A20000000000000000000036 /* BLEObservableStateModels.swift */, + A20000000000000000000037 /* BLEBinaryChunkCodec.swift */, + A20000000000000000000038 /* FullLyricsCacheStore.swift */, + A20000000000000000000039 /* Localizable.xcstrings */, + A20000000000000000000040 /* InfoPlist.xcstrings */, A20000000000000000000032 /* SelfHealingEngine.swift */, B20000000000000000000001 /* SonyMusicActivityAttributes.swift */, B20000000000000000000002 /* LiveActivityManager.swift */, @@ -190,6 +236,7 @@ children = ( A20000000000000000000006 /* sonyMusic.app */, B20000000000000000000007 /* SonyMusicLiveActivityExtension.appex */, + C20000000000000000000002 /* IOSBleFeasibilityTests.xctest */, ); name = Products; sourceTree = ""; @@ -206,6 +253,14 @@ path = SonyMusicLiveActivityExtension; sourceTree = ""; }; + C40000000000000000000001 /* IOSBleFeasibilityTests */ = { + isa = PBXGroup; + children = ( + C20000000000000000000001 /* PerformanceStabilityTests.swift */, + ); + path = IOSBleFeasibilityTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -245,6 +300,24 @@ productReference = B20000000000000000000007 /* SonyMusicLiveActivityExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + C50000000000000000000001 /* IOSBleFeasibilityTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C90000000000000000000001 /* Build configuration list for PBXNativeTarget "IOSBleFeasibilityTests" */; + buildPhases = ( + C30000000000000000000002 /* Sources */, + C30000000000000000000001 /* Frameworks */, + C30000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C80000000000000000000002 /* PBXTargetDependency */, + ); + name = IOSBleFeasibilityTests; + productName = IOSBleFeasibilityTests; + productReference = C20000000000000000000002 /* IOSBleFeasibilityTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -271,6 +344,10 @@ }; }; }; + C50000000000000000000001 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = A50000000000000000000001; + }; }; }; buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXProject "IOSBleFeasibility" */; @@ -279,6 +356,7 @@ hasScannedForEncodings = 0; knownRegions = ( en, + "zh-Hans", Base, ); mainGroup = A40000000000000000000001; @@ -288,6 +366,7 @@ targets = ( A50000000000000000000001 /* sonyMusic */, B50000000000000000000001 /* SonyMusicLiveActivityExtension */, + C50000000000000000000001 /* IOSBleFeasibilityTests */, ); }; /* End PBXProject section */ @@ -298,10 +377,20 @@ buildActionMask = 2147483647; files = ( A10000000000000000000005 /* Assets.xcassets in Resources */, + A10000000000000000000039 /* Localizable.xcstrings in Resources */, + A10000000000000000000040 /* InfoPlist.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; B30000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000014 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C30000000000000000000003 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -317,6 +406,7 @@ files = ( A10000000000000000000001 /* IOSBleFeasibilityApp.swift in Sources */, A10000000000000000000002 /* ContentView.swift in Sources */, + A10000000000000000000041 /* PlayerProductUI.swift in Sources */, A10000000000000000000006 /* FullLyricsView.swift in Sources */, A10000000000000000000007 /* DebugToolsView.swift in Sources */, B10000000000000000000001 /* SonyMusicActivityAttributes.swift in Sources */, @@ -334,6 +424,12 @@ A10000000000000000000026 /* PreferencesView.swift in Sources */, A10000000000000000000027 /* PreferencesStore.swift in Sources */, A10000000000000000000028 /* AlbumArtReceiver.swift in Sources */, + A10000000000000000000033 /* ArtworkImageCache.swift in Sources */, + A10000000000000000000034 /* LastNowPlayingSnapshotStore.swift in Sources */, + A10000000000000000000035 /* MediaLoadingState.swift in Sources */, + A10000000000000000000036 /* BLEObservableStateModels.swift in Sources */, + A10000000000000000000037 /* BLEBinaryChunkCodec.swift in Sources */, + A10000000000000000000038 /* FullLyricsCacheStore.swift in Sources */, A10000000000000000000029 /* SelfHealingEngine.swift in Sources */, A10000000000000000000019 /* LyricDisplayMode.swift in Sources */, A10000000000000000000021 /* LyricDiagnostic.swift in Sources */, @@ -365,6 +461,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C30000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000001 /* PerformanceStabilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -373,6 +477,11 @@ target = B50000000000000000000001 /* SonyMusicLiveActivityExtension */; targetProxy = B80000000000000000000001 /* PBXContainerItemProxy */; }; + C80000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* sonyMusic */; + targetProxy = C80000000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -482,6 +591,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -510,6 +620,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -537,6 +648,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -566,11 +678,50 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; + C70000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 98K7KHA6Z7; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sqz.IOSBleFeasibilityTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sonyMusic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sonyMusic"; + }; + name = Debug; + }; + C70000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 98K7KHA6Z7; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sqz.IOSBleFeasibilityTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sonyMusic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sonyMusic"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -601,6 +752,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C90000000000000000000001 /* Build configuration list for PBXNativeTarget "IOSBleFeasibilityTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C70000000000000000000001 /* Debug */, + C70000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = A60000000000000000000001 /* Project object */; diff --git a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift index e331162..4c2c071 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift @@ -12,6 +12,7 @@ private let ALBUM_ART_TRANSIENT_SOURCE_TTL_MS: Int64 = 30 * 60 * 1_000 private let ALBUM_ART_STABLE_SOURCE_TTL_MS: Int64 = 24 * 60 * 60 * 1_000 private let ALBUM_ART_HARD_CACHE_TTL_MS: Int64 = 30 * 24 * 60 * 60 * 1_000 private let ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT = 300 +private let ALBUM_ART_PROVISIONAL_REFRESH_DELAYS: [TimeInterval] = [0.05, 2, 5, 12] struct AlbumArtSnapshot { let id: String @@ -84,12 +85,13 @@ private struct AlbumArtCacheValidation { return expired || source == "notificationLargeIcon" || source == "unknown" || + reason == "low_information_requires_refresh" || pixelWidth <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT || pixelHeight <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT } var shouldDisplayWhileRefreshing: Bool { - valid + valid && reason != "low_information_requires_refresh" } } @@ -115,6 +117,116 @@ extension UIImage { } } +enum AlbumArtPlaceholderDisposition: Equatable { + case normal + case provisional +} + +enum AlbumArtPlaceholderPolicy { + static func disposition( + image: UIImage, + dataSize: Int + ) -> AlbumArtPlaceholderDisposition { + isLowInformation(image, dataSize: dataSize) || + isKnownQQMusicFallback(image) + ? .provisional + : .normal + } + + static func isLowInformation(_ image: UIImage, dataSize: Int) -> Bool { + guard dataSize < 1_800, let stats = visualStats(image) else { + return false + } + return stats.visiblePixels > 0 && + stats.colorBuckets <= 10 && + stats.colorfulPixels * 20 <= stats.visiblePixels + } + + private static func isKnownQQMusicFallback(_ image: UIImage) -> Bool { + let width = image.pixelWidth + let height = image.pixelHeight + guard (200...240).contains(width), + (200...240).contains(height), + abs(width - height) <= max(width, height) / 20, + let stats = visualStats(image), + stats.visiblePixels > 0 else { + return false + } + // JPEG color conversion differs slightly between simulator and device. + // Keep the dominant-tone guard strict while allowing anti-aliased gray + // edges to span a few neighboring quantized buckets. + return stats.colorBuckets <= 8 && + stats.colorfulPixels * 100 <= stats.visiblePixels && + stats.dominantBucketPixels * 100 >= stats.visiblePixels * 70 + } + + private static func visualStats(_ image: UIImage) -> VisualStats? { + guard let cgImage = image.cgImage else { + return nil + } + let width = 24 + let height = 24 + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + context.interpolationQuality = .low + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + var buckets: [Int: Int] = [:] + var colorfulPixels = 0 + var visiblePixels = 0 + stride(from: 0, to: pixels.count, by: 4).forEach { index in + let red = Int(pixels[index]) + let green = Int(pixels[index + 1]) + let blue = Int(pixels[index + 2]) + let alpha = Int(pixels[index + 3]) + guard alpha >= 24 else { return } + let maximum = max(red, max(green, blue)) + let minimum = min(red, min(green, blue)) + if maximum > 0 && Double(maximum - minimum) / Double(maximum) > 0.12 { + colorfulPixels += 1 + } + let bucket = ((red / 32) << 10) | ((green / 32) << 5) | (blue / 32) + buckets[bucket, default: 0] += 1 + visiblePixels += 1 + } + + return VisualStats( + colorBuckets: buckets.count, + colorfulPixels: colorfulPixels, + visiblePixels: visiblePixels, + dominantBucketPixels: buckets.values.max() ?? 0 + ) + } + + private struct VisualStats { + let colorBuckets: Int + let colorfulPixels: Int + let visiblePixels: Int + let dominantBucketPixels: Int + } +} + +enum AlbumArtSourceRefreshPolicy { + static func needsRefresh( + cacheRequiresRefresh: Bool, + refreshAttempted: Bool, + refreshInFlight: Bool + ) -> Bool { + cacheRequiresRefresh && (!refreshAttempted || refreshInFlight) + } +} + protocol AlbumArtReceiverDelegate: AnyObject { var albumArtCurrentTrackID: String { get } var albumArtCurrentTitle: String { get } @@ -160,6 +272,7 @@ final class AlbumArtReceiver { private var binaryAlbumArtTransferID = "" private var binaryAlbumArtExpectedCRC32: UInt32? private var binaryAlbumArtGeneration: Int64? + private var albumArtDecodeToken: UUID? private var binaryAlbumArtRetryCounts: [String: Int] = [:] private var binaryAlbumArtChunks: [Int: Data] = [:] private var binaryAlbumArtLastPublishedProgressBucket = -1 @@ -178,6 +291,9 @@ final class AlbumArtReceiver { private var albumArtRequestStartTimeouts: [String: DispatchWorkItem] = [:] private var albumArtRequestStartRetryCounts: [String: Int] = [:] private var requestedHqAlbumArtIDs: Set = [] + private var forceRefreshAlbumArtIDs: Set = [] + private var provisionalRefreshAttempts: [String: Int] = [:] + private var provisionalRefreshWorkItem: DispatchWorkItem? private var hqAlbumArtWorkItem: DispatchWorkItem? private var hqAlbumArtUnavailableReason = "-" private var hqAlbumArtUnavailableBestBytes = 0 @@ -205,6 +321,8 @@ final class AlbumArtReceiver { private var predictiveOfferTimes: [String: Date] = [:] private var predictiveHqRequestTimes: [String: Date] = [:] private var sourceRefreshAttemptedAlbumArtIDs: Set = [] + private var sourceRefreshInFlightAlbumArtIDs: Set = [] + private var albumArtCacheLookupToken: UUID? private let albumArtCacheWriteQueue = DispatchQueue( label: "com.sqz.IOSBleFeasibility.albumArtCacheWrite", qos: .utility @@ -256,34 +374,47 @@ final class AlbumArtReceiver { log("[AlbumArtOffer] id=\(id) currentTitle=\(delegate?.albumArtCurrentTitle ?? "-")") recordPredictiveOffer(id: id) handleAlbumArtIdentity(id) - let hqValidation = validateAlbumArtCache(id: id, preferredQuality: "hq") - let previewValidation = validateAlbumArtCache(id: id, preferredQuality: "preview") + loadAlbumArtCacheValidations(id: id) { [weak self] hqValidation, previewValidation, _ in + guard let self else { return } + self.applyAlbumArtOffer( + id: id, + hqValidation: hqValidation, + previewValidation: previewValidation + ) + } + } + + private func applyAlbumArtOffer( + id: String, + hqValidation: AlbumArtCacheValidation, + previewValidation: AlbumArtCacheValidation + ) { + guard currentAlbumArtID == id, isExpectedAlbumArtPayload(id: id) else { + log("[AlbumArt-iOS] stale cache lookup discarded id=\(id)") + return + } let sourceRefreshAlreadyAttempted = sourceRefreshAttemptedAlbumArtIDs.contains(id) - let hqNeedsRefresh = hqValidation.shouldRefreshOnOffer && !sourceRefreshAlreadyAttempted - let previewNeedsRefresh = previewValidation.shouldRefreshOnOffer && !sourceRefreshAlreadyAttempted + let sourceRefreshInFlight = sourceRefreshInFlightAlbumArtIDs.contains(id) + let hqNeedsRefresh = AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: hqValidation.shouldRefreshOnOffer, + refreshAttempted: sourceRefreshAlreadyAttempted, + refreshInFlight: sourceRefreshInFlight + ) + let previewNeedsRefresh = AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: previewValidation.shouldRefreshOnOffer, + refreshAttempted: sourceRefreshAlreadyAttempted, + refreshInFlight: sourceRefreshInFlight + ) - if let cached = cachedEnhancedAlbumArt(id: id), + if let image = hqValidation.image, hqValidation.valid, !hqNeedsRefresh { cancelAlbumArtFallback() cancelHqAlbumArtRequest() recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) - setAlbumArtDisplay(image: cached.image, id: id, quality: .enhanced, reason: "offer cacheHit") - delegate?.albumArtPublishLiveArtwork(image: cached.image, key: id, reason: "enhancedCacheHit") - let message = "[AlbumArtCache] display quality=enhanced id=\(id), skip hq request" - log(message) - consoleLog(message) - log("[AlbumArtRefresh] skip reason=fresh_cache id=\(id) source=\(hqValidation.source)") - delegate?.albumArtSendCommand(cmd: "ALBUM_ART_SKIP", extra: ["id": id, "quality": "hq"]) - } else if let image = hqValidation.image, - hqValidation.valid, - !hqNeedsRefresh { - cancelAlbumArtFallback() - cancelHqAlbumArtRequest() - recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) setAlbumArtDisplay(image: image, id: id, quality: .hq, reason: "offer cacheHit") delegate?.albumArtPublishLiveArtwork(image: image, key: id, reason: "cacheHit") - startArtworkEnhancementFromCachedHq(id: id, reason: "hq cacheHit") + loadEnhancedAlbumArtIfAvailable(id: id, reason: "offer cacheHit") let message = "[AlbumArtCache] display quality=hq id=\(id), enhance from cache" log(message) consoleLog(message) @@ -344,14 +475,17 @@ final class AlbumArtReceiver { } cancelAlbumArtFallback() cancelHqAlbumArtRequest() + cancelProvisionalAlbumArtRefresh() albumArtRequestStartTimeouts.values.forEach { $0.cancel() } albumArtRequestStartTimeouts.removeAll() albumArtRequestStartRetryCounts.removeAll() requestedAlbumArtKeys.removeAll() requestedHqAlbumArtIDs.removeAll() + forceRefreshAlbumArtIDs.removeAll() albumArtPreviewRetryCounts.removeAll() albumArtHqRetryCounts.removeAll() sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() authoritativeAlbumArtID = "" deferredAlbumArtOfferWorkItem?.cancel() deferredAlbumArtOfferWorkItem = nil @@ -475,16 +609,15 @@ final class AlbumArtReceiver { } func handleBinaryChunk(_ data: Data) { - guard data.count > 6 else { + guard let chunk = BLEBinaryChunkCodec.decode(data, expectedMagic: 0xA1) else { let message = "[AlbumArtBinary] invalid chunk" log(message) consoleLog(message) return } - let bytes = [UInt8](data.prefix(6)) - let qualityCode = bytes[1] - let index = (Int(bytes[2]) << 8) | Int(bytes[3]) - let totalChunks = (Int(bytes[4]) << 8) | Int(bytes[5]) + let qualityCode = chunk.kindCode + let index = chunk.index + let totalChunks = chunk.total let quality: String switch qualityCode { case 2: @@ -504,7 +637,7 @@ final class AlbumArtReceiver { consoleLog(message) return } - let payload = data.subdata(in: 6.. Bool { + func requestCurrentPreviewAlbumArt(forceRefresh: Bool = false) -> Bool { let id = currentAlbumArtID guard !id.isEmpty else { log("[AlbumArt] preview request skipped reason=no artwork id") @@ -634,12 +774,12 @@ final class AlbumArtReceiver { ) return false } - requestAlbumArt(id: id, quality: "preview") + requestAlbumArt(id: id, quality: "preview", forceRefresh: forceRefresh) return true } @discardableResult - func requestCurrentHqAlbumArt() -> Bool { + func requestCurrentHqAlbumArt(forceRefresh: Bool = false) -> Bool { let id = currentAlbumArtID guard !id.isEmpty else { log("[NowDiag] hq artwork request skipped reason=no artwork id") @@ -675,7 +815,7 @@ final class AlbumArtReceiver { requestedHqAlbumArtIDs.remove(id) requestedAlbumArtKeys.remove("\(id)|hq") log("[NowDiag] request HQ artwork id=\(id)") - requestHqAlbumArt(id: id) + requestHqAlbumArt(id: id, forceRefresh: forceRefresh) return true } @@ -687,8 +827,6 @@ final class AlbumArtReceiver { return false } log("[AlbumArtRefresh] force refresh id=\(id)") - removeCachedAlbumArt(id: id, quality: "preview", reason: "force refresh") - removeCachedAlbumArt(id: id, quality: "hq", reason: "force refresh") ArtworkEnhancementManager.shared.cancel(artworkId: id) ArtworkEnhancementManager.shared.clearCachedArtwork(artworkId: id) { [weak self] in self?.log("[ArtworkEnhance] cache removed id=\(id) reason=force refresh") @@ -696,20 +834,26 @@ final class AlbumArtReceiver { requestedAlbumArtKeys.remove("\(id)|preview") requestedAlbumArtKeys.remove("\(id)|hq") requestedHqAlbumArtIDs.remove(id) + forceRefreshAlbumArtIDs.insert(id) + cancelProvisionalAlbumArtRefresh(clearAttemptsFor: id) + albumArtRequestStartRetryCounts.removeValue(forKey: "\(id)|preview") + albumArtRequestStartRetryCounts.removeValue(forKey: "\(id)|hq") cancelHqAlbumArtRequest() cancelAlbumArtFallback() - if artworkDisplayQuality != .placeholder { - artworkDisplayQuality = .placeholder - albumArtImage = nil - displayedAlbumArtID = "" - currentCachedAlbumArtQuality = "" - updateArtworkEnhancementStatus(message: "force refresh") - delegate?.albumArtClearLiveArtwork(reason: "forceRefresh", shouldUpdate: false) - notifyStateChanged() - } - let requested = requestCurrentPreviewAlbumArt() + updateArtworkEnhancementStatus(message: "force refresh pending") + log( + "[AlbumArtRefresh] retain current artwork while refreshing " + + "quality=\(artworkDisplayQuality.label)" + ) + notifyStateChanged() + let requested = requestCurrentPreviewAlbumArt(forceRefresh: true) if requested { - schedulePredictiveHqPrefetch(id: id, delay: 2.2, reason: "force refresh") + schedulePredictiveHqPrefetch( + id: id, + delay: 2.2, + reason: "force refresh", + forceRefresh: true + ) } return requested } @@ -801,7 +945,11 @@ final class AlbumArtReceiver { binaryAlbumArtRetryCounts.removeAll() requestedAlbumArtKeys.removeAll() requestedHqAlbumArtIDs.removeAll() + forceRefreshAlbumArtIDs.removeAll() + sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() cancelHqAlbumArtRequest() + cancelProvisionalAlbumArtRefresh() predictivePendingHqId = "" notifyStateChanged() } @@ -828,6 +976,12 @@ final class AlbumArtReceiver { ) } + /// Hot-path state used by the main player UI. Unlike `snapshot()`, this + /// never inspects artwork files or metadata on disk. + func transferSnapshot() -> AlbumArtTransferDiagnosticSnapshot { + makeAlbumArtTransferDiagnosticSnapshot() + } + private func finishAlbumArtTransfer(id: String, quality: String) { let missingIndexes = (0.. 0, reason != "reset" { let message = "[AlbumArt-iOS] cancel in-flight reason=\(reason)" @@ -1346,8 +1572,19 @@ final class AlbumArtReceiver { count > 0 ? total / count : 0 } - private func schedulePredictiveHqPrefetch(id: String, delay: TimeInterval, reason: String) { - scheduleHqAlbumArtRequest(id: id, delay: delay, reason: reason, predictive: true) + private func schedulePredictiveHqPrefetch( + id: String, + delay: TimeInterval, + reason: String, + forceRefresh: Bool = false + ) { + scheduleHqAlbumArtRequest( + id: id, + delay: delay, + reason: reason, + predictive: true, + forceRefresh: forceRefresh + ) } private enum PredictiveHqSkipCategory { @@ -1459,10 +1696,11 @@ final class AlbumArtReceiver { id: String, delay: TimeInterval = 1.8, reason: String = "standard", - predictive: Bool = false + predictive: Bool = false, + forceRefresh: Bool = false ) { guard id == currentAlbumArtID else { return } - guard artworkDisplayQuality < .hq else { + guard forceRefresh || artworkDisplayQuality < .hq else { if predictive { recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) } @@ -1482,10 +1720,16 @@ final class AlbumArtReceiver { } cancelHqAlbumArtRequest() let timing = predictive ? nil : delegate?.albumArtEffectiveHqDelay(delay) - let effectiveDelay = timing?.delay ?? delay + let baseDelay = timing?.delay ?? delay + let effectiveDelay = baseDelay * PreferencesStore.shared.playbackPerformanceMode.hqDelayMultiplier let deferred = timing?.deferred ?? false let workItem = DispatchWorkItem { [weak self] in - self?.requestHqAlbumArt(id: id, reason: reason, predictive: predictive) + self?.requestHqAlbumArt( + id: id, + reason: reason, + predictive: predictive, + forceRefresh: forceRefresh + ) } hqAlbumArtWorkItem = workItem log("[AlbumArtHQ] scheduled id=\(id) delayMs=\(Int(effectiveDelay * 1_000))") @@ -1509,9 +1753,56 @@ final class AlbumArtReceiver { hqAlbumArtWorkItem = nil } - private func requestHqAlbumArt(id: String, reason: String = "standard", predictive: Bool = false) { + private func scheduleProvisionalAlbumArtRefresh(id: String) { + guard id == currentAlbumArtID else { return } + let attempt = provisionalRefreshAttempts[id] ?? 0 + guard attempt < ALBUM_ART_PROVISIONAL_REFRESH_DELAYS.count else { + log("[AlbumArtRefresh] provisional retries exhausted id=\(id)") + return + } + provisionalRefreshAttempts[id] = attempt + 1 + provisionalRefreshWorkItem?.cancel() + let delay = ALBUM_ART_PROVISIONAL_REFRESH_DELAYS[attempt] + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.currentAlbumArtID == id else { return } + self.requestedAlbumArtKeys.remove("\(id)|hq") + self.requestedHqAlbumArtIDs.remove(id) + self.forceRefreshAlbumArtIDs.insert(id) + let requested = self.requestCurrentHqAlbumArt(forceRefresh: true) + self.log( + "[AlbumArtRefresh] provisional retry id=\(id) " + + "attempt=\(attempt + 1) requested=\(requested)" + ) + if !requested { + self.scheduleProvisionalAlbumArtRefresh(id: id) + } + } + provisionalRefreshWorkItem = workItem + log( + "[AlbumArtRefresh] provisional retry scheduled id=\(id) " + + "attempt=\(attempt + 1) delayMs=\(Int(delay * 1_000))" + ) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func cancelProvisionalAlbumArtRefresh(clearAttemptsFor id: String? = nil) { + provisionalRefreshWorkItem?.cancel() + provisionalRefreshWorkItem = nil + if let id { + provisionalRefreshAttempts.removeValue(forKey: id) + } else { + provisionalRefreshAttempts.removeAll() + } + } + + private func requestHqAlbumArt( + id: String, + reason: String = "standard", + predictive: Bool = false, + forceRefresh: Bool = false + ) { guard id == currentAlbumArtID else { return } - guard artworkDisplayQuality < .hq else { + guard forceRefresh || artworkDisplayQuality < .hq else { if predictive { recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) } @@ -1539,7 +1830,13 @@ final class AlbumArtReceiver { if predictive { recordPredictiveHqSkip(id: id, reason: "in flight", category: .inFlight) } - scheduleHqAlbumArtRequest(id: id, delay: 1.0, reason: "busy retry", predictive: predictive) + scheduleHqAlbumArtRequest( + id: id, + delay: 1.0, + reason: "busy retry", + predictive: predictive, + forceRefresh: forceRefresh + ) return } guard requestedHqAlbumArtIDs.insert(id).inserted else { @@ -1554,10 +1851,14 @@ final class AlbumArtReceiver { recordPredictiveHqRequest(id: id) log("[PredictiveAlbumArt] request hq id=\(id) reason=\(reason)") } - requestAlbumArt(id: id, quality: "hq") + requestAlbumArt(id: id, quality: "hq", forceRefresh: forceRefresh) } - private func requestAlbumArt(id: String, quality: String) { + private func requestAlbumArt( + id: String, + quality: String, + forceRefresh: Bool = false + ) { guard id == currentAlbumArtID else { return } cancelStaleBinaryAlbumArtTransferIfNeeded(reason: "request found stale transfer") if let session = binaryAlbumArtSession { @@ -1573,12 +1874,24 @@ final class AlbumArtReceiver { } let key = "\(id)|\(quality)" guard requestedAlbumArtKeys.insert(key).inserted else { return } - log("[AlbumArt] request \(quality)") - delegate?.albumArtSendCommand(cmd: "ALBUM_ART_REQUEST", extra: ["id": id, "quality": quality]) - scheduleAlbumArtRequestStartTimeout(id: id, quality: quality) + log("[AlbumArt] request \(quality) forceRefresh=\(forceRefresh)") + var extra: [String: Any] = ["id": id, "quality": quality] + if forceRefresh { + extra["forceRefresh"] = true + } + delegate?.albumArtSendCommand(cmd: "ALBUM_ART_REQUEST", extra: extra) + scheduleAlbumArtRequestStartTimeout( + id: id, + quality: quality, + forceRefresh: forceRefresh + ) } - private func scheduleAlbumArtRequestStartTimeout(id: String, quality: String) { + private func scheduleAlbumArtRequestStartTimeout( + id: String, + quality: String, + forceRefresh: Bool = false + ) { let key = "\(id)|\(quality)" albumArtRequestStartTimeouts[key]?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -1597,13 +1910,22 @@ final class AlbumArtReceiver { let attempts = (self.albumArtRequestStartRetryCounts[key] ?? 0) + 1 self.albumArtRequestStartRetryCounts[key] = attempts guard attempts <= ALBUM_ART_REQUEST_START_MAX_RETRIES else { + if quality == "hq" { + self.forceRefreshAlbumArtIDs.remove(id) + self.sourceRefreshInFlightAlbumArtIDs.remove(id) + self.sourceRefreshAttemptedAlbumArtIDs.remove(id) + } self.log("[AlbumArt] request start timeout id=\(id) quality=\(quality), giving up") return } self.log("[AlbumArt] request start timeout id=\(id) quality=\(quality), retry=\(attempts)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self, self.currentAlbumArtID == id else { return } - self.requestAlbumArt(id: id, quality: quality) + self.requestAlbumArt( + id: id, + quality: quality, + forceRefresh: forceRefresh + ) } } albumArtRequestStartTimeouts[key] = workItem @@ -1618,27 +1940,195 @@ final class AlbumArtReceiver { albumArtRequestStartTimeouts.removeValue(forKey: key)?.cancel() } - private func loadCachedAlbumArt(id: String) -> (image: UIImage, quality: String)? { - let hqValidation = validateAlbumArtCache(id: id, preferredQuality: "hq") - if let cached = cachedEnhancedAlbumArt(id: id), - hqValidation.shouldDisplayWhileRefreshing { - return (cached.image, "enhanced") + private func loadAlbumArtCacheValidations( + id: String, + completion: @escaping ( + AlbumArtCacheValidation, + AlbumArtCacheValidation, + AlbumArtCacheValidation + ) -> Void + ) { + let token = UUID() + albumArtCacheLookupToken = token + ArtworkImageCache.shared.performIO { [weak self] in + guard let self else { return } + let hq = self.readAlbumArtCacheValidation( + id: id, + preferredQuality: "hq" + ) + let preview = self.readAlbumArtCacheValidation( + id: id, + preferredQuality: "preview" + ) + let legacy = self.readAlbumArtCacheValidation( + id: id, + preferredQuality: nil + ) + DispatchQueue.main.async { [weak self] in + guard let self, + self.albumArtCacheLookupToken == token, + self.currentAlbumArtID == id else { + return + } + self.logAlbumArtValidation(id: id, validation: hq) + self.logAlbumArtValidation(id: id, validation: preview) + completion(hq, preview, legacy) + } + } + } + + private func readAlbumArtCacheValidation( + id: String, + preferredQuality: String? + ) -> AlbumArtCacheValidation { + let resolvedQuality = preferredQuality ?? "legacy" + let imageURL = albumArtCacheURL(id: id, quality: preferredQuality) + let metadataURL = albumArtMetadataURL(id: id, quality: preferredQuality) + let exists = FileManager.default.fileExists(atPath: imageURL.path) + let metadata = readAlbumArtMetadata(from: metadataURL) + let memoryImage = ArtworkImageCache.shared.memoryImage( + artworkId: id, + quality: resolvedQuality, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) + // A decoded memory hit must not map and read the JPEG again. Metadata + // is written atomically with the image and carries the exact byte size. + let data: Data? + if memoryImage == nil, exists { + data = try? Data(contentsOf: imageURL, options: [.mappedIfSafe]) + } else { + data = nil } - if let image = hqValidation.image, - hqValidation.shouldDisplayWhileRefreshing { - return (image, "hq") + let size = metadata?.bytes ?? data?.count ?? ( + (try? imageURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + ) + let image = memoryImage ?? data.flatMap { + ArtworkImageCache.downsampledImage( + data: $0, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) + } + if let image { + ArtworkImageCache.shared.store( + image, + artworkId: id, + quality: resolvedQuality, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) } - let previewValidation = validateAlbumArtCache(id: id, preferredQuality: "preview") - if let image = previewValidation.image, - previewValidation.shouldDisplayWhileRefreshing { - return (image, "preview") + let pixelWidth = metadata?.pixelWidth ?? image?.pixelWidth ?? 0 + let pixelHeight = metadata?.pixelHeight ?? image?.pixelHeight ?? 0 + let source = normalizedAlbumArtSource(metadata?.source) + let ttlMs = albumArtCacheTtlMs(source: source) + let savedAt = metadata?.effectiveSavedAt ?? fileModifiedAt(imageURL) ?? 0 + let ageMs: Int64 = savedAt > 0 + ? max(0, Int64((Date().timeIntervalSince1970 - savedAt) * 1_000)) + : Int64.max + let expired = ageMs > ttlMs + let hardExpired = ageMs > ALBUM_ART_HARD_CACHE_TTL_MS + let storedQuality = preferredQuality == nil + ? (try? String( + contentsOf: albumArtQualityURL(id: id, quality: nil), + encoding: .utf8 + ))?.trimmingCharacters(in: .whitespacesAndNewlines) + : preferredQuality + let quality = preferredQuality ?? (storedQuality == "preview" ? "preview" : "hq") + let placeholder = image.map { + isLikelyPlaceholderAlbumArt($0, dataSize: size) + } ?? false + + let reason: String + let valid: Bool + if !exists { + reason = "missing" + valid = false + } else if image == nil || size <= 0 { + reason = "decode_failed" + valid = false + } else if placeholder { + reason = "placeholder" + valid = false + } else if hardExpired { + reason = "hard_ttl_expired" + valid = false + } else if expired { + reason = "source_ttl_expired" + valid = true + } else if source == "notificationLargeIcon" || source == "unknown" { + reason = "source_requires_refresh" + valid = true + } else if pixelWidth <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT || + pixelHeight <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT { + reason = "small_cache_requires_refresh" + valid = true + } else { + reason = "ok" + valid = true } - let legacyValidation = validateAlbumArtCache(id: id, preferredQuality: nil) - if let image = legacyValidation.image, - legacyValidation.shouldDisplayWhileRefreshing { - return (image, legacyValidation.quality) + + return AlbumArtCacheValidation( + image: image, + quality: quality, + source: source, + ageMs: ageMs, + ttlMs: ttlMs, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + bytes: size, + valid: valid, + expired: expired, + reason: reason + ) + } + + private func logAlbumArtValidation( + id: String, + validation: AlbumArtCacheValidation + ) { + let message = + "[AlbumArtCache] validate id=\(id) valid=\(validation.valid) " + + "quality=\(validation.quality) source=\(validation.source) " + + "ageMs=\(validation.ageMs == Int64.max ? -1 : validation.ageMs) " + + "ttlMs=\(validation.ttlMs) reason=\(validation.reason) " + + "size=\(validation.bytes) " + + "pixelSize=\(validation.pixelWidth)x\(validation.pixelHeight)" + log(message) + consoleLog(message) + } + + private func loadEnhancedAlbumArtIfAvailable(id: String, reason: String) { + guard artworkEnhancementEnabled else { return } + let target = artworkEnhancementTargetPixelSize + let sharpness = artworkEnhancementSharpness + ArtworkImageCache.shared.performIO { [weak self] in + guard let self else { return } + let result = ArtworkEnhancementManager.shared.cachedEnhancedArtwork( + artworkId: id, + targetPixelSize: target, + sharpness: sharpness + ) + DispatchQueue.main.async { [weak self] in + guard let self, self.currentAlbumArtID == id else { return } + if let result, result.shouldAutoDisplay { + self.setAlbumArtDisplay( + image: result.image, + id: id, + quality: .enhanced, + reason: "\(reason) enhanced" + ) + self.delegate?.albumArtPublishLiveArtwork( + image: result.image, + key: id, + reason: "enhancedCacheHit" + ) + } else { + self.startArtworkEnhancementFromCachedHq( + id: id, + reason: reason + ) + } + } } - return nil } private func cachedEnhancedAlbumArt(id: String, allowLowGain: Bool = false) -> ArtworkEnhancementResult? { @@ -1723,13 +2213,37 @@ final class AlbumArtReceiver { } private func startArtworkEnhancementFromCachedHq(id: String, reason: String) { - guard let data = try? Data(contentsOf: albumArtCacheURL(id: id, quality: "hq")), - let image = UIImage(data: data) else { - updateArtworkEnhancementStatus(message: "HQ cache missing") - notifyStateChanged() - return + let fileURL = albumArtCacheURL(id: id, quality: "hq") + ArtworkImageCache.shared.performIO { [weak self] in + guard let self else { return } + let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) + let image = data.flatMap { + ArtworkImageCache.downsampledImage( + data: $0, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) + } + DispatchQueue.main.async { [weak self] in + guard let self, self.currentAlbumArtID == id else { return } + guard let data, let image else { + self.updateArtworkEnhancementStatus(message: "HQ cache missing") + self.notifyStateChanged() + return + } + ArtworkImageCache.shared.store( + image, + artworkId: id, + quality: "hq", + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) + self.startArtworkEnhancementIfNeeded( + id: id, + sourceData: data, + sourceImage: image, + reason: reason + ) + } } - startArtworkEnhancementIfNeeded(id: id, sourceData: data, sourceImage: image, reason: reason) } private func startArtworkEnhancementIfNeeded( @@ -1896,12 +2410,15 @@ final class AlbumArtReceiver { } else if !decoded || size <= 0 { reason = "decode_failed" valid = false - } else if let image, isLikelyPlaceholderAlbumArt(image, dataSize: size) { - reason = "placeholder" - valid = false } else if hardExpired { reason = "hard_ttl_expired" valid = false + } else if let image, + AlbumArtPlaceholderPolicy.disposition( + image: image, + dataSize: size + ) == .provisional { + reason = "low_information_requires_refresh" } else if expired { reason = "source_ttl_expired" } else if source == "notificationLargeIcon" || source == "unknown" { @@ -1938,23 +2455,6 @@ final class AlbumArtReceiver { reason: reason ) } - if isLikelyPlaceholderAlbumArt(image, dataSize: size) { - removePlaceholderAlbumArt(id: id, quality: preferredQuality) - return AlbumArtCacheValidation( - image: nil, - quality: quality, - source: source, - ageMs: ageMs, - ttlMs: ttlMs, - pixelWidth: pixelWidth, - pixelHeight: pixelHeight, - bytes: size, - valid: false, - expired: expired, - reason: reason - ) - } - if expired { let message = "[AlbumArtCache] expired id=\(id) source=\(source) " + @@ -1985,20 +2485,16 @@ final class AlbumArtReceiver { cancelAlbumArtFallback() cancelHqAlbumArtRequest() let firstRefreshAttempt = sourceRefreshAttemptedAlbumArtIDs.insert(id).inserted + sourceRefreshInFlightAlbumArtIDs.insert(id) + forceRefreshAlbumArtIDs.insert(id) let selected = hqValidation.valid ? hqValidation : previewValidation let reason = selected.reason - if let cached = cachedEnhancedAlbumArt(id: id), hqValidation.valid { - setAlbumArtDisplay( - image: cached.image, - id: id, - quality: .enhanced, - reason: "stale-while-revalidate" - ) - delegate?.albumArtPublishLiveArtwork( - image: cached.image, - key: id, - reason: "staleWhileRevalidate" - ) + let rejectedFallback = reason == "low_information_requires_refresh" + if rejectedFallback { + removeCachedAlbumArt(id: id, quality: "preview", reason: "qq fallback placeholder") + removeCachedAlbumArt(id: id, quality: "hq", reason: "qq fallback placeholder") + updateArtworkEnhancementStatus(message: "waiting for real artwork") + log("[AlbumArtCache] fallback placeholder not displayed id=\(id)") } else if let image = selected.image, selected.valid { let displayQuality = selected.quality == "preview" ? ArtworkDisplayQuality.preview @@ -2014,8 +2510,16 @@ final class AlbumArtReceiver { key: id, reason: "staleWhileRevalidate" ) + if displayQuality == .hq { + loadEnhancedAlbumArtIfAvailable( + id: id, + reason: "stale-while-revalidate" + ) + } + } + if !rejectedFallback { + updateArtworkEnhancementStatus(message: "refreshing cache") } - updateArtworkEnhancementStatus(message: "refreshing cache") let staleReason = selected.expired ? "source_ttl_expired" : reason let staleMessage = "[AlbumArtCache] stale id=\(id) reason=\(staleReason)" log(staleMessage) @@ -2035,8 +2539,13 @@ final class AlbumArtReceiver { "id=\(id) source=\(selected.source) cacheReason=\(reason)" log(message) consoleLog(message) - requestAlbumArt(id: id, quality: "preview") - schedulePredictiveHqPrefetch(id: id, delay: 2.2, reason: "stale cache") + requestAlbumArt(id: id, quality: "preview", forceRefresh: true) + schedulePredictiveHqPrefetch( + id: id, + delay: 2.2, + reason: "stale cache", + forceRefresh: true + ) notifyStateChanged() } @@ -2044,11 +2553,8 @@ final class AlbumArtReceiver { removeCachedAlbumArt(id: id, quality: quality, reason: "corrupted") } - private func removePlaceholderAlbumArt(id: String, quality: String? = nil) { - removeCachedAlbumArt(id: id, quality: quality, reason: "placeholder") - } - private func removeCachedAlbumArt(id: String, quality: String? = nil, reason: String) { + ArtworkImageCache.shared.removeAllDecodedImages() let imageURL = albumArtCacheURL(id: id, quality: quality) let qualityURL = albumArtQualityURL(id: id, quality: quality) let metadataURL = albumArtMetadataURL(id: id, quality: quality) @@ -2061,46 +2567,7 @@ final class AlbumArtReceiver { } private func isLikelyPlaceholderAlbumArt(_ image: UIImage, dataSize: Int) -> Bool { - guard dataSize < 1_800, let cgImage = image.cgImage else { - return false - } - let width = 24 - let height = 24 - var pixels = [UInt8](repeating: 0, count: width * height * 4) - let colorSpace = CGColorSpaceCreateDeviceRGB() - guard let context = CGContext( - data: &pixels, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return false - } - context.interpolationQuality = .low - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) - - var buckets = Set() - var colorfulPixels = 0 - var visiblePixels = 0 - stride(from: 0, to: pixels.count, by: 4).forEach { index in - let red = Int(pixels[index]) - let green = Int(pixels[index + 1]) - let blue = Int(pixels[index + 2]) - let alpha = Int(pixels[index + 3]) - guard alpha >= 24 else { return } - let maximum = max(red, max(green, blue)) - let minimum = min(red, min(green, blue)) - if maximum > 0 && Double(maximum - minimum) / Double(maximum) > 0.12 { - colorfulPixels += 1 - } - buckets.insert(((red / 32) << 10) | ((green / 32) << 5) | (blue / 32)) - visiblePixels += 1 - } - - return visiblePixels > 0 && buckets.count <= 10 && colorfulPixels * 20 <= visiblePixels + AlbumArtPlaceholderPolicy.isLowInformation(image, dataSize: dataSize) } private func shouldAcceptHqAlbumArt(image: UIImage, data: Data, id: String) -> Bool { @@ -2413,14 +2880,7 @@ final class AlbumArtReceiver { } private static func crc32(_ data: Data) -> UInt32 { - var crc: UInt32 = 0xffff_ffff - for byte in data { - crc ^= UInt32(byte) - for _ in 0..<8 { - crc = (crc >> 1) ^ ((crc & 1) == 1 ? 0xedb8_8320 : 0) - } - } - return crc ^ 0xffff_ffff + BLEBinaryChunkCodec.crc32(data) } private func log(_ message: String) { diff --git a/IOSBleFeasibility/IOSBleFeasibility/AppExperienceMode.swift b/IOSBleFeasibility/IOSBleFeasibility/AppExperienceMode.swift index 82d6f76..5a36594 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AppExperienceMode.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AppExperienceMode.swift @@ -9,18 +9,18 @@ enum AppExperienceMode: String, CaseIterable, Identifiable { var title: String { switch self { case .daily: - return "日常模式" + return AppLocalization.string("日常模式") case .debug: - return "调试模式" + return AppLocalization.string("调试模式") } } var toggleTitle: String { switch self { case .daily: - return "进入调试模式" + return AppLocalization.string("进入调试模式") case .debug: - return "退出调试模式" + return AppLocalization.string("退出调试模式") } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/AppLogStore.swift b/IOSBleFeasibility/IOSBleFeasibility/AppLogStore.swift index a584287..acc0a4b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AppLogStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AppLogStore.swift @@ -1,4 +1,6 @@ import Foundation +@preconcurrency import MetricKit +import OSLog final class AppLogStore { static let shared = AppLogStore() @@ -232,3 +234,55 @@ final class AppLogStore { return "\(timestamp) [App] \(message)\n" } } + +enum AppPerformanceLog { + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.sqz.IOSBleFeasibility" + + static let connection = Logger(subsystem: subsystem, category: "BLE.Connection") + static let protocolLog = Logger(subsystem: subsystem, category: "BLE.Protocol") + static let lyrics = Logger(subsystem: subsystem, category: "Media.Lyrics") + static let artwork = Logger(subsystem: subsystem, category: "Media.Artwork") + static let liveActivity = Logger(subsystem: subsystem, category: "LiveActivity") + static let ui = Logger(subsystem: subsystem, category: "UI.Responsiveness") + + static let protocolSignposter = OSSignposter(logger: protocolLog) + static let artworkSignposter = OSSignposter(logger: artwork) + static let liveActivitySignposter = OSSignposter(logger: liveActivity) +} + +final class MetricDiagnosticsSubscriber: NSObject, MXMetricManagerSubscriber, @unchecked Sendable { + static let shared = MetricDiagnosticsSubscriber() + + private let lock = NSLock() + private var started = false + + func start() { + lock.lock() + guard !started else { + lock.unlock() + return + } + started = true + lock.unlock() + MXMetricManager.shared.add(self) + AppPerformanceLog.ui.info("MetricKit subscriber started") + } + + func didReceive(_ payloads: [MXMetricPayload]) { + AppLogStore.shared.append("[MetricKit] metric payloads=\(payloads.count)") + payloads.forEach { payload in + AppPerformanceLog.ui.info( + "MetricKit metric payload bytes=\(payload.jsonRepresentation().count)" + ) + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + AppLogStore.shared.append("[MetricKit] diagnostic payloads=\(payloads.count)") + payloads.forEach { payload in + AppPerformanceLog.ui.error( + "MetricKit diagnostic payload bytes=\(payload.jsonRepresentation().count)" + ) + } + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift b/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift index 32117a4..f5cef4b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift @@ -15,10 +15,10 @@ enum ArtworkDisplayQuality: Int, Comparable { var label: String { switch self { - case .placeholder: return "placeholder" - case .preview: return "preview" - case .hq: return "hq" - case .enhanced: return "enhanced" + case .placeholder: return AppLocalization.string("占位图") + case .preview: return AppLocalization.string("预览图") + case .hq: return AppLocalization.string("高清图") + case .enhanced: return AppLocalization.string("增强图") } } } @@ -45,7 +45,7 @@ struct ArtworkEnhancementResult { let shouldAutoDisplay: Bool } -struct ArtworkEnhancementDebugStatus { +struct ArtworkEnhancementDebugStatus: Equatable { var enabled: Bool = true var currentSource: String = "-" var target: String = "-" diff --git a/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift b/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift new file mode 100644 index 0000000..d127260 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift @@ -0,0 +1,191 @@ +import Foundation +import ImageIO +import UIKit + +/// Decoded artwork cache plus the utility queue used for artwork disk I/O. +/// +/// CoreBluetooth callbacks use the main queue in this app, so neither file +/// reads nor ImageIO decoding should run inline with a notification callback. +final class ArtworkImageCache: @unchecked Sendable { + static let shared = ArtworkImageCache() + + static let mainArtworkMaximumPixelSize = 780 + static let historyArtworkMaximumPixelSize = 128 + + private let images = NSCache() + private let ioQueue = DispatchQueue( + label: "com.sqz.IOSBleFeasibility.artworkImageCache", + qos: .utility + ) + private var memoryWarningObserver: NSObjectProtocol? + + private init() { + images.countLimit = 40 + images.totalCostLimit = 32 * 1_024 * 1_024 + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.images.removeAllObjects() + } + } + + deinit { + if let memoryWarningObserver { + NotificationCenter.default.removeObserver(memoryWarningObserver) + } + } + + func memoryImage( + artworkId: String, + quality: String, + maximumPixelSize: Int + ) -> UIImage? { + images.object( + forKey: cacheKey( + artworkId: artworkId, + quality: quality, + maximumPixelSize: maximumPixelSize + ) + ) + } + + func store( + _ image: UIImage, + artworkId: String, + quality: String, + maximumPixelSize: Int + ) { + let cost = max(image.pixelWidth, 1) * max(image.pixelHeight, 1) * 4 + images.setObject( + image, + forKey: cacheKey( + artworkId: artworkId, + quality: quality, + maximumPixelSize: maximumPixelSize + ), + cost: cost + ) + } + + func load( + artworkId: String, + quality: String, + fileURL: URL, + maximumPixelSize: Int, + completion: @escaping (Data?, UIImage?) -> Void + ) { + if let image = memoryImage( + artworkId: artworkId, + quality: quality, + maximumPixelSize: maximumPixelSize + ) { + DispatchQueue.main.async { + completion(nil, image) + } + return + } + + ioQueue.async { [weak self] in + guard let self else { return } + let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) + let image = data.flatMap { + Self.downsampledImage(data: $0, maximumPixelSize: maximumPixelSize) + } + if let image { + self.store( + image, + artworkId: artworkId, + quality: quality, + maximumPixelSize: maximumPixelSize + ) + } + DispatchQueue.main.async { + completion(data, image) + } + } + } + + func load( + artworkId: String, + quality: String, + fileURL: URL, + maximumPixelSize: Int + ) async -> UIImage? { + await withCheckedContinuation { continuation in + load( + artworkId: artworkId, + quality: quality, + fileURL: fileURL, + maximumPixelSize: maximumPixelSize + ) { _, image in + continuation.resume(returning: image) + } + } + } + + func decode( + data: Data, + artworkId: String, + quality: String, + maximumPixelSize: Int, + completion: @escaping (UIImage?) -> Void + ) { + ioQueue.async { [weak self] in + guard let self else { return } + let image = Self.downsampledImage( + data: data, + maximumPixelSize: maximumPixelSize + ) + if let image { + self.store( + image, + artworkId: artworkId, + quality: quality, + maximumPixelSize: maximumPixelSize + ) + } + DispatchQueue.main.async { + completion(image) + } + } + } + + func performIO(_ work: @escaping () -> Void) { + ioQueue.async(execute: work) + } + + func removeAllDecodedImages() { + images.removeAllObjects() + } + + static func downsampledImage(data: Data, maximumPixelSize: Int) -> UIImage? { + guard maximumPixelSize > 0, + let source = CGImageSourceCreateWithData( + data as CFData, + [kCGImageSourceShouldCache: false] as CFDictionary + ), + let cgImage = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, + kCGImageSourceShouldCacheImmediately: true + ] as CFDictionary + ) else { + return nil + } + return UIImage(cgImage: cgImage) + } + + private func cacheKey( + artworkId: String, + quality: String, + maximumPixelSize: Int + ) -> NSString { + "\(artworkId)|\(quality)|\(maximumPixelSize)" as NSString + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLEBinaryChunkCodec.swift b/IOSBleFeasibility/IOSBleFeasibility/BLEBinaryChunkCodec.swift new file mode 100644 index 0000000..241564e --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEBinaryChunkCodec.swift @@ -0,0 +1,67 @@ +import Foundation + +struct BLEBinaryChunk: Equatable { + let magic: UInt8 + let kindCode: UInt8 + let index: Int + let total: Int + let payload: Data +} + +enum BLEBinaryChunkCodec { + static let headerSize = 6 + + static func decode(_ data: Data, expectedMagic: UInt8) -> BLEBinaryChunk? { + guard data.count > headerSize, + data[data.startIndex] == expectedMagic else { + return nil + } + let kindCode = data[data.startIndex + 1] + let index = Int(data[data.startIndex + 2]) << 8 | + Int(data[data.startIndex + 3]) + let total = Int(data[data.startIndex + 4]) << 8 | + Int(data[data.startIndex + 5]) + guard total > 0, index >= 0, index < total else { + return nil + } + return BLEBinaryChunk( + magic: expectedMagic, + kindCode: kindCode, + index: index, + total: total, + payload: Data(data.dropFirst(headerSize)) + ) + } + + static func missingIndexes( + chunks: [Int: Data], + expectedCount: Int + ) -> [Int] { + guard expectedCount > 0 else { return [] } + return (0.. Data? { + guard expectedCount > 0, + missingIndexes(chunks: chunks, expectedCount: expectedCount).isEmpty else { + return nil + } + return (0.. UInt32 { + var crc: UInt32 = 0xffff_ffff + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { + crc = (crc >> 1) ^ ((crc & 1) == 1 ? 0xedb8_8320 : 0) + } + } + return crc ^ 0xffff_ffff + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift new file mode 100644 index 0000000..7e920d9 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift @@ -0,0 +1,716 @@ +import Combine +import Observation +import UIKit + +final class BLEInboundPipeline: @unchecked Sendable { + private let queue = DispatchQueue( + label: "com.musicblecontroller.ble-inbound-pipeline", + qos: .userInitiated + ) + + func submit(_ operation: @escaping @Sendable () -> Void) { + queue.async(execute: operation) + } +} + +final class BLEStatusObjectBox: @unchecked Sendable { + let value: [String: Any] + init(_ value: [String: Any]) { self.value = value } +} + +final class ObservableStateSlice: ObservableObject { + @Published private(set) var value: Value + + init(_ value: Value) { + self.value = value + } + + func update(_ newValue: Value) { + guard value != newValue else { return } + value = newValue + } +} + +struct BLEConnectionViewState: Equatable { + var status = "未连接" + var displayState = "disconnected" + var healthState = "disconnected" + var deviceName = "-" + var characteristicReady = false + var autoReconnectState = "idle" +} + +struct BLEPlaybackViewState: Equatable { + var title = "-" + var artist = "-" + var album = "-" + var isPlaying = false + var positionMs: Int64 = 0 + var displayPositionMs: Int64 = 0 + var durationMs: Int64 = 0 + var seekPositionMs: Int64 = 0 + var isSeeking = false + var volumeCurrent = 0 + var volumeMax = 0 + var volumeSeekValue = 0 + var isVolumeSeeking = false +} + +struct BLELyricsViewState: Equatable { + var lyric = "" + var fullLyrics: [LyricLine] = [] + var fullLyricsTrackId = "" + var isCurrent = false + var isReceiving = false + var currentWordLineIndex = -1 + var currentWordIndex = -1 + var loadingStage: LyricLoadingStage = .idle + var translationState: LyricSecondaryLoadState = .idle + var romanizationState: LyricSecondaryLoadState = .idle +} + +struct BLEArtworkViewState: Equatable { + var image: UIImage? + var displayQuality: ArtworkDisplayQuality = .placeholder + var enhancementStatus = ArtworkEnhancementDebugStatus() + var isRestoredSnapshot = false + var loadingStage: ArtworkLoadingStage = .idle + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.image === rhs.image && + lhs.displayQuality == rhs.displayQuality && + lhs.enhancementStatus == rhs.enhancementStatus && + lhs.isRestoredSnapshot == rhs.isRestoredSnapshot && + lhs.loadingStage == rhs.loadingStage + } +} + +struct BLEDiagnosticsViewState: Equatable { + var logCount = 0 + var connectionAttemptId = "-" + var lastHardReconnectReason = "-" + var remoteLogInProgress = false + var mediaDumpInProgress = false + var lyricDiagnosticLoading = false +} + +enum BLEBluetoothAvailability: String, Equatable { + case unknown + case available + case poweredOff + case unauthorized + case unsupported + case resetting +} + +enum BLEConnectionFailure: String, Equatable { + case deviceNotFound + case timeout + case serviceUnavailable + case incompatibleProtocol + case connectionLost + case unknown +} + +enum BLEConnectionPresentationState: Equatable { + case unavailable(BLEBluetoothAvailability) + case disconnected(lastDeviceName: String?) + case scanning + case connecting(deviceName: String?) + case reconnecting(deviceName: String?, attempt: Int) + case connected(deviceName: String, health: String) + case failed(BLEConnectionFailure) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } +} + +struct BLEPlaybackMetadataState: Equatable { + var title = "-" + var artist = "-" + var album = "-" +} + +struct BLEPlaybackTimelineState: Equatable { + var isPlaying = false + var positionMs: Int64 = 0 + var displayPositionMs: Int64 = 0 + var durationMs: Int64 = 0 + var seekPositionMs: Int64 = 0 + var isSeeking = false +} + +struct BLEVolumeViewState: Equatable { + var current = 0 + var maximum = 0 + var seekValue = 0 + var isSeeking = false +} + +struct BLELiveLyricState: Equatable { + var text = "" + var currentWordLineIndex = -1 + var currentWordIndex = -1 + var loadingStage: LyricLoadingStage = .idle +} + +struct BLEFullLyricsViewState: Equatable { + var lines: [LyricLine] = [] + var trackId = "" + var isCurrent = false + var isReceiving = false + var translationState: LyricSecondaryLoadState = .idle + var romanizationState: LyricSecondaryLoadState = .idle +} + +@Observable +final class ConnectionStore { + private(set) var state = BLEConnectionViewState() + private(set) var presentation: BLEConnectionPresentationState = .disconnected(lastDeviceName: nil) + + func update( + state newState: BLEConnectionViewState, + presentation newPresentation: BLEConnectionPresentationState + ) { + if state != newState { state = newState } + if presentation != newPresentation { presentation = newPresentation } + } +} + +@Observable +final class PlaybackStore { + private(set) var metadata = BLEPlaybackMetadataState() + private(set) var timeline = BLEPlaybackTimelineState() + private(set) var volume = BLEVolumeViewState() + + func updateMetadata(_ value: BLEPlaybackMetadataState) { + if metadata != value { metadata = value } + } + + func updateTimeline(_ value: BLEPlaybackTimelineState) { + if timeline != value { timeline = value } + } + + func updateVolume(_ value: BLEVolumeViewState) { + if volume != value { volume = value } + } +} + +@Observable +final class LyricsStore { + private(set) var live = BLELiveLyricState() + private(set) var document = BLEFullLyricsViewState() + + func updateLive(_ value: BLELiveLyricState) { + if live != value { live = value } + } + + func updateDocument(_ value: BLEFullLyricsViewState) { + if document != value { document = value } + } +} + +@Observable +final class ArtworkStore { + private(set) var state = BLEArtworkViewState() + + func update(_ value: BLEArtworkViewState) { + if state != value { state = value } + } +} + +@Observable +final class DiagnosticsStore { + private(set) var state = BLEDiagnosticsViewState() + + func update(_ value: BLEDiagnosticsViewState) { + if state != value { state = value } + } +} + +struct BLEProtocolV3Features: OptionSet, Equatable, Sendable { + let rawValue: Int + + static let statusMetaV1 = Self(rawValue: 1 << 0) + static let structuredErrorV1 = Self(rawValue: 1 << 1) + static let mediaLoadStateV1 = Self(rawValue: 1 << 2) + static let all: Self = [.statusMetaV1, .structuredErrorV1, .mediaLoadStateV1] +} + +struct BLEProtocolV2Features: OptionSet, Equatable, Sendable { + let rawValue: Int + + static let albumArtBinary = Self(rawValue: 1 << 0) + static let fullLyricsZlib = Self(rawValue: 1 << 1) + static let lyricWindow = Self(rawValue: 1 << 2) + static let ping = Self(rawValue: 1 << 3) + static let clockSyncV1 = Self(rawValue: 1 << 4) + static let transferRetry = Self(rawValue: 1 << 5) +} + +struct BLECapabilitiesAck: Equatable, Sendable { + var protocolVersion = 1 + var v2Features: BLEProtocolV2Features = [] + var v3Features: BLEProtocolV3Features = [] + var sessionId: String? +} + +struct BLEStatusMetadata: Equatable, Sendable { + let sessionId: String + let eventSequence: UInt64 +} + +enum BLEEventSequenceObservation: Equatable, Sendable { + case first + case inOrder + case duplicate + case gap(missing: UInt64) + case outOfOrder + case newSession +} + +struct BLEEventSequenceDiagnostics: Sendable { + private(set) var sessionId: String? + private(set) var highestSequence: UInt64 = 0 + + mutating func reset(sessionId: String? = nil) { + self.sessionId = sessionId + highestSequence = 0 + } + + mutating func observe(_ metadata: BLEStatusMetadata) -> BLEEventSequenceObservation { + guard sessionId == metadata.sessionId else { + let hadSession = sessionId != nil + sessionId = metadata.sessionId + highestSequence = metadata.eventSequence + return hadSession ? .newSession : .first + } + if metadata.eventSequence == highestSequence { return .duplicate } + if metadata.eventSequence < highestSequence { return .outOfOrder } + let previous = highestSequence + highestSequence = metadata.eventSequence + if previous > 0, metadata.eventSequence > previous + 1 { + return .gap(missing: metadata.eventSequence - previous - 1) + } + return previous == 0 ? .first : .inOrder + } +} + +struct LiveActivityArtworkRevisionFence: Sendable { + private(set) var current: UInt64 = 0 + + mutating func begin() -> UInt64 { + current &+= 1 + return current + } + + mutating func invalidate() { + current &+= 1 + } + + func accepts(_ revision: UInt64) -> Bool { + revision == current + } +} + +enum BLECommandErrorDomain: String, Equatable, Sendable { + case `protocol` + case lyrics + case artwork + case history + case connection + case unknown +} + +struct BLECommandErrorPayload: Equatable, Sendable { + let sequence: UInt64? + let command: String + let domain: BLECommandErrorDomain + let code: String + let retryable: Bool + let retryAfterMs: Int64? + let trackId: String? + let generation: Int64? + let metadata: BLEStatusMetadata? +} + +enum BLEMediaResource: String, Equatable, Sendable { + case lyrics + case artwork +} + +enum BLEMediaLoadStage: String, Equatable, Sendable { + case waiting + case preparing + case transferring + case ready + case unavailable + case failed +} + +struct BLEMediaLoadPayload: Equatable, Sendable { + let resource: BLEMediaResource + let stage: BLEMediaLoadStage + let reason: String + let retryable: Bool + let retryAfterMs: Int64? + let trackId: String + let generation: Int64? + let metadata: BLEStatusMetadata? + + var deduplicationKey: String { + "\(trackId)|\(generation ?? -1)|\(resource.rawValue)|\(stage.rawValue)|\(reason)" + } +} + +enum BLEProtocolV3Parser { + static let maximumJSONNotifyBytes = 64 * 1_024 + + static func jsonObject(from data: Data) -> [String: Any]? { + guard !data.isEmpty, data.count <= maximumJSONNotifyBytes else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + static func capabilitiesAck(from object: [String: Any]) -> BLECapabilitiesAck { + let protocolVersion = int(object["protocolVersion"], default: 1) + if protocolVersion >= 3, object["f2"] != nil { + return BLECapabilitiesAck( + protocolVersion: protocolVersion, + v2Features: BLEProtocolV2Features(rawValue: int(object["f2"])), + v3Features: BLEProtocolV3Features(rawValue: int(object["f3"])), + sessionId: nonEmptyString(object["sid"]) + ) + } + + var v2: BLEProtocolV2Features = [] + if bool(object["albumArtBinary"]) { v2.insert(.albumArtBinary) } + if bool(object["fullLyricsZlib"]) { v2.insert(.fullLyricsZlib) } + if bool(object["lyricWindow"]) { v2.insert(.lyricWindow) } + if bool(object["ping"]) { v2.insert(.ping) } + if bool(object["clockSyncV1"]) { v2.insert(.clockSyncV1) } + if bool(object["transferRetry"]) { v2.insert(.transferRetry) } + return BLECapabilitiesAck(protocolVersion: protocolVersion, v2Features: v2) + } + + static func statusMetadata(from object: [String: Any]) -> BLEStatusMetadata? { + guard let sessionId = nonEmptyString(object["sid"]), + let sequence = uint64(object["es"]) else { + return nil + } + return BLEStatusMetadata(sessionId: sessionId, eventSequence: sequence) + } + + static func commandError(from object: [String: Any]) -> BLECommandErrorPayload? { + guard let command = nonEmptyString(object["cmd"]), + let code = nonEmptyString(object["code"]) else { + return nil + } + return BLECommandErrorPayload( + sequence: uint64(object["seq"]), + command: command, + domain: BLECommandErrorDomain( + rawValue: nonEmptyString(object["domain"]) ?? "" + ) ?? .unknown, + code: code, + retryable: bool(object["retryable"]), + retryAfterMs: int64(object["retryAfterMs"]), + trackId: nonEmptyString(object["trackId"]), + generation: int64(object["generation"]), + metadata: statusMetadata(from: object) + ) + } + + static func mediaLoadState(from object: [String: Any]) -> BLEMediaLoadPayload? { + guard let resourceRaw = nonEmptyString(object["resource"]), + let resource = BLEMediaResource(rawValue: resourceRaw), + let stageRaw = nonEmptyString(object["stage"]), + let stage = BLEMediaLoadStage(rawValue: stageRaw), + let trackId = nonEmptyString(object["trackId"]) else { + return nil + } + return BLEMediaLoadPayload( + resource: resource, + stage: stage, + reason: nonEmptyString(object["reason"]) ?? "unknown", + retryable: bool(object["retryable"]), + retryAfterMs: int64(object["retryAfterMs"]), + trackId: trackId, + generation: int64(object["generation"]), + metadata: statusMetadata(from: object) + ) + } + + private static func nonEmptyString(_ value: Any?) -> String? { + let text: String? + if let value = value as? String { + text = value + } else if let value = value as? NSNumber { + text = value.stringValue + } else { + text = nil + } + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func bool(_ value: Any?) -> Bool { + if let value = value as? Bool { return value } + if let value = value as? NSNumber { return value.boolValue } + if let value = value as? String { return value == "true" || value == "1" } + return false + } + + private static func int(_ value: Any?, default defaultValue: Int = 0) -> Int { + if let value = value as? Int { return value } + if let value = value as? NSNumber { return value.intValue } + if let value = value as? String, let result = Int(value) { return result } + return defaultValue + } + + private static func int64(_ value: Any?) -> Int64? { + if let value = value as? Int64 { return value } + if let value = value as? Int { return Int64(value) } + if let value = value as? NSNumber { return value.int64Value } + if let value = value as? String { return Int64(value) } + return nil + } + + private static func uint64(_ value: Any?) -> UInt64? { + if let value = value as? UInt64 { return value } + if let value = value as? Int, value >= 0 { return UInt64(value) } + if let value = value as? NSNumber { return value.uint64Value } + if let value = value as? String { return UInt64(value) } + return nil + } +} + +enum PlaybackClockPolicy { + static func shouldRun( + isPlaying: Bool, + durationMs: Int64, + appLifecycleState: String + ) -> Bool { + isPlaying && durationMs > 0 && appLifecycleState == "active" + } +} + +struct MonotonicClockSyncSnapshot: Equatable { + let localMinusServerMs: Double + let bestRoundTripMs: Int64 + let offsetJitterMs: Int64 + let sampleCount: Int + let isConfident: Bool +} + +struct MonotonicClockSynchronizer { + private struct Sample { + let localMinusServerMs: Double + let roundTripMs: Int64 + let receivedAtLocalMs: Int64 + } + + private var samples: [Sample] = [] + private(set) var snapshot: MonotonicClockSyncSnapshot? + + mutating func reset() { + samples.removeAll(keepingCapacity: true) + snapshot = nil + } + + @discardableResult + mutating func record( + clientSendElapsedMs: Int64, + serverReceiveElapsedMs: Int64, + serverSendElapsedMs: Int64, + clientReceiveElapsedMs: Int64 + ) -> MonotonicClockSyncSnapshot? { + guard clientSendElapsedMs > 0, + serverReceiveElapsedMs > 0, + serverSendElapsedMs >= serverReceiveElapsedMs, + clientReceiveElapsedMs >= clientSendElapsedMs else { + return snapshot + } + let serverProcessingMs = serverSendElapsedMs - serverReceiveElapsedMs + let roundTripMs = clientReceiveElapsedMs - clientSendElapsedMs - serverProcessingMs + guard roundTripMs >= 0, roundTripMs <= Self.maximumAcceptedRoundTripMs else { + return snapshot + } + let localMinusServerMs = ( + Double(clientSendElapsedMs - serverReceiveElapsedMs) + + Double(clientReceiveElapsedMs - serverSendElapsedMs) + ) / 2.0 + samples.removeAll { + clientReceiveElapsedMs - $0.receivedAtLocalMs > Self.sampleLifetimeMs + } + samples.append( + Sample( + localMinusServerMs: localMinusServerMs, + roundTripMs: roundTripMs, + receivedAtLocalMs: clientReceiveElapsedMs + ) + ) + if samples.count > Self.maximumSamples { + samples.removeFirst(samples.count - Self.maximumSamples) + } + + let best = Array(samples.sorted { $0.roundTripMs < $1.roundTripMs }.prefix(3)) + guard let minimumRoundTrip = best.map(\.roundTripMs).min() else { + snapshot = nil + return nil + } + let sortedOffsets = best.map(\.localMinusServerMs).sorted() + let offset: Double + if sortedOffsets.count == 2 { + offset = (sortedOffsets[0] + sortedOffsets[1]) / 2.0 + } else { + offset = sortedOffsets[sortedOffsets.count / 2] + } + let jitter = Int64( + ((sortedOffsets.last ?? offset) - (sortedOffsets.first ?? offset)).rounded() + ) + let value = MonotonicClockSyncSnapshot( + localMinusServerMs: offset, + bestRoundTripMs: minimumRoundTrip, + offsetJitterMs: max(jitter, 0), + sampleCount: samples.count, + isConfident: samples.count >= 3 && + minimumRoundTrip <= Self.confidentRoundTripMs && + jitter <= Self.confidentOffsetJitterMs + ) + snapshot = value + return value + } + + func transportAgeMs( + serverSampleElapsedMs: Int64, + localReceiveElapsedMs: Int64 + ) -> Int64? { + guard serverSampleElapsedMs > 0, + let snapshot, + snapshot.isConfident else { + return nil + } + let mappedLocalSample = Double(serverSampleElapsedMs) + snapshot.localMinusServerMs + return Int64((Double(localReceiveElapsedMs) - mappedLocalSample).rounded()) + } + + private static let maximumAcceptedRoundTripMs: Int64 = 1_500 + private static let confidentRoundTripMs: Int64 = 300 + private static let confidentOffsetJitterMs: Int64 = 100 + private static let sampleLifetimeMs: Int64 = 5 * 60 * 1_000 + private static let maximumSamples = 12 +} + +enum RemotePlaybackAnchorResolution: Equatable { + case unavailable + case stale(transportAgeMs: Int64) + case resolved(positionMs: Int64, transportAgeMs: Int64) +} + +enum RemotePlaybackAnchorPolicy { + static let staleTransportAgeMs: Int64 = 1_500 + + static func resolve( + remotePositionMs: Int64, + serverSampleElapsedMs: Int64, + localReceiveElapsedMs: Int64, + playbackSpeed: Double, + isPlaying: Bool, + durationMs: Int64, + synchronizer: MonotonicClockSynchronizer + ) -> RemotePlaybackAnchorResolution { + guard let measuredAge = synchronizer.transportAgeMs( + serverSampleElapsedMs: serverSampleElapsedMs, + localReceiveElapsedMs: localReceiveElapsedMs + ) else { + return .unavailable + } + return resolve( + remotePositionMs: remotePositionMs, + measuredTransportAgeMs: measuredAge, + playbackSpeed: playbackSpeed, + isPlaying: isPlaying, + durationMs: durationMs + ) + } + + static func resolve( + remotePositionMs: Int64, + measuredTransportAgeMs measuredAge: Int64, + playbackSpeed: Double, + isPlaying: Bool, + durationMs: Int64 + ) -> RemotePlaybackAnchorResolution { + guard measuredAge >= -75 else { return .unavailable } + let transportAgeMs = max(measuredAge, 0) + guard transportAgeMs <= staleTransportAgeMs else { + return .stale(transportAgeMs: transportAgeMs) + } + let normalizedSpeed = playbackSpeed.isFinite && playbackSpeed > 0 + ? playbackSpeed + : 1.0 + let advanceMs = isPlaying + ? Int64((Double(transportAgeMs) * normalizedSpeed).rounded()) + : 0 + let projected = remotePositionMs + advanceMs + let position = durationMs > 0 + ? min(max(projected, 0), durationMs) + : max(projected, 0) + return .resolved(positionMs: position, transportAgeMs: transportAgeMs) + } + + static func smoothedPosition( + currentPositionMs: Int64, + targetPositionMs: Int64, + force: Bool = false + ) -> Int64 { + guard !force else { return targetPositionMs } + let delta = targetPositionMs - currentPositionMs + switch abs(delta) { + case 0..<80: + return currentPositionMs + case 80...400: + return currentPositionMs + delta / 2 + default: + return targetPositionMs + } + } +} + +struct CurrentWordOrderingFence { + private(set) var generation: Int64 = -1 + private(set) var sequence: Int64 = -1 + private(set) var positionMs: Int64 = -1 + + mutating func shouldAccept( + generation incomingGeneration: Int64, + sequence incomingSequence: Int64, + positionMs incomingPositionMs: Int64 + ) -> Bool { + if incomingGeneration > 0, incomingSequence > 0 { + if incomingGeneration < generation || + (incomingGeneration == generation && incomingSequence <= sequence) { + return false + } + } + if positionMs >= 0, + incomingPositionMs < positionMs, + positionMs - incomingPositionMs <= 1_500 { + return false + } + if incomingGeneration > 0, incomingSequence > 0 { + generation = incomingGeneration + sequence = incomingSequence + } + positionMs = incomingPositionMs + return true + } + + mutating func reset() { + generation = -1 + sequence = -1 + positionMs = -1 + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index eb9bb89..ad010f2 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -1,4 +1,5 @@ import CoreBluetooth +import Combine import Foundation import UIKit @@ -17,6 +18,8 @@ private let LIVE_ACTIVITY_WRITE_STALL_MS: Int64 = 2_000 private let VOLUME_SEND_THROTTLE_MS: Int64 = 120 private let VOLUME_PENDING_TTL_MS: Int64 = 1_000 private let AUTO_RECONNECT_LAST_PERIPHERAL_KEY = "lastSonyPeripheralIdentifier" +private let CENTRAL_RESTORE_IDENTIFIER = "com.musicblecontroller.sony.central.v1" +private let DEBUG_SMOKE_LAUNCH_ARGUMENTS_FILE = "SmokeLaunchArguments.txt" private let FAST_RETRIEVE_CONNECT_TIMEOUT_MS: Int64 = 1_800 private let DEFAULT_CONNECT_TIMEOUT_MS: Int64 = 8_000 private let CONNECTION_HEALTH_TICK_MS: Int64 = 3_000 @@ -25,12 +28,84 @@ private let CONNECTION_HEALTH_PAUSED_SUSPECT_MS: Int64 = 30_000 private let CONNECTION_HEALTH_PROBE_TIMEOUT_MS: Int64 = 3_000 private let CONNECTION_HEALTH_HARD_RECONNECT_MIN_INTERVAL_MS: Int64 = 5_000 private let CONNECTION_SUBSCRIBE_NOTIFY_TIMEOUT_MS: Int64 = 5_000 +private let CORE_BLUETOOTH_RESTORE_TIMEOUT_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_CONNECTED_MIN_HOLD_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_DISCONNECTED_CONFIRM_MS: Int64 = 1_000 private let COMMAND_WRITE_CALLBACK_TIMEOUT_MS: Int64 = 2_500 +private let COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS: Int64 = 3_000 +private let COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT = 2 +private let FOREGROUND_LINK_VALIDATION_TIMEOUT_MS: Int64 = 5_000 +private let FOREGROUND_INFLIGHT_SETTLE_TIMEOUT_MS: Int64 = 3_000 private let FULL_LYRICS_REQUEST_DEDUP_WINDOW_MS: Int64 = 1_500 private let FULL_LYRICS_REQUEST_START_TIMEOUT_MS: Int64 = 3_000 private let FULL_LYRICS_REQUEST_START_MAX_RETRIES = 2 +private let LYRIC_SECONDARY_START_TIMEOUT_MS: Int64 = 3_000 +private let LYRIC_SECONDARY_IDLE_TIMEOUT_MS: Int64 = 3_000 +private let LYRIC_SECONDARY_TOTAL_TIMEOUT_MS: Int64 = 10_000 +private let LYRIC_SECONDARY_MAX_RETRIES = 1 +private let LYRIC_SECONDARY_FAILURE_COOLDOWN_MS: Int64 = 10_000 +private let CLOCK_SYNC_BOOTSTRAP_SAMPLE_COUNT = 5 +private let CLOCK_SYNC_REFRESH_SAMPLE_COUNT = 3 +private let CLOCK_SYNC_SAMPLE_SPACING_MS: Int64 = 150 +private let CLOCK_SYNC_REFRESH_INTERVAL_MS: Int64 = 120_000 +private let CLOCK_SYNC_PROBE_TTL_MS: Int64 = 5_000 + +enum CommandWriteTimeoutAction: Equatable { + case suspendUntilForeground + case extendWithoutAdvancingQueue + case reconnect +} + +enum CommandWriteTimeoutPolicy { + static func action( + appIsActive: Bool, + transportReady: Bool, + timeoutCountAfterIncrement: Int, + reconnectThreshold: Int + ) -> CommandWriteTimeoutAction { + guard appIsActive else { return .suspendUntilForeground } + guard transportReady else { return .reconnect } + return timeoutCountAfterIncrement < reconnectThreshold + ? .extendWithoutAdvancingQueue + : .reconnect + } +} + +enum LyricSecondaryLoadState: Equatable { + case idle + case loading + case ready + case unavailable + case failed(reason: String) +} + +enum LyricSecondaryRetryAction: Equatable { + case retry + case markUnavailable + case markFailed +} + +enum LyricSecondaryRetryPolicy { + static func action( + explicitlyUnavailable: Bool, + retryCount: Int, + maximumRetries: Int = LYRIC_SECONDARY_MAX_RETRIES + ) -> LyricSecondaryRetryAction { + if explicitlyUnavailable { + return .markUnavailable + } + return retryCount < maximumRetries ? .retry : .markFailed + } +} + +enum SystemReconnectPolicy { + static func shouldScheduleManualReconnect( + autoReconnectEnabled: Bool, + systemIsReconnecting: Bool + ) -> Bool { + autoReconnectEnabled && !systemIsReconnecting + } +} struct LyricWord: Identifiable, Equatable { let id: Int @@ -98,10 +173,26 @@ private struct LyricSecondaryTransfer { let transferId: String let mode: LyricSecondaryMode let itemCount: Int + let requestToken: UUID + let connectionAttemptId: UUID var lines: [Int: LyricSecondaryLineParts] } -final class BLETestManager: NSObject, ObservableObject { +private struct LyricSecondaryRequest { + let trackId: String + let mode: LyricSecondaryMode + let token: UUID + let connectionAttemptId: UUID + let retryCount: Int +} + +private struct ClockSyncProbe { + let sequence: UInt64 + let clientSendElapsedMs: Int64 + let clientSendDate: Date +} + +final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { private static let logTimestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -111,42 +202,113 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var appExperienceMode: AppExperienceMode = PreferencesStore.shared.appExperienceMode @Published private(set) var mode = "BLE Central / GATT Client" - @Published private(set) var connectionStatus = "未连接" - @Published private(set) var logs: [String] = [] - @Published private(set) var title = "-" - @Published private(set) var artist = "-" - @Published private(set) var album = "-" - @Published private(set) var lyric = "" - @Published private(set) var fullLyrics: [LyricLine] = [] - @Published private(set) var fullLyricsTrackId = "" - @Published private(set) var isFullLyricsCurrent = false - @Published private(set) var isFullLyricsReceiving = false + @Published private(set) var connectionStatus = "未连接" { + didSet { syncConnectionStore() } + } + @Published private(set) var logs: [String] = [] { + didSet { syncDiagnosticsStore() } + } + @Published private(set) var title = "-" { + didSet { syncPlaybackMetadataStore() } + } + @Published private(set) var artist = "-" { + didSet { syncPlaybackMetadataStore() } + } + @Published private(set) var album = "-" { + didSet { syncPlaybackMetadataStore() } + } + @Published private(set) var lyric = "" { + didSet { syncLiveLyricsStore() } + } + @Published private(set) var fullLyrics: [LyricLine] = [] { + didSet { syncFullLyricsStore() } + } + @Published private(set) var fullLyricsTrackId = "" { + didSet { syncFullLyricsStore() } + } + @Published private(set) var isFullLyricsCurrent = false { + didSet { syncFullLyricsStore() } + } + @Published private(set) var isFullLyricsReceiving = false { + didSet { syncFullLyricsStore() } + } + @Published private(set) var translationLyricsState: LyricSecondaryLoadState = .idle { + didSet { syncFullLyricsStore() } + } + @Published private(set) var romanizationLyricsState: LyricSecondaryLoadState = .idle { + didSet { syncFullLyricsStore() } + } @Published private(set) var lyricDiagnostic: LyricDiagnostic? - @Published private(set) var lyricDiagnosticLoading = false + @Published private(set) var lyricDiagnosticLoading = false { + didSet { syncDiagnosticsStore() } + } @Published private(set) var lyricDiagnosticLastUpdatedAt: Date? - @Published private(set) var isPlaying = false - @Published private(set) var positionMs: Int64 = 0 - @Published private(set) var displayPositionMs: Int64 = 0 - @Published private(set) var durationMs: Int64 = 0 - @Published private(set) var seekPositionMs: Int64 = 0 - @Published private(set) var isSeeking = false - @Published private(set) var volumeCurrent = 0 - @Published private(set) var volumeMax = 0 - @Published private(set) var volumeSeekValue = 0 - @Published private(set) var isVolumeSeeking = false - @Published private(set) var albumArtImage: UIImage? - @Published private(set) var connectedDeviceName = "-" - @Published private(set) var artworkDisplayQuality: ArtworkDisplayQuality = .placeholder - @Published private(set) var artworkEnhancementStatus = ArtworkEnhancementDebugStatus() + @Published private(set) var isPlaying = false { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var positionMs: Int64 = 0 { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var displayPositionMs: Int64 = 0 { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var durationMs: Int64 = 0 { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var seekPositionMs: Int64 = 0 { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var isSeeking = false { + didSet { syncPlaybackTimelineStore() } + } + @Published private(set) var volumeCurrent = 0 { + didSet { syncPlaybackVolumeStore() } + } + @Published private(set) var volumeMax = 0 { + didSet { syncPlaybackVolumeStore() } + } + @Published private(set) var volumeSeekValue = 0 { + didSet { syncPlaybackVolumeStore() } + } + @Published private(set) var isVolumeSeeking = false { + didSet { syncPlaybackVolumeStore() } + } + @Published private(set) var albumArtImage: UIImage? { + didSet { syncArtworkStore() } + } + @Published private(set) var connectedDeviceName = "-" { + didSet { syncConnectionStore() } + } + @Published private(set) var artworkDisplayQuality: ArtworkDisplayQuality = .placeholder { + didSet { syncArtworkStore() } + } + @Published private(set) var artworkEnhancementStatus = ArtworkEnhancementDebugStatus() { + didSet { syncArtworkStore() } + } + @Published private(set) var isShowingLastNowPlayingSnapshot = false { + didSet { syncArtworkStore() } + } + @Published private(set) var mediaLoadingState = MediaLoadingState() { + didSet { + syncLiveLyricsStore() + syncArtworkStore() + } + } @Published private(set) var artworkEnhancementTargetPixelSize = 780 @Published private(set) var artworkEnhancementSharpness = 0.30 @Published private(set) var remoteLogText = "" @Published private(set) var remoteLogCopyStatus = "" - @Published private(set) var isRemoteLogTransferInProgress = false + @Published private(set) var isRemoteLogTransferInProgress = false { + didSet { syncDiagnosticsStore() } + } @Published private(set) var mediaFieldDumpText = "" @Published private(set) var mediaFieldDumpCopyStatus = "" - @Published private(set) var isMediaFieldDumpReceiving = false + @Published private(set) var isMediaFieldDumpReceiving = false { + didSet { syncDiagnosticsStore() } + } @Published private(set) var mediaFieldDumpProgressText = "" + @Published private(set) var automaticLyricSyncEnabled = + PreferencesStore.shared.automaticLyricSyncEnabled @Published private(set) var karaokeOffsetMs: Int64 = Int64(PreferencesStore.shared.lyricOffsetMs) @Published private(set) var localLogActionStatus = "" @Published private(set) var liveActivityControlStatus = LiveActivityControlStatus() @@ -155,8 +317,12 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var isPlaybackHistorySyncing = false @Published private(set) var playbackHistoryStatus = "" @Published private(set) var autoReconnectEnabled = PreferencesStore.shared.autoReconnectEnabled - @Published private(set) var autoReconnectState = AutoReconnectState.idle.rawValue - @Published private(set) var autoReconnectAttempt = 0 + @Published private(set) var autoReconnectState = AutoReconnectState.idle.rawValue { + didSet { syncConnectionStore() } + } + @Published private(set) var autoReconnectAttempt = 0 { + didSet { syncConnectionStore() } + } @Published private(set) var autoReconnectNextRetryAt: Date? @Published private(set) var autoReconnectWorkItemExists = false @Published private(set) var autoReconnectScheduledAgeMs: Int64 = -1 @@ -173,28 +339,87 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var autoReconnectLastSubscribeCostMs: Int64 = 0 @Published private(set) var manualReconnectCount = 0 @Published private(set) var autoReconnectCount = 0 - @Published private(set) var connectionHealthState = ConnectionHealthState.disconnected.rawValue + @Published private(set) var connectionHealthState = ConnectionHealthState.disconnected.rawValue { + didSet { syncConnectionStore() } + } @Published private(set) var connectionHealthLastNotifyAgeMs: Int64 = -1 @Published private(set) var connectionHealthLastProbeAtText = "-" @Published private(set) var connectionHealthProbeInFlight = false - @Published private(set) var connectionHealthLastHardReconnectReason = "-" - @Published private(set) var connectionHealthAttemptId = "-" + @Published private(set) var connectionHealthLastHardReconnectReason = "-" { + didSet { syncDiagnosticsStore() } + } + @Published private(set) var connectionHealthAttemptId = "-" { + didSet { syncDiagnosticsStore() } + } @Published private(set) var connectionHealthPeripheralState = "-" - @Published private(set) var connectionHealthCharacteristicReady = false - @Published private(set) var connectionDisplayState = ConnectionDisplayState.disconnected.rawValue + @Published private(set) var connectionHealthCharacteristicReady = false { + didSet { syncConnectionStore() } + } + @Published private(set) var connectionDisplayState = ConnectionDisplayState.disconnected.rawValue { + didSet { syncConnectionStore() } + } @Published private(set) var connectionHealthSuspectCount = 0 @Published private(set) var connectionHealthStaleCount = 0 @Published private(set) var connectionHealthHardReconnectCount = 0 @Published private(set) var connectionHealthMaxNotifyGapMs: Int64 = 0 - @Published private(set) var currentWordLineIndex = -1 - @Published private(set) var currentWordIndex = -1 + @Published private(set) var currentWordLineIndex = -1 { + didSet { syncLiveLyricsStore() } + } + @Published private(set) var currentWordIndex = -1 { + didSet { syncLiveLyricsStore() } + } @Published private(set) var currentWordPushCount: Int64 = 0 @Published private(set) var currentWordDropCount: Int64 = 0 @Published private(set) var currentWordAverageUpdateIntervalMs: Int64 = 0 @Published private(set) var currentWordLastLatencyMs: Int64 = 0 + @Published private(set) var lyricAutomaticCompensationMs: Int64 = 0 + @Published private(set) var lyricClockBestRoundTripMs: Int64 = 0 + @Published private(set) var lyricClockOffsetJitterMs: Int64 = 0 + @Published private(set) var lyricClockSampleCount = 0 + @Published private(set) var lyricClockSyncConfident = false + + let connectionStore = ConnectionStore() + let playbackStore = PlaybackStore() + let lyricsStore = LyricsStore() + let artworkStore = ArtworkStore() + let diagnosticsStore = DiagnosticsStore() + + // Transitional read-only slices keep older diagnostic views and tests + // source-compatible while production UI observes the focused stores above. + let connectionStateModel = ObservableStateSlice(BLEConnectionViewState()) + let playbackStateModel = ObservableStateSlice(BLEPlaybackViewState()) + let lyricsStateModel = ObservableStateSlice(BLELyricsViewState()) + let artworkStateModel = ObservableStateSlice(BLEArtworkViewState()) + + @Published private(set) var bluetoothAvailability: BLEBluetoothAvailability = .unknown { + didSet { syncConnectionStore() } + } + @Published private(set) var negotiatedV3Features: BLEProtocolV3Features = [] + @Published private(set) var serverSessionId = "-" + @Published private(set) var lastServerEventSequence: UInt64 = 0 + @Published private(set) var lastCommandErrorSummary = "-" private let preferences = PreferencesStore.shared - private lazy var centralManager = CBCentralManager(delegate: self, queue: nil) + private let inboundPipeline = BLEInboundPipeline() + private let protocolDecodeQueue = DispatchQueue( + label: "com.musicblecontroller.protocol-decode", + qos: .userInitiated + ) + private let uiLogQueue = DispatchQueue( + label: "com.musicblecontroller.ui-log", + qos: .utility + ) + private var pendingUILogLines: [String] = [] + private var isUILogFlushScheduled = false + private var isUILogStreamingEnabled = false + private lazy var centralManager = CBCentralManager( + delegate: self, + queue: nil, + options: [ + CBCentralManagerOptionRestoreIdentifierKey: CENTRAL_RESTORE_IDENTIFIER, + CBCentralManagerOptionShowPowerAlertKey: true + ] + ) private lazy var peripheralManager = CBPeripheralManager(delegate: self, queue: nil) private var sonyPeripheral: CBPeripheral? @@ -204,6 +429,7 @@ final class BLETestManager: NSObject, ObservableObject { private var commandWriteInflight: [CommandWriteInfo] = [] private var pendingCommandWrites: [PendingCommandWrite] = [] private var commandWriteTimeoutWorkItem: DispatchWorkItem? + private var consecutiveCommandWriteTimeouts = 0 private var volumeWriteInFlightSeq: UInt64? private var lastVolumeSendAtMs: Int64 = 0 private var lastVolumeRequestedValue: Int? @@ -220,18 +446,30 @@ final class BLETestManager: NSObject, ObservableObject { private var lastMainHeartbeatAtMs: Int64 = 0 private var lastMainHeartbeatAppState = "active" private var appLifecycleState = "active" + private var lifecycleGeneration: UInt64 = 0 + private var backgroundEnteredAt: Date? + private var foregroundValidationPending = false + private var foregroundValidationWaitingForInflight = false + private var foregroundValidationCommandSeq: UInt64? + private var foregroundValidationStartedAt: Date? + private var foregroundValidationTimeoutWorkItem: DispatchWorkItem? + private var foregroundInflightSettleWorkItem: DispatchWorkItem? private var firstConnectionReadyAtMs: Int64 = 0 private var lastKaraokeOffsetLogAtMs: Int64 = 0 private var currentTrackID = "" + private var currentTrackGeneration: Int64 = 0 private var currentLiveArtworkKey: String? private var currentLiveArtworkRevision = 0 + private var liveArtworkRevisionFence = LiveActivityArtworkRevisionFence() private var lastLiveActivityRequestAt = Date.distantPast + private var pendingLiveActivityUpdateWorkItem: DispatchWorkItem? private var lastLiveActivityRequestTrackID = "" private var lastLiveActivityLyricTrackID = "" private var lastLiveActivityLyricLineIndex = Int.min private var lastLiveActivityLyricText = "" private var lastLiveActivityCurrentWordSkipLogAtMs: Int64 = 0 private var requestedFullLyricsTrackIDs: Set = [] + private var lastSnapshotQueuedAtMs: Int64 = 0 private var completedFullLyricsTrackIDs: Set = [] private var fullLyricsUnavailableTrackIDs: Set = [] private var fullLyricsDelayedRetryTrackIDs: Set = [] @@ -241,11 +479,19 @@ final class BLETestManager: NSObject, ObservableObject { private var ignoredLyricSecondaryPlaceholderKeys: Set = [] private var pendingLyricSecondaryModes: [LyricSecondaryMode] = [] private var lyricSecondaryTransfer: LyricSecondaryTransfer? + private var activeLyricSecondaryRequest: LyricSecondaryRequest? + private var lyricSecondaryRetryCounts: [String: Int] = [:] + private var lyricSecondaryFailureCooldownUntilMs: [String: Int64] = [:] + private var lyricSecondaryStartTimeoutWorkItem: DispatchWorkItem? + private var lyricSecondaryIdleTimeoutWorkItem: DispatchWorkItem? + private var lyricSecondaryTotalTimeoutWorkItem: DispatchWorkItem? + private var lyricSecondaryDeferredRequestWorkItem: DispatchWorkItem? private var lastAutomaticLyricDiagnosticRequestAt: [String: Date] = [:] private var fullLyricsReceivingTrackID = "" private var fullLyricsExpectedCount = 0 private var fullLyricsChunks: [Int: LyricLine] = [:] private var fullLyricsBinaryTransfer: FullLyricsBinaryTransfer? + private var fullLyricsBinaryDecodingTransferID: String? private var fullLyricsBinaryRetryCounts: [String: Int] = [:] private var fullLyricsBinaryFallbackTrackIDs: Set = [] private var lyricWindowTransfer: LyricWindowTransfer? @@ -278,12 +524,18 @@ final class BLETestManager: NSObject, ObservableObject { private var trackInfoExpectedSize = 0 private var trackInfoExpectedChunks = 0 private var trackInfoChunks: [Int: Data] = [:] + private var trackInfoTransferToken = UUID() private var basePlaybackPositionMs: Int64 = 0 - private var playbackStateReceivedAt = Date() + private var playbackAnchorElapsedMs = Int64( + (ProcessInfo.processInfo.systemUptime * 1_000).rounded() + ) private var progressTimer: Timer? private var lastCurrentWordReceivedAtMs: Int64 = 0 private var currentWordIntervalTotalMs: Int64 = 0 private var currentWordIntervalCount: Int64 = 0 + private var currentWordFence = CurrentWordOrderingFence() + private var lastCurrentWordLoggedLineIndex = -1 + private var lastCurrentWordDiagnosticLogAtMs: Int64 = 0 private let selfHealingEngine = SelfHealingEngine.shared private lazy var albumArtReceiver: AlbumArtReceiver = { let receiver = AlbumArtReceiver(delegate: self) @@ -301,6 +553,7 @@ final class BLETestManager: NSObject, ObservableObject { private var scanTimeoutWorkItem: DispatchWorkItem? private var reconnectWorkItem: DispatchWorkItem? private var reconnectStuckCheckWorkItem: DispatchWorkItem? + private var coreBluetoothRestoreTimeoutWorkItem: DispatchWorkItem? private var reconnectScheduledAt: Date? private var reconnectScheduledDelayMs: Int64 = 0 private var connectTimeoutWorkItem: DispatchWorkItem? @@ -315,6 +568,9 @@ final class BLETestManager: NSObject, ObservableObject { private var currentScanIsAutoReconnect = false private var currentConnectIsAutoReconnect = false private var currentConnectIsRetrievedPeripheral = false + private var coreBluetoothRestoreInProgress = false + private var systemAutoReconnectInProgress = false + private var systemAutoReconnectStartedAt: Date? private var connectionAttemptId = UUID() private var lastStatusNotifyAt: Date? private var lastPlaybackStateAt: Date? @@ -325,13 +581,27 @@ final class BLETestManager: NSObject, ObservableObject { private var reconnectStateSyncRequestedFullLyricsTrackIDs: Set = [] private var connectionReadyAt: Date? private var lastHealthProbeAt: Date? + private var healthProbeCommandSeq: UInt64? private var healthProbeStartedAt: Date? + private var healthPingClockProbeStartedAt: Date? + private var sonyClockOffsetMs: Int64? + private var serverSupportsClockSyncV1 = false + private var clockSynchronizer = MonotonicClockSynchronizer() + private var sonyUnixMinusElapsedMs: Int64? + private var clockSyncProbes: [String: ClockSyncProbe] = [:] + private var clockSyncBootstrapWorkItems: [DispatchWorkItem] = [] + private var clockSyncRefreshWorkItem: DispatchWorkItem? + private var remotePlaybackSpeed = 1.0 + private var lastStaleRemoteAnchorLogAtMs: Int64 = 0 private var healthProbeFailureCount = 0 private var serverProtocolVersion = 1 + private var serverSupportsAlbumArtBinary = false private var serverSupportsFullLyricsZlib = false private var serverSupportsLyricWindow = false private var serverSupportsPing = false private var serverSupportsTransferRetry = false + private var lastMediaLoadStateKeyByResource: [BLEMediaResource: String] = [:] + private var eventSequenceDiagnostics = BLEEventSequenceDiagnostics() private var lastHardReconnectAt: Date? private var connectionDisplayStateChangedAt = Date() private var connectionDisplayWorkItem: DispatchWorkItem? @@ -353,6 +623,15 @@ final class BLETestManager: NSObject, ObservableObject { let volumeReason: String? } + private struct TrackInfoTransferPayload: Decodable, Sendable { + let type: String + let title: String? + let artist: String? + let album: String? + let trackId: String? + let generation: Int64? + } + private enum HistoryRequestKind { case since case page @@ -378,6 +657,11 @@ final class BLETestManager: NSObject, ObservableObject { var chunks: [Int: Data] = [:] } + private enum FullLyricsBinaryDecodeResult { + case success(lines: [LyricLine], compressedSize: Int) + case failure(reason: String) + } + private struct LyricWindowTransfer { let trackId: String let transferId: String @@ -388,7 +672,7 @@ final class BLETestManager: NSObject, ObservableObject { override init() { super.init() - preferences.load() + syncAllStores() syncPreferencesStateFromStore() logAppExperienceModeLoaded() log("[BLE-iOS] app log store ready") @@ -398,13 +682,193 @@ final class BLETestManager: NSObject, ObservableObject { refreshLiveActivityControlStatus() updateAppLifecycleState(UIApplication.shared.applicationState, emitLog: false) registerAppLifecycleDiagnostics() - startProgressTimer() + _ = centralManager + #if DEBUG startMainHeartbeatDiagnostics() + #endif loadCachedPlaybackHistory() + restoreLastNowPlayingSnapshot() syncAlbumArtReceiverState(albumArtReceiver) runDebugSmokeTestIfNeeded() } + private func syncAllStores() { + syncConnectionStore() + syncPlaybackMetadataStore() + syncPlaybackTimelineStore() + syncPlaybackVolumeStore() + syncLiveLyricsStore() + syncFullLyricsStore() + syncArtworkStore() + syncDiagnosticsStore() + } + + private func syncConnectionStore() { + let state = BLEConnectionViewState( + status: connectionStatus, + displayState: connectionDisplayState, + healthState: connectionHealthState, + deviceName: connectedDeviceName, + characteristicReady: connectionHealthCharacteristicReady, + autoReconnectState: autoReconnectState + ) + let deviceName = connectedDeviceName == "-" ? nil : connectedDeviceName + let presentation: BLEConnectionPresentationState + if bluetoothAvailability != .available, + bluetoothAvailability != .unknown { + presentation = .unavailable(bluetoothAvailability) + } else { + switch connectionDisplayState { + case ConnectionDisplayState.connected.rawValue: + presentation = .connected( + deviceName: deviceName ?? "Sony", + health: connectionHealthState + ) + case ConnectionDisplayState.reconnecting.rawValue: + if connectionStatus.contains("搜索") || connectionStatus.contains("扫描") { + presentation = .scanning + } else if connectionStatus.contains("连接") && autoReconnectAttempt == 0 { + presentation = .connecting(deviceName: deviceName) + } else { + presentation = .reconnecting( + deviceName: deviceName, + attempt: autoReconnectAttempt + ) + } + default: + if connectionStatus.contains("未找到") { + presentation = .failed(.deviceNotFound) + } else { + presentation = .disconnected(lastDeviceName: deviceName) + } + } + } + connectionStore.update(state: state, presentation: presentation) + connectionStateModel.update(state) + } + + private func syncPlaybackMetadataStore() { + playbackStore.updateMetadata( + BLEPlaybackMetadataState(title: title, artist: artist, album: album) + ) + syncLegacyPlaybackSlice() + } + + private func syncPlaybackTimelineStore() { + playbackStore.updateTimeline( + BLEPlaybackTimelineState( + isPlaying: isPlaying, + positionMs: positionMs, + displayPositionMs: displayPositionMs, + durationMs: durationMs, + seekPositionMs: seekPositionMs, + isSeeking: isSeeking + ) + ) + syncLegacyPlaybackSlice() + } + + private func syncPlaybackVolumeStore() { + playbackStore.updateVolume( + BLEVolumeViewState( + current: volumeCurrent, + maximum: volumeMax, + seekValue: volumeSeekValue, + isSeeking: isVolumeSeeking + ) + ) + syncLegacyPlaybackSlice() + } + + private func syncLiveLyricsStore() { + lyricsStore.updateLive( + BLELiveLyricState( + text: lyric, + currentWordLineIndex: currentWordLineIndex, + currentWordIndex: currentWordIndex, + loadingStage: mediaLoadingState.lyric + ) + ) + syncLegacyLyricsSlice() + } + + private func syncFullLyricsStore() { + lyricsStore.updateDocument( + BLEFullLyricsViewState( + lines: fullLyrics, + trackId: fullLyricsTrackId, + isCurrent: isFullLyricsCurrent, + isReceiving: isFullLyricsReceiving, + translationState: translationLyricsState, + romanizationState: romanizationLyricsState + ) + ) + syncLegacyLyricsSlice() + } + + private func syncArtworkStore() { + artworkStore.update( + BLEArtworkViewState( + image: albumArtImage, + displayQuality: artworkDisplayQuality, + enhancementStatus: artworkEnhancementStatus, + isRestoredSnapshot: isShowingLastNowPlayingSnapshot, + loadingStage: mediaLoadingState.artwork + ) + ) + artworkStateModel.update(artworkStore.state) + } + + private func syncLegacyPlaybackSlice() { + playbackStateModel.update( + BLEPlaybackViewState( + title: playbackStore.metadata.title, + artist: playbackStore.metadata.artist, + album: playbackStore.metadata.album, + isPlaying: playbackStore.timeline.isPlaying, + positionMs: playbackStore.timeline.positionMs, + displayPositionMs: playbackStore.timeline.displayPositionMs, + durationMs: playbackStore.timeline.durationMs, + seekPositionMs: playbackStore.timeline.seekPositionMs, + isSeeking: playbackStore.timeline.isSeeking, + volumeCurrent: playbackStore.volume.current, + volumeMax: playbackStore.volume.maximum, + volumeSeekValue: playbackStore.volume.seekValue, + isVolumeSeeking: playbackStore.volume.isSeeking + ) + ) + } + + private func syncLegacyLyricsSlice() { + lyricsStateModel.update( + BLELyricsViewState( + lyric: lyricsStore.live.text, + fullLyrics: lyricsStore.document.lines, + fullLyricsTrackId: lyricsStore.document.trackId, + isCurrent: lyricsStore.document.isCurrent, + isReceiving: lyricsStore.document.isReceiving, + currentWordLineIndex: lyricsStore.live.currentWordLineIndex, + currentWordIndex: lyricsStore.live.currentWordIndex, + loadingStage: lyricsStore.live.loadingStage, + translationState: lyricsStore.document.translationState, + romanizationState: lyricsStore.document.romanizationState + ) + ) + } + + private func syncDiagnosticsStore() { + diagnosticsStore.update( + BLEDiagnosticsViewState( + logCount: logs.count, + connectionAttemptId: connectionHealthAttemptId, + lastHardReconnectReason: connectionHealthLastHardReconnectReason, + remoteLogInProgress: isRemoteLogTransferInProgress, + mediaDumpInProgress: isMediaFieldDumpReceiving, + lyricDiagnosticLoading: lyricDiagnosticLoading + ) + ) + } + func toggleAppExperienceMode() { setAppExperienceMode(appExperienceMode.toggled) } @@ -424,15 +888,54 @@ final class BLETestManager: NSObject, ObservableObject { private func syncPreferencesStateFromStore() { appExperienceMode = preferences.appExperienceMode autoReconnectEnabled = preferences.autoReconnectEnabled + automaticLyricSyncEnabled = preferences.automaticLyricSyncEnabled karaokeOffsetMs = Int64(preferences.lyricOffsetMs) } private func syncAlbumArtReceiverState(_ receiver: AlbumArtReceiver) { - albumArtImage = receiver.albumArtImage - artworkDisplayQuality = receiver.artworkDisplayQuality - artworkEnhancementStatus = receiver.artworkEnhancementStatus - artworkEnhancementTargetPixelSize = receiver.artworkEnhancementTargetPixelSize - artworkEnhancementSharpness = receiver.artworkEnhancementSharpness + if albumArtImage !== receiver.albumArtImage { + albumArtImage = receiver.albumArtImage + } + if artworkDisplayQuality != receiver.artworkDisplayQuality { + artworkDisplayQuality = receiver.artworkDisplayQuality + } + if artworkEnhancementStatus != receiver.artworkEnhancementStatus { + artworkEnhancementStatus = receiver.artworkEnhancementStatus + } + if artworkEnhancementTargetPixelSize != receiver.artworkEnhancementTargetPixelSize { + artworkEnhancementTargetPixelSize = receiver.artworkEnhancementTargetPixelSize + } + if artworkEnhancementSharpness != receiver.artworkEnhancementSharpness { + artworkEnhancementSharpness = receiver.artworkEnhancementSharpness + } + let transfer = receiver.transferSnapshot() + let artworkStage: ArtworkLoadingStage + if transfer.state == "failed" || transfer.state == "timeout" { + artworkStage = .failed(reason: transfer.lastFailureReason) + } else if transfer.state == "receiving" || transfer.state == "decoding" { + if transfer.quality.lowercased() == "hq" { + artworkStage = .hq( + received: transfer.receivedChunks, + expected: transfer.totalChunks + ) + } else { + artworkStage = .preview( + received: transfer.receivedChunks, + expected: transfer.totalChunks + ) + } + } else if receiver.artworkDisplayQuality >= .hq { + artworkStage = .hqReady + } else if receiver.albumArtImage != nil { + artworkStage = .previewReady + } else if !receiver.currentAlbumArtID.isEmpty { + artworkStage = .preview(received: 0, expected: 0) + } else { + artworkStage = .idle + } + if mediaLoadingState.artwork != artworkStage { + mediaLoadingState.artwork = artworkStage + } } private func logAppExperienceModeLoaded() { @@ -449,9 +952,18 @@ final class BLETestManager: NSObject, ObservableObject { #if DEBUG runFullLyricsProtocolSelfTest() let defaults = UserDefaults.standard - let arguments = ProcessInfo.processInfo.arguments + var arguments = ProcessInfo.processInfo.arguments + arguments.append(contentsOf: consumeDebugSmokeLaunchArguments()) let markerKey = "smokeTestPreferencesWritten" + if arguments.contains("--smoke-force-v2") { + preferences.forceProtocolV2 = true + log("[SmokeTest] force protocol V2") + } else if arguments.contains("--smoke-force-v3") { + preferences.forceProtocolV2 = false + log("[SmokeTest] force protocol V3") + } if arguments.contains("--smoke-test-preferences") { + let expectedArtworkSize = ArtworkDisplaySizeOption.small.rawValue setAppExperienceMode(.debug) preferences.artworkDisplaySize = .small setKaraokeOffsetMs(300) @@ -461,11 +973,15 @@ final class BLETestManager: NSObject, ObservableObject { guard let self else { return } self.log("[SmokeTest] preferences written") let verified = self.preferences.appExperienceMode == .debug && - self.preferences.artworkDisplaySize.rawValue == 200 && + self.preferences.artworkDisplaySize.rawValue == expectedArtworkSize && self.karaokeOffsetMs == 300 && self.preferences.autoReconnectEnabled if verified { - self.log("[SmokeTest] preferences verified mode=debug artworkDisplaySize=200 lyricOffsetMs=300 autoReconnect=true") + self.log( + "[SmokeTest] preferences verified mode=debug " + + "artworkDisplaySize=\(expectedArtworkSize) " + + "lyricOffsetMs=300 autoReconnect=true" + ) } else { self.log( "[SmokeTest] preferences verification failed " + @@ -477,7 +993,7 @@ final class BLETestManager: NSObject, ObservableObject { } } else if defaults.bool(forKey: markerKey), preferences.appExperienceMode == .debug, - preferences.artworkDisplaySize.rawValue == 200, + preferences.artworkDisplaySize == .small, karaokeOffsetMs == 300, preferences.autoReconnectEnabled { log("[SmokeTest] preferences persisted") @@ -495,6 +1011,31 @@ final class BLETestManager: NSObject, ObservableObject { } #if DEBUG + private func consumeDebugSmokeLaunchArguments() -> [String] { + guard let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + return [] + } + let markerURL = documentsURL.appendingPathComponent( + DEBUG_SMOKE_LAUNCH_ARGUMENTS_FILE, + isDirectory: false + ) + guard let text = try? String(contentsOf: markerURL, encoding: .utf8) else { + return [] + } + try? FileManager.default.removeItem(at: markerURL) + let arguments = text + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { $0.hasPrefix("--smoke-") } + if !arguments.isEmpty { + log("[SmokeTest] restored launch arguments=\(arguments.joined(separator: ","))") + } + return arguments + } + private func runFullLyricsProtocolSelfTest() { let raw = Data( "{\"trackId\":\"fixed\",\"generation\":7,\"lines\":[{\"index\":0,\"timeMs\":0,\"durationMs\":1000,\"text\":\"hello\"}]}" @@ -783,7 +1324,10 @@ final class BLETestManager: NSObject, ObservableObject { private func runSourceCapabilitySequence() { log("[SourceCapabilitySmoke] start") for index in 0..<5 { - let delay = TimeInterval(index * 30) + // The observable display state intentionally settles after the raw + // CoreBluetooth state. Give it a short grace period so the smoke + // request exercises the wire instead of being locally rejected. + let delay = 0.8 + TimeInterval(index * 30) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } self.log( @@ -792,8 +1336,10 @@ final class BLETestManager: NSObject, ObservableObject { ) self.sendGetPlaybackState() self.sendGetFullLyrics(force: true) - let requested = self.requestCurrentHqAlbumArt() - self.log("[SourceCapabilitySmoke] albumArt request result=\(requested)") + let requested = self.requestCurrentHqAlbumArt(forceRefresh: true) + self.log( + "[SourceCapabilitySmoke] albumArt force request result=\(requested)" + ) } } } @@ -937,6 +1483,8 @@ final class BLETestManager: NSObject, ObservableObject { healthCheckWorkItem?.cancel() healthProbeTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem?.cancel() + foregroundInflightSettleWorkItem?.cancel() NotificationCenter.default.removeObserver(self) } @@ -991,6 +1539,13 @@ final class BLETestManager: NSObject, ObservableObject { scheduleReconnect(reason: "enabled", immediate: true) } else { cancelPendingReconnect(reason: "disabled") + if systemAutoReconnectInProgress, let sonyPeripheral { + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil + isConnectingToSony = false + centralManager.cancelPeripheralConnection(sonyPeripheral) + self.sonyPeripheral = nil + } setAutoReconnectState(.idle) } } @@ -1000,6 +1555,10 @@ final class BLETestManager: NSObject, ObservableObject { performHardReconnect(reason: "manual force reconnect", manual: true) } + func resyncCurrentPlaybackFromDevice() { + syncAfterReconnect(reason: "device detail manual resync") + } + private func performHardReconnect(reason: String, manual: Bool) { let now = Date() if !manual, @@ -1010,6 +1569,8 @@ final class BLETestManager: NSObject, ObservableObject { return } lastHardReconnectAt = now + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil connectionHealthHardReconnectCount += 1 log("[BLE-Health] hard reconnect reason=\(reason)") cancelPendingReconnect(reason: manual ? "manual hard reconnect" : "force reconnect") @@ -1140,9 +1701,53 @@ final class BLETestManager: NSObject, ObservableObject { } } + func retryCurrentLyricsFromMain() { + let trackID = currentTrackID + guard !trackID.isEmpty else { return } + fullLyricsRequestStartTimeouts.removeValue(forKey: trackID)?.cancel() + fullLyricsRequestStartRetryCounts.removeValue(forKey: trackID) + requestedFullLyricsTrackIDs.remove(trackID) + completedFullLyricsTrackIDs.remove(trackID) + fullLyricsUnavailableTrackIDs.remove(trackID) + fullLyricsDelayedRetryTrackIDs.remove(trackID) + requestedLyricWindowTrackIDs.remove(trackID) + lyricDiagnostic = nil + lyricDiagnosticLoading = true + mediaLoadingState.lyric = .waitingQqQrc + log("[LyricRetry] main UI retry current trackId=\(trackID)") + sendCommand( + cmd: "GET_FULL_LYRICS", + extra: [ + "trackId": trackID, + "positionMs": displayPositionMs, + "includeWordsAroundCurrent": true, + "forceRefresh": true, + "format": serverSupportsFullLyricsZlib ? "zlib-json-v1" : "legacy" + ] + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { [weak self] in + guard let self, self.currentTrackID == trackID else { return } + if self.serverSupportsLyricWindow { + self.requestedLyricWindowTrackIDs.remove(trackID) + self.requestLyricWindow(trackID: trackID) + } + self.requestFullLyricsIfNeeded(force: true) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in + guard let self, self.currentTrackID == trackID else { return } + self.requestLyricDiagnostic(manual: true) + } + } + + func retryCurrentAlbumArtFromMain() { + mediaLoadingState.artwork = .preview(received: 0, expected: 0) + let requested = forceRefreshCurrentAlbumArt() + log("[AlbumArtRefresh] main UI retry requested=\(requested)") + } + @discardableResult - func requestCurrentHqAlbumArt() -> Bool { - albumArtReceiver.requestCurrentHqAlbumArt() + func requestCurrentHqAlbumArt(forceRefresh: Bool = false) -> Bool { + albumArtReceiver.requestCurrentHqAlbumArt(forceRefresh: forceRefresh) } @discardableResult @@ -1230,7 +1835,15 @@ final class BLETestManager: NSObject, ObservableObject { pushCount: currentWordPushCount, dropCount: currentWordDropCount, averageUpdateIntervalMs: currentWordAverageUpdateIntervalMs, - lastLatencyMs: currentWordLastLatencyMs + lastLatencyMs: currentWordLastLatencyMs, + automaticSyncEnabled: automaticLyricSyncEnabled, + automaticCompensationMs: lyricAutomaticCompensationMs, + manualFineTuneMs: karaokeOffsetMs, + legacyFallbackMs: legacyLyricFallbackOffsetMs, + clockBestRoundTripMs: lyricClockBestRoundTripMs, + clockOffsetJitterMs: lyricClockOffsetJitterMs, + clockSampleCount: lyricClockSampleCount, + clockSyncConfident: lyricClockSyncConfident ), connection: connection, selfHealing: selfHealing @@ -1241,7 +1854,17 @@ final class BLETestManager: NSObject, ObservableObject { let normalized = min(max(value, -2_000), 2_000) preferences.lyricOffsetMs = Int(normalized) karaokeOffsetMs = normalized - log("[Lyrics-iOS] karaoke offsetMs=\(value)") + log("[Lyrics-iOS] manual fine tune offsetMs=\(value)") + } + + func setAutomaticLyricSyncEnabled(_ enabled: Bool) { + guard automaticLyricSyncEnabled != enabled else { return } + preferences.automaticLyricSyncEnabled = enabled + automaticLyricSyncEnabled = enabled + if !enabled { + lyricAutomaticCompensationMs = 0 + } + log("[Lyrics-iOS] automatic sync enabled=\(enabled)") } func refreshLiveActivityAppearance() { @@ -1261,6 +1884,25 @@ final class BLETestManager: NSObject, ObservableObject { } } + func setUILogStreamingEnabled(_ enabled: Bool) { + uiLogQueue.async { [weak self] in + guard let self else { return } + self.isUILogStreamingEnabled = enabled + if !enabled { + self.pendingUILogLines.removeAll(keepingCapacity: true) + self.isUILogFlushScheduled = false + return + } + AppLogStore.shared.readRecentText { [weak self] text in + guard let self else { return } + self.logs = text + .split(separator: "\n", omittingEmptySubsequences: true) + .suffix(300) + .map(String.init) + } + } + } + func clearIOSLogs() { AppLogStore.shared.clear { [weak self] in guard let self else { return } @@ -1303,7 +1945,124 @@ final class BLETestManager: NSObject, ObservableObject { } func karaokePositionMs(rawPositionMs: Int64) -> Int64 { - rawPositionMs + karaokeOffsetMs + rawPositionMs + karaokeOffsetMs + legacyLyricFallbackOffsetMs + } + + private var legacyLyricFallbackOffsetMs: Int64 { + automaticLyricSyncEnabled && + !serverSupportsClockSyncV1 && + karaokeOffsetMs == 0 + ? 600 + : 0 + } + + private func resolveRemotePlaybackAnchor( + object: [String: Any], + remotePositionMs: Int64, + timestampMs: Int64 = 0, + isPlaying: Bool + ) -> RemotePlaybackAnchorResolution { + guard automaticLyricSyncEnabled else { + return .unavailable + } + let parsedSpeed = Self.doubleValue(object["speed"]) + let speed = parsedSpeed.isFinite && parsedSpeed > 0 + ? parsedSpeed + : remotePlaybackSpeed + let sampleElapsedMs = Self.int64Value(object["sampleMono"]) + if sampleElapsedMs > 0 { + let resolved = RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: remotePositionMs, + serverSampleElapsedMs: sampleElapsedMs, + localReceiveElapsedMs: monotonicTimeMs(), + playbackSpeed: speed, + isPlaying: isPlaying, + durationMs: durationMs, + synchronizer: clockSynchronizer + ) + if resolved != .unavailable { + return resolved + } + } + if timestampMs > 0, + serverSupportsClockSyncV1, + lyricClockSyncConfident, + let sonyUnixMinusElapsedMs { + let bridgedServerSampleElapsedMs = timestampMs - sonyUnixMinusElapsedMs + let resolved = RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: remotePositionMs, + serverSampleElapsedMs: bridgedServerSampleElapsedMs, + localReceiveElapsedMs: monotonicTimeMs(), + playbackSpeed: speed, + isPlaying: isPlaying, + durationMs: durationMs, + synchronizer: clockSynchronizer + ) + if resolved != .unavailable { + return resolved + } + } + if timestampMs > 0, + let sonyClockOffsetMs, + !serverSupportsClockSyncV1 || lyricClockSyncConfident { + let mappedSampleTimeMs = timestampMs - sonyClockOffsetMs + let measuredAgeMs = currentTimeMs() - mappedSampleTimeMs + return RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: remotePositionMs, + measuredTransportAgeMs: measuredAgeMs, + playbackSpeed: speed, + isPlaying: isPlaying, + durationMs: durationMs + ) + } + return .unavailable + } + + private func calibratedRemotePosition( + from resolution: RemotePlaybackAnchorResolution, + remotePositionMs: Int64, + source: String + ) -> Int64? { + switch resolution { + case .unavailable: + lyricAutomaticCompensationMs = 0 + return remotePositionMs + case let .stale(transportAgeMs): + lyricAutomaticCompensationMs = transportAgeMs + let nowMs = currentTimeMs() + if nowMs - lastStaleRemoteAnchorLogAtMs >= 1_000 { + lastStaleRemoteAnchorLogAtMs = nowMs + log( + "[ClockSync] stale anchor discarded source=\(source) " + + "ageMs=\(transportAgeMs) positionMs=\(remotePositionMs)" + ) + } + return nil + case let .resolved(targetPositionMs, transportAgeMs): + lyricAutomaticCompensationMs = automaticLyricSyncEnabled + ? transportAgeMs + : 0 + return RemotePlaybackAnchorPolicy.smoothedPosition( + currentPositionMs: displayPositionMs, + targetPositionMs: targetPositionMs + ) + } + } + + private func locallyResolvedWord( + positionMs: Int64 + ) -> (lineIndex: Int, wordIndex: Int, text: String)? { + guard isSameTrackId(incoming: fullLyricsTrackId, current: currentTrackID), + let arrayIndex = currentLyricIndex(lines: fullLyrics, positionMs: positionMs), + fullLyrics.indices.contains(arrayIndex) else { + return nil + } + let line = fullLyrics[arrayIndex] + var wordIndex = -1 + for (index, word) in line.words.enumerated() where word.startMs <= positionMs { + wordIndex = index + } + return (line.index, wordIndex, line.text) } func resolveCurrentLyric( @@ -1362,7 +2121,10 @@ final class BLETestManager: NSObject, ObservableObject { guard now - lastKaraokeOffsetLogAtMs >= 3_000 else { return } lastKaraokeOffsetLogAtMs = now log( - "[Lyrics-iOS] karaoke offsetMs=\(karaokeOffsetMs) " + + "[Lyrics-iOS] automaticSync=\(automaticLyricSyncEnabled) " + + "autoCompensationMs=\(lyricAutomaticCompensationMs) " + + "manualOffsetMs=\(karaokeOffsetMs) " + + "legacyFallbackMs=\(legacyLyricFallbackOffsetMs) " + "rawPosition=\(rawPositionMs) " + "effectivePosition=\(karaokePositionMs(rawPositionMs: rawPositionMs))" ) @@ -1521,7 +2283,12 @@ final class BLETestManager: NSObject, ObservableObject { let startMs = currentTimeMs() var payload = extra payload["cmd"] = cmd - payload["time"] = startMs + // Capability payloads sit close to the common 182-byte ATT limit. + // Their wall-clock field was never consumed by Sony, so omit it to + // leave room for additive capability flags without fragmenting writes. + if cmd != "CLIENT_CAPABILITIES" { + payload["time"] = startMs + } payload["seq"] = seq let connected = sonyPeripheral?.state == .connected @@ -1554,7 +2321,7 @@ final class BLETestManager: NSObject, ObservableObject { guard let sonyPeripheral, sonyPeripheral.state == .connected, - let sonyCommandCharacteristic else { + sonyCommandCharacteristic != nil else { let reason = isReconnectInProgress ? "reconnecting" : "not_connected" ctrlLog("[CTRL-iOS] write skipped seq=\(seq) cmd=\(cmd) reason=\(reason)") log("[Command] send failed \(cmd): \(reason)") @@ -1579,7 +2346,10 @@ final class BLETestManager: NSObject, ObservableObject { } private func enqueueCommandWrite(_ request: PendingCommandWrite) { - if request.isControl, + if foregroundValidationPending, + foregroundValidationCommandSeq == request.seq { + pendingCommandWrites.insert(request, at: 0) + } else if request.isControl, let firstBackgroundIndex = pendingCommandWrites.firstIndex(where: { !$0.isControl }) { pendingCommandWrites.insert(request, at: firstBackgroundIndex) } else { @@ -1595,6 +2365,13 @@ final class BLETestManager: NSObject, ObservableObject { } private func flushCommandWriteQueue() { + // Background execution is opportunistic. Keep queued synchronization + // work frozen until foreground, while still allowing a user initiated + // Live Activity control (which is ordered ahead of background work). + if appLifecycleState != "active", + pendingCommandWrites.first?.isControl != true { + return + } guard commandWriteInflight.isEmpty, !pendingCommandWrites.isEmpty, let sonyPeripheral, @@ -1639,24 +2416,40 @@ final class BLETestManager: NSObject, ObservableObject { } log("[BLE] write requested \(request.payloadText)") scheduleCommandWriteTimeout(seq: request.seq, cmd: request.cmd) + if healthProbeCommandSeq == request.seq { + startHealthProbeResponseTimeout(seq: request.seq, command: request.cmd) + } + if foregroundValidationPending, + foregroundValidationCommandSeq == request.seq { + startForegroundValidationTimeout(seq: request.seq) + } } private func scheduleCommandWriteTimeout(seq: UInt64, cmd: String) { commandWriteTimeoutWorkItem?.cancel() + guard appLifecycleState == "active" else { + commandWriteTimeoutWorkItem = nil + ctrlLog( + "[CTRL-iOS] write timeout suspended seq=\(seq) cmd=\(cmd) " + + "appState=\(appLifecycleState)" + ) + return + } + let expectedLifecycleGeneration = lifecycleGeneration let item = DispatchWorkItem { [weak self] in guard let self, + self.appLifecycleState == "active", + self.lifecycleGeneration == expectedLifecycleGeneration, self.commandWriteInflight.first?.seq == seq else { return } + self.commandWriteTimeoutWorkItem = nil self.ctrlLog( "[CTRL-iOS] write timeout seq=\(seq) cmd=\(cmd) " + "timeoutMs=\(COMMAND_WRITE_CALLBACK_TIMEOUT_MS) " + "pending=\(self.pendingCommandWrites.count)" ) - self.performHardReconnect( - reason: "command write callback timeout cmd=\(cmd) seq=\(seq)", - manual: false - ) + self.handleCommandWriteCallbackTimeout(seq: seq, cmd: cmd) } commandWriteTimeoutWorkItem = item DispatchQueue.main.asyncAfter( @@ -1665,6 +2458,50 @@ final class BLETestManager: NSObject, ObservableObject { ) } + private func handleCommandWriteCallbackTimeout(seq: UInt64, cmd: String) { + let now = Date() + let recentNotifyAgeMs = lastStatusNotifyAt.map { + Int64(now.timeIntervalSince($0) * 1_000) + } ?? Int64.max + let transportReady = sonyPeripheral?.state == .connected && + sonyCommandCharacteristic != nil + let nextTimeoutCount = consecutiveCommandWriteTimeouts + 1 + let action = CommandWriteTimeoutPolicy.action( + appIsActive: appLifecycleState == "active", + transportReady: transportReady, + timeoutCountAfterIncrement: nextTimeoutCount, + reconnectThreshold: COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT + ) + switch action { + case .suspendUntilForeground: + ctrlLog( + "[CTRL-iOS] write timeout ignored during lifecycle suspension " + + "seq=\(seq) cmd=\(cmd) appState=\(appLifecycleState)" + ) + case .extendWithoutAdvancingQueue: + consecutiveCommandWriteTimeouts = nextTimeoutCount + // Never pop the in-flight request here. CoreBluetooth can deliver + // its callback late; advancing the queue would then attribute that + // callback to a different command. + setConnectionHealth( + .suspect, + reason: "write callback delayed recentNotifyAgeMs=\(recentNotifyAgeMs)" + ) + ctrlLog( + "[CTRL-iOS] write timeout extended seq=\(seq) cmd=\(cmd) " + + "count=\(consecutiveCommandWriteTimeouts) " + + "recentNotify=\(recentNotifyAgeMs <= COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS)" + ) + scheduleCommandWriteTimeout(seq: seq, cmd: cmd) + case .reconnect: + consecutiveCommandWriteTimeouts = nextTimeoutCount + performHardReconnect( + reason: "command write callback timeout x\(nextTimeoutCount) cmd=\(cmd) seq=\(seq)", + manual: false + ) + } + } + func seek(to position: Int64) { sendUserCommand(cmd: "SEEK_TO", extra: ["position": max(position, 0)]) } @@ -1675,7 +2512,7 @@ final class BLETestManager: NSObject, ObservableObject { displayPositionMs = targetPosition seekPositionMs = targetPosition basePlaybackPositionMs = targetPosition - playbackStateReceivedAt = Date() + playbackAnchorElapsedMs = monotonicTimeMs() log("[iOS][Seek] lyric line position=\(targetPosition)") updateLiveActivity(force: true, reason: "seek") seek(to: targetPosition) @@ -1702,6 +2539,16 @@ final class BLETestManager: NSObject, ObservableObject { private func enqueueLyricSecondaryIfNeeded(mode: LyricSecondaryMode, trackID: String) { let key = lyricSecondaryKey(trackID: trackID, mode: mode) + let nowMs = currentTimeMs() + if let cooldownUntil = lyricSecondaryFailureCooldownUntilMs[key], + cooldownUntil > nowMs { + log( + "[Lyrics-iOS] secondary cooldown mode=\(mode.rawValue) " + + "remainingMs=\(cooldownUntil - nowMs)" + ) + return + } + lyricSecondaryFailureCooldownUntilMs.removeValue(forKey: key) guard !completedLyricSecondaryKeys.contains(key), !requestedLyricSecondaryKeys.contains(key), !pendingLyricSecondaryModes.contains(mode), @@ -1710,10 +2557,12 @@ final class BLETestManager: NSObject, ObservableObject { } requestedLyricSecondaryKeys.insert(key) pendingLyricSecondaryModes.append(mode) + setLyricSecondaryState(.loading, mode: mode) log("[Lyrics-iOS] secondary queued mode=\(mode.rawValue) trackId=\(trackID)") } private func requestNextLyricSecondaryIfPossible() { + guard activeLyricSecondaryRequest == nil else { return } guard lyricSecondaryTransfer == nil else { return } guard !pendingLyricSecondaryModes.isEmpty else { return } let trackID = currentTrackID @@ -1722,13 +2571,27 @@ final class BLETestManager: NSObject, ObservableObject { !fullLyrics.isEmpty else { return } if isInStartupLoadWindow() { let delay = startupLoadRemainingDelay() + guard lyricSecondaryDeferredRequestWorkItem == nil else { return } log("[StartupLoad] defer request=GET_LYRIC_SECONDARY reason=first connection warmup delayMs=\(Int(delay * 1_000))") - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + let item = DispatchWorkItem { [weak self] in + self?.lyricSecondaryDeferredRequestWorkItem = nil self?.requestNextLyricSecondaryIfPossible() } + lyricSecondaryDeferredRequestWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) return } let mode = pendingLyricSecondaryModes.removeFirst() + let key = lyricSecondaryKey(trackID: trackID, mode: mode) + let request = LyricSecondaryRequest( + trackId: trackID, + mode: mode, + token: UUID(), + connectionAttemptId: connectionAttemptId, + retryCount: lyricSecondaryRetryCounts[key] ?? 0 + ) + activeLyricSecondaryRequest = request + setLyricSecondaryState(.loading, mode: mode) log("[Lyrics-iOS] secondary request mode=\(mode.rawValue) trackId=\(trackID)") sendCommand( cmd: "GET_LYRIC_SECONDARY", @@ -1737,33 +2600,171 @@ final class BLETestManager: NSObject, ObservableObject { "mode": mode.rawValue ] ) + scheduleLyricSecondaryStartTimeout(for: request) + scheduleLyricSecondaryTotalTimeout(for: request) } private func lyricSecondaryKey(trackID: String, mode: LyricSecondaryMode) -> String { "\(trackID)|\(mode.rawValue)" } - func beginSeeking() { - guard durationMs > 0 else { return } - isSeeking = true - seekPositionMs = displayPositionMs + private func setLyricSecondaryState( + _ state: LyricSecondaryLoadState, + mode: LyricSecondaryMode + ) { + switch mode { + case .translation: + guard translationLyricsState != state else { return } + translationLyricsState = state + case .romanization: + guard romanizationLyricsState != state else { return } + romanizationLyricsState = state + } } - func updateSeekPosition(_ value: Double) { - let maximum = max(durationMs, 0) - seekPositionMs = Int64(value.rounded()).clamped(to: 0...maximum) + private func scheduleLyricSecondaryStartTimeout(for request: LyricSecondaryRequest) { + lyricSecondaryStartTimeoutWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, + self.activeLyricSecondaryRequest?.token == request.token, + self.lyricSecondaryTransfer == nil else { return } + self.handleLyricSecondaryFailure( + trackID: request.trackId, + mode: request.mode, + reason: "start timeout", + explicitlyUnavailable: false + ) + } + lyricSecondaryStartTimeoutWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(Int(LYRIC_SECONDARY_START_TIMEOUT_MS)), + execute: item + ) } - func finishSeeking() { - guard isSeeking else { return } + private func scheduleLyricSecondaryIdleTimeout(for request: LyricSecondaryRequest) { + lyricSecondaryIdleTimeoutWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, + self.activeLyricSecondaryRequest?.token == request.token, + self.lyricSecondaryTransfer?.requestToken == request.token else { return } + self.handleLyricSecondaryFailure( + trackID: request.trackId, + mode: request.mode, + reason: "chunk idle timeout", + explicitlyUnavailable: false + ) + } + lyricSecondaryIdleTimeoutWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(Int(LYRIC_SECONDARY_IDLE_TIMEOUT_MS)), + execute: item + ) + } + + private func scheduleLyricSecondaryTotalTimeout(for request: LyricSecondaryRequest) { + lyricSecondaryTotalTimeoutWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, + self.activeLyricSecondaryRequest?.token == request.token else { return } + self.handleLyricSecondaryFailure( + trackID: request.trackId, + mode: request.mode, + reason: "total timeout", + explicitlyUnavailable: false + ) + } + lyricSecondaryTotalTimeoutWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(Int(LYRIC_SECONDARY_TOTAL_TIMEOUT_MS)), + execute: item + ) + } + + private func cancelLyricSecondaryTimeouts() { + lyricSecondaryStartTimeoutWorkItem?.cancel() + lyricSecondaryStartTimeoutWorkItem = nil + lyricSecondaryIdleTimeoutWorkItem?.cancel() + lyricSecondaryIdleTimeoutWorkItem = nil + lyricSecondaryTotalTimeoutWorkItem?.cancel() + lyricSecondaryTotalTimeoutWorkItem = nil + } + + private func handleLyricSecondaryFailure( + trackID: String, + mode: LyricSecondaryMode, + reason: String, + explicitlyUnavailable: Bool + ) { + guard trackID == currentTrackID else { return } + let key = lyricSecondaryKey(trackID: trackID, mode: mode) + let retryCount = activeLyricSecondaryRequest?.retryCount ?? + lyricSecondaryRetryCounts[key] ?? 0 + let action = LyricSecondaryRetryPolicy.action( + explicitlyUnavailable: explicitlyUnavailable, + retryCount: retryCount + ) + cancelLyricSecondaryTimeouts() + activeLyricSecondaryRequest = nil + lyricSecondaryTransfer = nil + + switch action { + case .retry: + let nextRetry = retryCount + 1 + lyricSecondaryRetryCounts[key] = nextRetry + if !pendingLyricSecondaryModes.contains(mode) { + pendingLyricSecondaryModes.insert(mode, at: 0) + } + setLyricSecondaryState(.loading, mode: mode) + log( + "[Lyrics-iOS] secondary retry mode=\(mode.rawValue) " + + "attempt=\(nextRetry) reason=\(reason)" + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, self.currentTrackID == trackID else { return } + self.requestNextLyricSecondaryIfPossible() + } + return + case .markUnavailable: + completedLyricSecondaryKeys.insert(key) + lyricSecondaryRetryCounts.removeValue(forKey: key) + setLyricSecondaryState(.unavailable, mode: mode) + case .markFailed: + requestedLyricSecondaryKeys.remove(key) + lyricSecondaryRetryCounts.removeValue(forKey: key) + lyricSecondaryFailureCooldownUntilMs[key] = + currentTimeMs() + LYRIC_SECONDARY_FAILURE_COOLDOWN_MS + setLyricSecondaryState(.failed(reason: reason), mode: mode) + } + log( + "[Lyrics-iOS] secondary finished mode=\(mode.rawValue) " + + "state=\(action) reason=\(reason)" + ) + requestNextLyricSecondaryIfPossible() + } + + func beginSeeking() { + guard durationMs > 0 else { return } + isSeeking = true + seekPositionMs = displayPositionMs + } + + func updateSeekPosition(_ value: Double) { + let maximum = max(durationMs, 0) + seekPositionMs = Int64(value.rounded()).clamped(to: 0...maximum) + } + + func finishSeeking() { + guard isSeeking else { return } let targetPosition = seekPositionMs.clamped( to: 0...max(durationMs, 0) ) positionMs = targetPosition displayPositionMs = targetPosition basePlaybackPositionMs = targetPosition - playbackStateReceivedAt = Date() + playbackAnchorElapsedMs = monotonicTimeMs() isSeeking = false + resetCurrentWordFence() log("[iOS][Seek] user set position=\(targetPosition)") updateLiveActivity(force: true, reason: "seek") seek(to: targetPosition) @@ -1871,7 +2872,7 @@ final class BLETestManager: NSObject, ObservableObject { log("[VOL-iOS] dropped reason=\(isReconnectInProgress ? "reconnecting" : "not connected") value=\(value)") return } - guard let sonyCommandCharacteristic else { + guard sonyCommandCharacteristic != nil else { log("[VOL-iOS] dropped reason=\(isReconnectInProgress ? "reconnecting" : "characteristic not ready") value=\(value)") return } @@ -2139,10 +3140,27 @@ final class BLETestManager: NSObject, ObservableObject { AppLogStore.shared.appendTimeline(message) } #endif - DispatchQueue.main.async { - self.logs.append("[\(Self.logTimestampFormatter.string(from: Date()))] \(message)") - if self.logs.count > 300 { - self.logs.removeFirst(self.logs.count - 300) + let timestamp = Date() + uiLogQueue.async { [weak self] in + guard let self, self.isUILogStreamingEnabled else { return } + self.pendingUILogLines.append( + "[\(Self.logTimestampFormatter.string(from: timestamp))] \(message)" + ) + guard !self.isUILogFlushScheduled else { return } + self.isUILogFlushScheduled = true + self.uiLogQueue.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + let batch = self.pendingUILogLines + self.pendingUILogLines.removeAll(keepingCapacity: true) + self.isUILogFlushScheduled = false + guard !batch.isEmpty else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.logs.append(contentsOf: batch) + if self.logs.count > 300 { + self.logs.removeFirst(self.logs.count - 300) + } + } } } } @@ -2161,6 +3179,169 @@ final class BLETestManager: NSObject, ObservableObject { Int64(Date().timeIntervalSince1970 * 1_000) } + private func monotonicTimeMs() -> Int64 { + Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded()) + } + + private func resetClockSync(reason: String) { + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + clockSyncRefreshWorkItem?.cancel() + clockSyncRefreshWorkItem = nil + clockSyncProbes.removeAll() + clockSynchronizer.reset() + sonyUnixMinusElapsedMs = nil + sonyClockOffsetMs = nil + lyricAutomaticCompensationMs = 0 + lyricClockBestRoundTripMs = 0 + lyricClockOffsetJitterMs = 0 + lyricClockSampleCount = 0 + lyricClockSyncConfident = false + lastStaleRemoteAnchorLogAtMs = 0 + log("[ClockSync] reset reason=\(reason)") + } + + private func pauseClockSyncScheduling(reason: String) { + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + clockSyncRefreshWorkItem?.cancel() + clockSyncRefreshWorkItem = nil + clockSyncProbes.removeAll() + log("[ClockSync] scheduling paused reason=\(reason)") + } + + private func scheduleClockSyncBootstrap(reason: String) { + guard appLifecycleState == "active", serverSupportsClockSyncV1 else { return } + scheduleClockSyncProbes( + count: CLOCK_SYNC_BOOTSTRAP_SAMPLE_COUNT, + reason: reason + ) + scheduleClockSyncRefresh() + } + + private func scheduleClockSyncProbes(count: Int, reason: String) { + guard appLifecycleState == "active", count > 0 else { return } + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + for index in 0.. Bool { + let sequence = Self.int64Value(object["seq"]) + guard sequence > 0, + let probe = clockSyncProbes.removeValue(forKey: String(sequence)) else { + return false + } + let receivedAtElapsedMs = monotonicTimeMs() + let receivedAt = Date() + let echoedClientElapsedMs = Self.int64Value(object["clientSendElapsedMs"]) + let serverReceiveElapsedMs = Self.int64Value(object["serverReceiveElapsedMs"]) + let serverSendElapsedMs = Self.int64Value(object["serverSendElapsedMs"]) + let sonyTimeMs = Self.int64Value(object["time"]) + if sonyTimeMs > 0 { + let midpointMs = Int64( + (probe.clientSendDate.timeIntervalSince1970 + + receivedAt.timeIntervalSince1970) * 500 + ) + sonyClockOffsetMs = sonyTimeMs - midpointMs + } + guard echoedClientElapsedMs == probe.clientSendElapsedMs, + serverReceiveElapsedMs > 0, + serverSendElapsedMs >= serverReceiveElapsedMs else { + log("[ClockSync] legacy pong seq=\(sequence) clockOffsetMs=\(sonyClockOffsetMs ?? 0)") + return true + } + if sonyTimeMs > 0 { + sonyUnixMinusElapsedMs = sonyTimeMs - serverSendElapsedMs + } + let snapshot = clockSynchronizer.record( + clientSendElapsedMs: probe.clientSendElapsedMs, + serverReceiveElapsedMs: serverReceiveElapsedMs, + serverSendElapsedMs: serverSendElapsedMs, + clientReceiveElapsedMs: receivedAtElapsedMs + ) + if let snapshot { + lyricClockBestRoundTripMs = snapshot.bestRoundTripMs + lyricClockOffsetJitterMs = snapshot.offsetJitterMs + lyricClockSampleCount = snapshot.sampleCount + lyricClockSyncConfident = snapshot.isConfident + log( + "[ClockSync] pong seq=\(sequence) bestRttMs=\(snapshot.bestRoundTripMs) " + + "offsetMs=\(Int64(snapshot.localMinusServerMs.rounded())) " + + "jitterMs=\(snapshot.offsetJitterMs) samples=\(snapshot.sampleCount) " + + "confident=\(snapshot.isConfident)" + ) + } + return true + } + private func isInStartupLoadWindow() -> Bool { guard firstConnectionReadyAtMs > 0 else { return false } return currentTimeMs() - firstConnectionReadyAtMs < 3_000 @@ -2323,6 +3504,7 @@ final class BLETestManager: NSObject, ObservableObject { healthProbeTimeoutWorkItem = nil subscribeNotifyTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem = nil + healthProbeCommandSeq = nil healthProbeStartedAt = nil healthProbeFailureCount = 0 connectionHealthProbeInFlight = false @@ -2337,6 +3519,11 @@ final class BLETestManager: NSObject, ObservableObject { private func startHealthMonitoring(reason: String) { healthCheckWorkItem?.cancel() + guard appLifecycleState == "active" else { + healthCheckWorkItem = nil + log("[BLE-Health] start deferred appState=\(appLifecycleState) reason=\(reason)") + return + } log("[BLE-Health] started reason=\(reason)") updateConnectionHealthDebugFields() scheduleHealthTick() @@ -2344,6 +3531,10 @@ final class BLETestManager: NSObject, ObservableObject { private func scheduleHealthTick() { healthCheckWorkItem?.cancel() + guard appLifecycleState == "active" else { + healthCheckWorkItem = nil + return + } let item = DispatchWorkItem { [weak self] in self?.runHealthTick() } @@ -2356,6 +3547,11 @@ final class BLETestManager: NSObject, ObservableObject { private func runHealthTick() { updateConnectionHealthDebugFields() + guard appLifecycleState == "active" else { + healthCheckWorkItem = nil + log("[BLE-Health] tick suspended appState=\(appLifecycleState)") + return + } guard centralManager.state == .poweredOn else { setConnectionHealth(.disconnected, reason: "bluetooth not powered") return @@ -2399,8 +3595,12 @@ final class BLETestManager: NSObject, ObservableObject { } private func sendHealthProbeIfNeeded(reason: String) { + guard appLifecycleState == "active" else { + log("[BLE-Health] probe deferred appState=\(appLifecycleState) trigger=\(reason)") + return + } let now = Date() - if healthProbeStartedAt != nil { + if healthProbeCommandSeq != nil { log("[BLE-Health] probe skipped reason=in flight trigger=\(reason)") return } @@ -2414,21 +3614,35 @@ final class BLETestManager: NSObject, ObservableObject { return } lastHealthProbeAt = now - healthProbeStartedAt = now - updateConnectionHealthDebugFields() let seq = nextCommandSeq() - log("[BLE-Health] probe sent seq=\(seq) reason=\(reason)") + healthProbeCommandSeq = seq + updateConnectionHealthDebugFields() + log("[BLE-Health] probe queued seq=\(seq) reason=\(reason)") sendCommand(cmd: serverSupportsPing ? "PING" : "GET_PLAYBACK_STATE", seq: seq) - let startedAt = now + } + + private func startHealthProbeResponseTimeout(seq: UInt64, command: String) { + guard healthProbeCommandSeq == seq, + healthProbeStartedAt == nil, + appLifecycleState == "active" else { return } + let startedAt = Date() + healthProbeStartedAt = startedAt + healthPingClockProbeStartedAt = command == "PING" ? startedAt : nil + log("[BLE-Health] probe sent seq=\(seq) cmd=\(command)") + updateConnectionHealthDebugFields() let item = DispatchWorkItem { [weak self] in guard let self, + self.appLifecycleState == "active", + self.healthProbeCommandSeq == seq, let healthProbeStartedAt = self.healthProbeStartedAt, abs(healthProbeStartedAt.timeIntervalSince(startedAt)) < 0.001 else { return } let costMs = Int64(Date().timeIntervalSince(startedAt) * 1_000) self.log("[BLE-Health] probe timeout costMs=\(costMs)") + self.healthProbeCommandSeq = nil self.healthProbeStartedAt = nil + self.healthPingClockProbeStartedAt = nil self.healthProbeTimeoutWorkItem = nil self.healthProbeFailureCount += 1 if self.healthProbeFailureCount >= 2 { @@ -2448,6 +3662,8 @@ final class BLETestManager: NSObject, ObservableObject { private func markStatusNotifyReceived(type: String) { let now = Date() + let completesForegroundValidation = foregroundValidationPending && + appLifecycleState == "active" let highVolume = isHighVolumeNotifyType(type) if !highVolume { log("[BLE-Health] notify received type=\(type)") @@ -2463,6 +3679,7 @@ final class BLETestManager: NSObject, ObservableObject { if let probeStartedAt = healthProbeStartedAt { let ageMs = Int64(now.timeIntervalSince(probeStartedAt) * 1_000) log("[BLE-Health] probe success ageMs=\(ageMs) type=\(type)") + healthProbeCommandSeq = nil healthProbeStartedAt = nil healthProbeFailureCount = 0 healthProbeTimeoutWorkItem?.cancel() @@ -2474,9 +3691,15 @@ final class BLETestManager: NSObject, ObservableObject { } if highVolume, connectionHealthState == ConnectionHealthState.healthy.rawValue { + if completesForegroundValidation { + completeForegroundLinkValidation(type: type) + } return } setConnectionHealth(.healthy, reason: "notify type=\(type)") + if completesForegroundValidation { + completeForegroundLinkValidation(type: type) + } } private func isHighVolumeNotifyType(_ type: String) -> Bool { @@ -2486,7 +3709,9 @@ final class BLETestManager: NSObject, ObservableObject { type == "historyPayloadChunk" || type == "lyricSecondaryPart" || type == "mediaFieldDumpChunk" || - type == "logChunk" + type == "logChunk" || + type == "currentWord" || + type == "link" } private func setAutoReconnectState(_ state: AutoReconnectState) { @@ -2544,6 +3769,10 @@ final class BLETestManager: NSObject, ObservableObject { log("[BLE-Reconnect] schedule skipped reason=disabled trigger=\(reason)") return } + guard !systemAutoReconnectInProgress else { + log("[BLE-Reconnect] schedule skipped reason=system auto reconnecting trigger=\(reason)") + return + } guard centralManager.state == .poweredOn else { setAutoReconnectState(.failed) log("[BLE-Reconnect] schedule skipped reason=bluetooth state=\(centralManager.state.rawValue)") @@ -2681,7 +3910,12 @@ final class BLETestManager: NSObject, ObservableObject { "[BLE-Reconnect] connect id=\(peripheral.identifier.uuidString) " + "reason=\(reason) attempt=\(connectionAttemptId.uuidString)" ) - centralManager.connect(peripheral) + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil + let options: [String: Any] = autoReconnectEnabled + ? [CBConnectPeripheralOptionEnableAutoReconnect: true] + : [:] + centralManager.connect(peripheral, options: options) let timeoutMs = isRetrieved ? FAST_RETRIEVE_CONNECT_TIMEOUT_MS : DEFAULT_CONNECT_TIMEOUT_MS let timeout = DispatchWorkItem { [weak self, weak peripheral] in guard let self, @@ -2720,11 +3954,24 @@ final class BLETestManager: NSObject, ObservableObject { private func clearConnectionTransports(reason: String) { log("[BLE-Reconnect] clear characteristics reason=\(reason)") + resetClockSync(reason: reason) + coreBluetoothRestoreTimeoutWorkItem?.cancel() + coreBluetoothRestoreTimeoutWorkItem = nil sonyCommandCharacteristic = nil sonyStatusCharacteristic = nil firstConnectionReadyAtMs = 0 commandWriteTimeoutWorkItem?.cancel() commandWriteTimeoutWorkItem = nil + consecutiveCommandWriteTimeouts = 0 + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = nil + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + foregroundValidationPending = false + foregroundValidationWaitingForInflight = false + foregroundValidationCommandSeq = nil + foregroundValidationStartedAt = nil + coreBluetoothRestoreInProgress = false commandWriteInflight.removeAll() pendingCommandWrites.removeAll() liveActivityControlInFlightSeq = nil @@ -2735,30 +3982,108 @@ final class BLETestManager: NSObject, ObservableObject { resetMediaFieldDumpTransfer() resetTrackInfoTransfer() resetFullLyricsTransfer() + cancelLyricSecondaryTimeouts() + lyricSecondaryDeferredRequestWorkItem?.cancel() + lyricSecondaryDeferredRequestWorkItem = nil + activeLyricSecondaryRequest = nil lyricSecondaryTransfer = nil pendingLyricSecondaryModes.removeAll() + requestedLyricSecondaryKeys.removeAll() + completedLyricSecondaryKeys.removeAll() + lyricSecondaryRetryCounts.removeAll() + lyricSecondaryFailureCooldownUntilMs.removeAll() + translationLyricsState = .idle + romanizationLyricsState = .idle isRemoteLogTransferInProgress = false isMediaFieldDumpReceiving = false mediaFieldDumpProgressText = "" log("[BLE-Reconnect] cancel in-flight albumArt/secondary") } + private func scheduleCoreBluetoothRestoreTimeout(for peripheral: CBPeripheral) { + coreBluetoothRestoreTimeoutWorkItem?.cancel() + let timeout = DispatchWorkItem { [weak self, weak peripheral] in + guard let self, + let peripheral, + self.coreBluetoothRestoreInProgress, + self.sonyPeripheral?.identifier == peripheral.identifier, + self.sonyCommandCharacteristic == nil || + self.sonyStatusCharacteristic == nil || + self.lastNotifySubscribedAt == nil else { + return + } + self.log("[BLE-Restore] timeout fallback scan") + self.coreBluetoothRestoreInProgress = false + self.clearConnectionTransports(reason: "CoreBluetooth restore timeout") + self.setStatus("正在连接") + self.setAutoReconnectState(.failed) + self.beginSonyScan( + reason: "restore timeout fallback", + isAutoReconnect: true, + force: true + ) + } + coreBluetoothRestoreTimeoutWorkItem = timeout + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(CORE_BLUETOOTH_RESTORE_TIMEOUT_MS) / 1_000, + execute: timeout + ) + } + private func syncAfterReconnect(reason: String) { + guard appLifecycleState == "active", + sonyCharacteristicsReady, + !foregroundValidationPending else { + log( + "[BLE-Reconnect] sync deferred reason=\(reason) " + + "appState=\(appLifecycleState) ready=\(sonyCharacteristicsReady) " + + "validating=\(foregroundValidationPending)" + ) + return + } + let expectedAttemptId = connectionAttemptId + let expectedLifecycleGeneration = lifecycleGeneration log("[BLE-Reconnect] sync playback state reason=\(reason)") sendGetPlaybackState() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.log("[BLE-Reconnect] sync volume") - self?.sendGetVolume() + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] sync volume") + self.sendGetVolume() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { [weak self] in - self?.log("[BLE-Reconnect] defer full lyrics") - self?.requestFullLyricsIfNeeded(after: 0) + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] defer full lyrics") + self.requestFullLyricsIfNeeded(after: 0) } DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in - self?.log("[BLE-Reconnect] defer secondary") + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] defer secondary") } } + private func canRunDeferredConnectionSync( + attemptId: UUID, + lifecycleGeneration: UInt64 + ) -> Bool { + appLifecycleState == "active" && + connectionAttemptId == attemptId && + self.lifecycleGeneration == lifecycleGeneration && + sonyCharacteristicsReady && + !foregroundValidationPending + } + private func startupLoadRemainingDelay() -> TimeInterval { guard firstConnectionReadyAtMs > 0 else { return 0 } let elapsedMs = currentTimeMs() - firstConnectionReadyAtMs @@ -2820,10 +4145,12 @@ final class BLETestManager: NSObject, ObservableObject { @objc private func appWillResignActive() { updateAppLifecycleState(.inactive, emitLog: true) + pauseConnectionWatchdogsForLifecycle(reason: "will resign active") } @objc private func appDidEnterBackground() { updateAppLifecycleState(.background, emitLog: true) + pauseConnectionWatchdogsForLifecycle(reason: "did enter background") } private func updateAppLifecycleState( @@ -2841,21 +4168,88 @@ final class BLETestManager: NSObject, ObservableObject { @unknown default: value = "unknown" } + if value != appLifecycleState { + lifecycleGeneration &+= 1 + } + if value == "background", appLifecycleState != "background" { + backgroundEnteredAt = Date() + } appLifecycleState = value + updateProgressTimerState() if emitLog { ctrlLog("[APP-LIFECYCLE] \(value)") } } + private func pauseConnectionWatchdogsForLifecycle(reason: String) { + healthCheckWorkItem?.cancel() + healthCheckWorkItem = nil + healthProbeTimeoutWorkItem?.cancel() + healthProbeTimeoutWorkItem = nil + subscribeNotifyTimeoutWorkItem?.cancel() + subscribeNotifyTimeoutWorkItem = nil + commandWriteTimeoutWorkItem?.cancel() + commandWriteTimeoutWorkItem = nil + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = nil + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + foregroundValidationPending = false + foregroundValidationWaitingForInflight = false + foregroundValidationCommandSeq = nil + foregroundValidationStartedAt = nil + healthProbeCommandSeq = nil + healthProbeStartedAt = nil + healthPingClockProbeStartedAt = nil + healthProbeFailureCount = 0 + consecutiveCommandWriteTimeouts = 0 + pauseClockSyncScheduling(reason: reason) + updateConnectionHealthDebugFields() + ctrlLog( + "[APP-LIFECYCLE] BLE watchdogs paused reason=\(reason) " + + "inflight=\(commandWriteInflight.count) pending=\(pendingCommandWrites.count)" + ) + } + private func handleAppForegroundReconnectCheck() { let connected = sonyPeripheral?.state == .connected let ready = connected && sonyCommandCharacteristic != nil && sonyStatusCharacteristic != nil + let suspendedMs = backgroundEnteredAt.map { + max(Int64(Date().timeIntervalSince($0) * 1_000), 0) + } ?? 0 + backgroundEnteredAt = nil log( "[BLE-Reconnect] app foreground check connected=\(connected) " + - "ready=\(ready) health=\(connectionHealthState)" + "ready=\(ready) health=\(connectionHealthState) suspendedMs=\(suspendedMs)" ) guard centralManager.state == .poweredOn else { return } + if connected, coreBluetoothRestoreInProgress { + log("[BLE-Reconnect] foreground restore skipped reason=CoreBluetooth restoration pending") + return + } if !connected { + if systemAutoReconnectInProgress { + let ageMs = systemAutoReconnectStartedAt.map { + Int64(Date().timeIntervalSince($0) * 1_000) + } ?? 0 + if ageMs < DEFAULT_CONNECT_TIMEOUT_MS { + log( + "[BLE-Reconnect] foreground reconnect skipped " + + "reason=system auto reconnecting ageMs=\(ageMs)" + ) + setStatus("正在连接 Sony") + setAutoReconnectState(.connecting) + return + } + log("[BLE-Reconnect] system auto reconnect expired ageMs=\(ageMs)") + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil + isConnectingToSony = false + if let sonyPeripheral { + centralManager.cancelPeripheralConnection(sonyPeripheral) + } + self.sonyPeripheral = nil + } if AutoReconnectState(rawValue: autoReconnectState) == .connecting, currentConnectIsRetrievedPeripheral, let sonyPeripheral { @@ -2883,16 +4277,116 @@ final class BLETestManager: NSObject, ObservableObject { log("[BLE-Reconnect] foreground rediscover services reason=characteristic missing") setAutoReconnectState(.serviceDiscovering) sonyPeripheral.discoverServices([BLEUUIDs.service]) - } else if connectionHealthState == ConnectionHealthState.stale.rawValue || - connectionHealthState == ConnectionHealthState.disconnected.rawValue { - performHardReconnect( - reason: "foreground unhealthy state=\(connectionHealthState)", + } else { + setConnectionHealth(.suspect, reason: "foreground validation pending") + if commandWriteInflight.isEmpty { + beginForegroundLinkValidation(reason: "foreground ready") + } else { + scheduleForegroundInflightSettle() + } + } + } + + private func scheduleForegroundInflightSettle() { + foregroundValidationWaitingForInflight = true + foregroundInflightSettleWorkItem?.cancel() + let expectedAttemptId = connectionAttemptId + let expectedLifecycleGeneration = lifecycleGeneration + let item = DispatchWorkItem { [weak self] in + guard let self, + self.appLifecycleState == "active", + self.connectionAttemptId == expectedAttemptId, + self.lifecycleGeneration == expectedLifecycleGeneration, + self.foregroundValidationWaitingForInflight else { return } + self.foregroundInflightSettleWorkItem = nil + if self.commandWriteInflight.isEmpty { + self.foregroundValidationWaitingForInflight = false + self.beginForegroundLinkValidation(reason: "foreground inflight settled") + } else { + let command = self.commandWriteInflight.first?.cmd ?? "unknown" + self.performHardReconnect( + reason: "foreground suspended write did not settle cmd=\(command)", + manual: false + ) + } + } + foregroundInflightSettleWorkItem = item + ctrlLog( + "[BLE-Health] foreground waiting for suspended write " + + "cmd=\(commandWriteInflight.first?.cmd ?? "unknown")" + ) + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(FOREGROUND_INFLIGHT_SETTLE_TIMEOUT_MS) / 1_000.0, + execute: item + ) + } + + private func beginForegroundLinkValidation(reason: String) { + guard appLifecycleState == "active", + sonyCharacteristicsReady, + !foregroundValidationPending else { return } + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + foregroundValidationWaitingForInflight = false + foregroundValidationPending = true + foregroundValidationStartedAt = nil + let seq = nextCommandSeq() + foregroundValidationCommandSeq = seq + let command = serverSupportsPing ? "PING" : "GET_PLAYBACK_STATE" + ctrlLog( + "[BLE-Health] foreground validation queued seq=\(seq) " + + "cmd=\(command) reason=\(reason)" + ) + sendCommand(cmd: command, seq: seq) + } + + private func startForegroundValidationTimeout(seq: UInt64) { + guard foregroundValidationPending, + foregroundValidationCommandSeq == seq, + foregroundValidationStartedAt == nil else { return } + let startedAt = Date() + foregroundValidationStartedAt = startedAt + let expectedAttemptId = connectionAttemptId + let expectedLifecycleGeneration = lifecycleGeneration + let item = DispatchWorkItem { [weak self] in + guard let self, + self.appLifecycleState == "active", + self.connectionAttemptId == expectedAttemptId, + self.lifecycleGeneration == expectedLifecycleGeneration, + self.foregroundValidationPending, + self.foregroundValidationCommandSeq == seq else { return } + self.foregroundValidationTimeoutWorkItem = nil + self.setConnectionHealth(.stale, reason: "foreground validation timeout") + self.performHardReconnect( + reason: "foreground link validation timeout seq=\(seq)", manual: false ) - } else { - startHealthMonitoring(reason: "foreground ready") - syncAfterReconnect(reason: "foreground ready") } + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(FOREGROUND_LINK_VALIDATION_TIMEOUT_MS) / 1_000.0, + execute: item + ) + } + + private func completeForegroundLinkValidation(type: String) { + guard foregroundValidationPending, + appLifecycleState == "active" else { return } + let costMs = foregroundValidationStartedAt.map { + Int64(Date().timeIntervalSince($0) * 1_000) + } ?? 0 + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = nil + foregroundValidationPending = false + foregroundValidationCommandSeq = nil + foregroundValidationStartedAt = nil + ctrlLog( + "[BLE-Health] foreground validation success type=\(type) costMs=\(costMs)" + ) + startHealthMonitoring(reason: "foreground validated") + syncAfterReconnect(reason: "foreground validated") + scheduleClockSyncBootstrap(reason: "foreground validated") } func albumArtConsoleLog(_ message: String) { @@ -3192,10 +4686,33 @@ extension BLETestManager: LiveActivityBLECommandSending { extension BLETestManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { log("[BLE] central state=\(central.state.rawValue)") + switch central.state { + case .poweredOn: + bluetoothAvailability = .available + case .poweredOff: + bluetoothAvailability = .poweredOff + case .unauthorized: + bluetoothAvailability = .unauthorized + case .unsupported: + bluetoothAvailability = .unsupported + case .resetting: + bluetoothAvailability = .resetting + case .unknown: + bluetoothAvailability = .unknown + @unknown default: + bluetoothAvailability = .unknown + } if central.state == .poweredOn, shouldScanWhenPoweredOn { beginSonyScan(reason: "poweredOn pending scan", isAutoReconnect: false, force: false) } else if central.state == .poweredOn, autoReconnectEnabled, sonyPeripheral?.state != .connected { - scheduleReconnect(reason: "central poweredOn", immediate: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, + self.centralManager.state == .poweredOn, + self.sonyPeripheral?.state != .connected else { + return + } + self.scheduleReconnect(reason: "central poweredOn", immediate: true) + } } else if central.state != .poweredOn { stopHealthMonitoring(reason: "central not powered") setConnectionHealth(.disconnected, reason: "central state=\(central.state.rawValue)") @@ -3204,6 +4721,97 @@ extension BLETestManager: CBCentralManagerDelegate { } } + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + let restoredPeripherals = + dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + let lastIdentifier = UserDefaults.standard.string( + forKey: AUTO_RECONNECT_LAST_PERIPHERAL_KEY + ) + let candidate = restoredPeripherals.first { + $0.identifier.uuidString == lastIdentifier + } ?? restoredPeripherals.first { + $0.state == .connected + } + + guard let peripheral = candidate else { + log("[BLE-Restore] no restorable Sony peripheral") + return + } + + coreBluetoothRestoreInProgress = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + scanTimeoutWorkItem?.cancel() + scanTimeoutWorkItem = nil + connectTimeoutWorkItem?.cancel() + connectTimeoutWorkItem = nil + central.stopScan() + isConnectingToSony = false + connectionAttemptId = UUID() + connectionHealthAttemptId = connectionAttemptId.uuidString + sonyPeripheral = peripheral + peripheral.delegate = self + connectedDeviceName = peripheral.name ?? "Sony" + saveLastSonyPeripheral(peripheral) + log( + "[BLE-Restore] restored id=\(peripheral.identifier.uuidString) " + + "state=\(peripheralStateText(peripheral))" + ) + + guard peripheral.state == .connected else { + coreBluetoothRestoreInProgress = false + connectSonyPeripheral( + peripheral, + reason: "CoreBluetooth restored peripheral", + isRetrieved: true + ) + return + } + + setStatus("正在恢复服务") + setConnectionHealth(.suspect, reason: "CoreBluetooth restoration pending") + setAutoReconnectState(.serviceDiscovering) + scheduleCoreBluetoothRestoreTimeout(for: peripheral) + if let service = peripheral.services?.first(where: { $0.uuid == BLEUUIDs.service }) { + let characteristics = service.characteristics ?? [] + sonyCommandCharacteristic = characteristics.first { + $0.uuid == BLEUUIDs.command + } + sonyStatusCharacteristic = characteristics.first { + $0.uuid == BLEUUIDs.status + } + if let status = sonyStatusCharacteristic, + sonyCommandCharacteristic != nil { + setAutoReconnectState(.subscribing) + subscribeStartedAtMs = currentTimeMs() + peripheral.setNotifyValue(true, for: status) + if status.isNotifying { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self, weak peripheral] in + guard let self, + let peripheral, + self.lastNotifySubscribedAt == nil, + self.sonyPeripheral?.identifier == peripheral.identifier else { + return + } + self.log("[BLE-Restore] reuse restored notifying characteristic") + self.peripheral( + peripheral, + didUpdateNotificationStateFor: status, + error: nil + ) + } + } + return + } + peripheral.discoverCharacteristics( + [BLEUUIDs.command, BLEUUIDs.status], + for: service + ) + } else { + peripheral.discoverServices([BLEUUIDs.service]) + } + } + func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, @@ -3243,6 +4851,8 @@ extension BLETestManager: CBCentralManagerDelegate { centralManager.cancelPeripheralConnection(peripheral) return } + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil isConnectingToSony = false updateAutoReconnectDebugFields() connectTimeoutWorkItem?.cancel() @@ -3254,7 +4864,11 @@ extension BLETestManager: CBCentralManagerDelegate { lastNotifySubscribedAt = nil lastStatusNotifyAt = nil lastPlaybackStateAt = nil + healthProbeCommandSeq = nil healthProbeStartedAt = nil + healthPingClockProbeStartedAt = nil + resetClockSync(reason: "didConnect") + resetCurrentWordFence() reconnectStateSyncWindowUntilMs = 0 reconnectStateSyncPlaybackLogged = false log("[BLE] connected") @@ -3288,6 +4902,8 @@ extension BLETestManager: CBCentralManagerDelegate { log("[BLE-Reconnect] ignore stale didFailToConnect id=\(peripheral.identifier.uuidString)") return } + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil isConnectingToSony = false connectTimeoutWorkItem?.cancel() connectTimeoutWorkItem = nil @@ -3307,34 +4923,84 @@ extension BLETestManager: CBCentralManagerDelegate { _ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error? + ) { + handlePeripheralDisconnect( + peripheral, + error: error, + systemIsReconnecting: false + ) + } + + @available(iOS 17.0, *) + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Bool, + error: Error? + ) { + handlePeripheralDisconnect( + peripheral, + error: error, + systemIsReconnecting: isReconnecting + ) + } + + private func handlePeripheralDisconnect( + _ peripheral: CBPeripheral, + error: Error?, + systemIsReconnecting: Bool ) { guard sonyPeripheral?.identifier == peripheral.identifier else { log("[BLE-Reconnect] ignore stale didDisconnect id=\(peripheral.identifier.uuidString)") return } - isConnectingToSony = false connectTimeoutWorkItem?.cancel() connectTimeoutWorkItem = nil let errorText = error?.localizedDescription ?? "none" autoReconnectLastDisconnectError = errorText - setStatus(autoReconnectEnabled ? "正在连接" : "未连接") - if !autoReconnectEnabled { - setAutoReconnectState(.idle) - } - log("[BLE] disconnected error=\(errorText)") - log("[BLE-iOS] didDisconnect error=\(errorText)") - log("[BLE-Reconnect] disconnected error=\(errorText)") + self.systemAutoReconnectInProgress = systemIsReconnecting + systemAutoReconnectStartedAt = systemIsReconnecting ? Date() : nil + isConnectingToSony = systemIsReconnecting + log( + "[BLE] disconnected error=\(errorText) " + + "systemReconnecting=\(systemIsReconnecting)" + ) + log( + "[BLE-iOS] didDisconnect error=\(errorText) " + + "systemReconnecting=\(systemIsReconnecting)" + ) + log( + "[BLE-Reconnect] disconnected error=\(errorText) " + + "systemReconnecting=\(systemIsReconnecting)" + ) reconnectStateSyncWindowUntilMs = 0 reconnectStateSyncPlaybackLogged = false stopHealthMonitoring(reason: "didDisconnect") setConnectionHealth(.disconnected, reason: "didDisconnect") - sonyPeripheral = nil - connectedDeviceName = "-" clearConnectionTransports(reason: "disconnect") clearPendingVolume() + + if systemIsReconnecting { + setStatus("正在连接 Sony") + setAutoReconnectState(.connecting) + connectedDeviceName = peripheral.name ?? connectedDeviceName + log("[BLE-Reconnect] waiting for CoreBluetooth automatic reconnect") + return + } + + sonyPeripheral = nil + connectedDeviceName = "-" + setStatus(autoReconnectEnabled ? "正在连接" : "未连接") + if !autoReconnectEnabled { + setAutoReconnectState(.idle) + } log("[BLE-Reconnect] update LiveActivity disconnected") updateLiveActivityDisconnected() - if autoReconnectEnabled { + if SystemReconnectPolicy.shouldScheduleManualReconnect( + autoReconnectEnabled: autoReconnectEnabled, + systemIsReconnecting: systemIsReconnecting + ) { scheduleReconnect(reason: "disconnect", immediate: false) } } @@ -3448,6 +5114,9 @@ extension BLETestManager: CBPeripheralDelegate { ) } + if error == nil, completed != nil { + consecutiveCommandWriteTimeouts = 0 + } if let error { if completed?.cmd == "SET_VOLUME" { log("[iOS][BLE] write SET_VOLUME failed: \(error.localizedDescription)") @@ -3466,7 +5135,15 @@ extension BLETestManager: CBPeripheralDelegate { log("[Command] \(completed?.cmd ?? "unknown") success") } if error == nil { - flushCommandWriteQueue() + if foregroundValidationWaitingForInflight, + appLifecycleState == "active" { + foregroundValidationWaitingForInflight = false + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + beginForegroundLinkValidation(reason: "suspended write callback received") + } else { + flushCommandWriteQueue() + } } } @@ -3480,6 +5157,9 @@ extension BLETestManager: CBPeripheralDelegate { return } guard characteristic.uuid == BLEUUIDs.status else { return } + coreBluetoothRestoreInProgress = false + coreBluetoothRestoreTimeoutWorkItem?.cancel() + coreBluetoothRestoreTimeoutWorkItem = nil if let error { setStatus("未连接") @@ -3511,13 +5191,26 @@ extension BLETestManager: CBPeripheralDelegate { setConnectionHealth(.suspect, reason: "notify subscribed waiting status") startHealthMonitoring(reason: "notify subscribed") serverProtocolVersion = 1 + serverSupportsAlbumArtBinary = false serverSupportsFullLyricsZlib = false serverSupportsLyricWindow = false serverSupportsPing = false + serverSupportsClockSyncV1 = false serverSupportsTransferRetry = false + negotiatedV3Features = [] + serverSessionId = "-" + lastServerEventSequence = 0 + eventSequenceDiagnostics.reset() + lastMediaLoadStateKeyByResource.removeAll() + resetClockSync(reason: "notify subscribed") requestedLyricWindowTrackIDs.removeAll() + let expectedAttemptId = connectionAttemptId + let expectedLifecycleGeneration = lifecycleGeneration let subscribeTimeout = DispatchWorkItem { [weak self] in guard let self, + self.appLifecycleState == "active", + self.connectionAttemptId == expectedAttemptId, + self.lifecycleGeneration == expectedLifecycleGeneration, let lastNotifySubscribedAt = self.lastNotifySubscribedAt, self.lastStatusNotifyAt == nil || self.lastStatusNotifyAt! < lastNotifySubscribedAt else { @@ -3530,32 +5223,62 @@ extension BLETestManager: CBPeripheralDelegate { deadline: .now() + Double(CONNECTION_SUBSCRIBE_NOTIFY_TIMEOUT_MS) / 1_000.0, execute: subscribeTimeout ) - log("[BLE-Reconnect] send CLIENT_CAPABILITIES") + let requestedProtocolVersion = preferences.forceProtocolV2 ? 2 : 3 + log( + "[BLE-Reconnect] send CLIENT_CAPABILITIES " + + "protocolVersion=\(requestedProtocolVersion) f3=\(requestedProtocolVersion >= 3 ? BLEProtocolV3Features.all.rawValue : 0)" + ) + var capabilities: [String: Any] = [ + "protocolVersion": requestedProtocolVersion, + "albumArtBinary": true, + "fullLyricsZlib": true, + "lyricWindow": true, + "ping": true, + "clockSyncV1": true, + "transferRetry": true + ] + if requestedProtocolVersion >= 3 { + capabilities["f3"] = BLEProtocolV3Features.all.rawValue + } sendCommand( cmd: "CLIENT_CAPABILITIES", - extra: [ - "protocolVersion": 2, - "albumArtBinary": true, - "fullLyricsZlib": true, - "lyricWindow": true, - "ping": true, - "transferRetry": true - ] + extra: capabilities ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.log("[BLE-Reconnect] sync playback state") - self?.sendGetPlaybackState() + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] sync playback state") + self.sendGetPlaybackState() } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.log("[BLE-Reconnect] sync volume") - self?.sendGetVolume() + // Let the interactive lyric window finish before the non-critical + // initial volume snapshot uses the ATT command/notify path. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] sync volume") + self.sendGetVolume() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { [weak self] in - self?.log("[BLE-Reconnect] defer full lyrics") - self?.requestFullLyricsIfNeeded(after: 0) + guard let self, + self.canRunDeferredConnectionSync( + attemptId: expectedAttemptId, + lifecycleGeneration: expectedLifecycleGeneration + ) else { return } + self.log("[BLE-Reconnect] defer full lyrics") + self.requestFullLyricsIfNeeded(after: 0) } DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.setAutoReconnectState(.connected) + guard let self, + self.connectionAttemptId == expectedAttemptId, + self.lifecycleGeneration == expectedLifecycleGeneration, + self.sonyCharacteristicsReady else { return } + self.setAutoReconnectState(.connected) } } } @@ -3580,64 +5303,124 @@ extension BLETestManager: CBPeripheralDelegate { log("[Status] empty notify") return } - if data.first == 0xA1 { - markStatusNotifyReceived(type: "albumArtBinaryChunk") - albumArtReceiver.handleBinaryChunk(data) - return - } - if data.first == 0xA2 { - markStatusNotifyReceived(type: "fullLyricsBinaryChunk") - handleFullLyricsBinaryChunk(data) - return - } - - guard let text = String(data: data, encoding: .utf8) else { - log("[Status] invalid UTF-8") - return - } - - if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = object["type"] as? String { - if type == "albumArtChunk" || type == "trackInfoChunk" { - log("[BLE] status notify received type=\(type)") - } else if !isHighVolumeNotifyType(type) { - log("[BLE] status notify received: \(text)") + let peripheralID = peripheral.identifier + inboundPipeline.submit { [weak self] in + guard let self else { return } + if data.first == 0xA1 { + DispatchQueue.main.async { [weak self] in + guard let self, + self.sonyPeripheral?.identifier == peripheralID else { return } + self.markStatusNotifyReceived(type: "albumArtBinaryChunk") + self.albumArtReceiver.handleBinaryChunk(data) + } + return + } + if data.first == 0xA2 { + DispatchQueue.main.async { [weak self] in + guard let self, + self.sonyPeripheral?.identifier == peripheralID else { return } + self.markStatusNotifyReceived(type: "fullLyricsBinaryChunk") + self.handleFullLyricsBinaryChunk(data) + } + return + } + let decodeState = AppPerformanceLog.protocolSignposter.beginInterval("Status JSON Decode") + let objectBox = BLEProtocolV3Parser.jsonObject(from: data).map(BLEStatusObjectBox.init) + let type = objectBox?.value["type"] as? String + let text = String(data: data, encoding: .utf8) + AppPerformanceLog.protocolSignposter.endInterval("Status JSON Decode", decodeState) + DispatchQueue.main.async { [weak self] in + guard let self, + self.sonyPeripheral?.identifier == peripheralID else { return } + guard let object = objectBox?.value, let type else { + if let text { + self.log("[BLE] status notify received: \(text)") + } else { + self.log("[Status] invalid UTF-8") + } + self.log("[Status] JSON parse failed") + return + } + if type == "albumArtChunk" || type == "trackInfoChunk" { + self.log("[BLE] status notify received type=\(type)") + } else if !self.isHighVolumeNotifyType(type) { + self.log("[BLE] status notify received: \(text ?? type)") + } + self.parseStatus(object, type: type) } - } else { - log("[BLE] status notify received: \(text)") } - parseStatus(data) } - private func parseStatus(_ data: Data) { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = object["type"] as? String else { - log("[Status] JSON parse failed") - return - } - + private func parseStatus(_ object: [String: Any], type: String) { let apply: () -> Void = { self.markStatusNotifyReceived(type: type) + self.observeV3StatusMetadata(object, type: type) switch type { + case "link": + // Sony's per-client keepalive already refreshed connection + // health above. It intentionally carries no media payload. + break + case "clientCapabilitiesAck": - self.serverProtocolVersion = Self.intValue(object["protocolVersion"]) - self.serverSupportsFullLyricsZlib = object["fullLyricsZlib"] as? Bool ?? false - self.serverSupportsLyricWindow = object["lyricWindow"] as? Bool ?? false - self.serverSupportsPing = object["ping"] as? Bool ?? false - self.serverSupportsTransferRetry = object["transferRetry"] as? Bool ?? false + let ack = BLEProtocolV3Parser.capabilitiesAck(from: object) + self.serverProtocolVersion = ack.protocolVersion + self.serverSupportsAlbumArtBinary = ack.v2Features.contains(.albumArtBinary) + self.serverSupportsFullLyricsZlib = ack.v2Features.contains(.fullLyricsZlib) + self.serverSupportsLyricWindow = ack.v2Features.contains(.lyricWindow) + self.serverSupportsPing = ack.v2Features.contains(.ping) + self.serverSupportsClockSyncV1 = ack.v2Features.contains(.clockSyncV1) + self.serverSupportsTransferRetry = ack.v2Features.contains(.transferRetry) + self.negotiatedV3Features = ack.v3Features.intersection(.all) + if let sessionId = ack.sessionId { + self.serverSessionId = sessionId + self.lastServerEventSequence = 0 + self.eventSequenceDiagnostics.reset(sessionId: sessionId) + } self.log( "[BLE-iOS] capabilities ack protocolVersion=\(self.serverProtocolVersion) " + + "albumArtBinary=\(self.serverSupportsAlbumArtBinary) " + "fullLyricsZlib=\(self.serverSupportsFullLyricsZlib) " + "lyricWindow=\(self.serverSupportsLyricWindow) " + "ping=\(self.serverSupportsPing) " + - "transferRetry=\(self.serverSupportsTransferRetry)" + "clockSyncV1=\(self.serverSupportsClockSyncV1) " + + "transferRetry=\(self.serverSupportsTransferRetry) " + + "f3=\(self.negotiatedV3Features.rawValue) sid=\(self.serverSessionId)" ) + if self.serverSupportsClockSyncV1 { + self.scheduleClockSyncBootstrap(reason: "capability ack") + } if self.serverSupportsLyricWindow, !self.currentTrackID.isEmpty { self.requestLyricWindow(trackID: self.currentTrackID) } + case "commandError": + self.handleStructuredCommandError(object) + + case "mediaLoadState": + self.handleNegotiatedMediaLoadState(object) + case "pong": - self.log("[BLE-Health] pong seq=\(object["seq"] ?? "")") + let handledClockSync = self.handleClockSyncPong(object) + if let startedAt = self.healthPingClockProbeStartedAt { + let receivedAt = Date() + let rttMs = Int64(receivedAt.timeIntervalSince(startedAt) * 1_000) + let midpointMs = Int64( + (startedAt.timeIntervalSince1970 + + receivedAt.timeIntervalSince1970) * 500 + ) + let sonyTimeMs = Self.int64Value(object["time"]) + if sonyTimeMs > 0 { + self.sonyClockOffsetMs = sonyTimeMs - midpointMs + } + self.healthPingClockProbeStartedAt = nil + self.log( + "[BLE-Health] pong seq=\(object["seq"] ?? "") " + + "rttMs=\(rttMs) " + + "clockOffsetMs=\(self.sonyClockOffsetMs ?? 0)" + ) + } else if !handledClockSync { + self.log("[BLE-Health] pong seq=\(object["seq"] ?? "")") + } case "playbackState": let oldLyric = self.lyric @@ -3646,6 +5429,11 @@ extension BLETestManager: CBPeripheralDelegate { let reconnectSyncWindow = self.isInReconnectStateSyncWindow() self.isPlaying = object["playing"] as? Bool ?? false self.durationMs = Self.int64Value(object["duration"]) + let parsedSpeed = Self.doubleValue(object["speed"]) + self.remotePlaybackSpeed = parsedSpeed.isFinite && parsedSpeed > 0 + ? parsedSpeed + : 1.0 + self.updateProgressTimerState() self.updateLightweightLyricDiagnostic(from: object) if let lyric = object["lyric"] as? String { self.lyric = lyric @@ -3689,11 +5477,23 @@ extension BLETestManager: CBPeripheralDelegate { } } if !self.isSeeking { - self.positionMs = Self.int64Value(object["position"]) - self.displayPositionMs = self.positionMs - self.seekPositionMs = self.positionMs - self.basePlaybackPositionMs = self.positionMs - self.playbackStateReceivedAt = Date() + let remotePositionMs = Self.int64Value(object["position"]) + let resolution = self.resolveRemotePlaybackAnchor( + object: object, + remotePositionMs: remotePositionMs, + isPlaying: self.isPlaying + ) + if let calibratedPositionMs = self.calibratedRemotePosition( + from: resolution, + remotePositionMs: remotePositionMs, + source: "playbackState" + ) { + self.positionMs = calibratedPositionMs + self.displayPositionMs = calibratedPositionMs + self.seekPositionMs = calibratedPositionMs + self.basePlaybackPositionMs = calibratedPositionMs + self.playbackAnchorElapsedMs = self.monotonicTimeMs() + } } self.log( "[iOS][Status] playbackState " + @@ -3726,6 +5526,7 @@ extension BLETestManager: CBPeripheralDelegate { ) { self.updateLiveActivity(force: false, reason: "playbackState") } + self.scheduleLastNowPlayingSnapshotSave(reason: "playbackState") case "currentWord": self.handleCurrentWord(object) @@ -3809,8 +5610,11 @@ extension BLETestManager: CBPeripheralDelegate { case "lyricSecondaryEnd": self.handleLyricSecondaryEnd(object) - case "lyricSecondaryUnavailable", "lyricSecondaryError": - self.handleLyricSecondaryUnavailable(object) + case "lyricSecondaryUnavailable": + self.handleLyricSecondaryUnavailable(object, isError: false) + + case "lyricSecondaryError": + self.handleLyricSecondaryUnavailable(object, isError: true) case "volumeState": self.volumeMax = Self.intValue(object["max"]) @@ -4017,6 +5821,166 @@ extension BLETestManager: CBPeripheralDelegate { } } + private func observeV3StatusMetadata(_ object: [String: Any], type: String) { + guard negotiatedV3Features.contains(.statusMetaV1), + let metadata = BLEProtocolV3Parser.statusMetadata(from: object) else { + return + } + + let previousSession = serverSessionId + let observation = eventSequenceDiagnostics.observe(metadata) + serverSessionId = metadata.sessionId + lastServerEventSequence = eventSequenceDiagnostics.highestSequence + switch observation { + case .first, .inOrder: + break + case .newSession: + log( + "[BLE-V3] session changed previous=\(previousSession) " + + "current=\(metadata.sessionId) type=\(type)" + ) + case .duplicate: + log("[BLE-V3] duplicate event sid=\(metadata.sessionId) es=\(metadata.eventSequence) type=\(type)") + case let .gap(missing): + log( + "[BLE-V3] event gap sid=\(metadata.sessionId) " + + "es=\(metadata.eventSequence) missing=\(missing) type=\(type)" + ) + case .outOfOrder: + log( + "[BLE-V3] out-of-order event sid=\(metadata.sessionId) " + + "es=\(metadata.eventSequence) last=\(lastServerEventSequence) type=\(type)" + ) + } + } + + private func handleStructuredCommandError(_ object: [String: Any]) { + guard negotiatedV3Features.contains(.structuredErrorV1) else { + log("[BLE-V3] commandError ignored because capability was not negotiated") + return + } + guard let payload = BLEProtocolV3Parser.commandError(from: object) else { + log("[BLE-V3] malformed commandError ignored") + return + } + guard isCurrentV3MediaPayload( + trackId: payload.trackId, + generation: payload.generation + ) else { + log( + "[BLE-V3] stale commandError ignored cmd=\(payload.command) " + + "trackId=\(payload.trackId ?? "-") generation=\(payload.generation ?? -1)" + ) + return + } + + let correlatedCommand: String = { + guard let sequence = payload.sequence else { return payload.command } + if let command = commandWriteInflight.first(where: { $0.seq == sequence })?.cmd { + return command + } + if let command = pendingCommandWrites.first(where: { $0.seq == sequence })?.cmd { + return command + } + return payload.command + }() + let retryText = payload.retryable + ? ",可重试\(payload.retryAfterMs.map { "(\($0)ms 后)" } ?? "")" + : "" + lastCommandErrorSummary = "\(correlatedCommand):\(payload.code)\(retryText)" + log( + "[BLE-V3] commandError seq=\(payload.sequence.map { String($0) } ?? "-") " + + "cmd=\(correlatedCommand) domain=\(payload.domain.rawValue) " + + "code=\(payload.code) retryable=\(payload.retryable) " + + "retryAfterMs=\(payload.retryAfterMs ?? -1)" + ) + + switch payload.domain { + case .lyrics: + mediaLoadingState.lyric = .failed(reason: payload.code) + case .artwork: + mediaLoadingState.artwork = .failed(reason: payload.code) + case .protocol, .history, .connection, .unknown: + break + } + } + + private func handleNegotiatedMediaLoadState(_ object: [String: Any]) { + guard negotiatedV3Features.contains(.mediaLoadStateV1) else { + log("[BLE-V3] mediaLoadState ignored because capability was not negotiated") + return + } + guard let payload = BLEProtocolV3Parser.mediaLoadState(from: object) else { + log("[BLE-V3] malformed mediaLoadState ignored") + return + } + guard isCurrentV3MediaPayload( + trackId: payload.trackId, + generation: payload.generation + ) else { + log( + "[BLE-V3] stale mediaLoadState ignored resource=\(payload.resource.rawValue) " + + "trackId=\(payload.trackId) generation=\(payload.generation ?? -1)" + ) + return + } + guard lastMediaLoadStateKeyByResource[payload.resource] != payload.deduplicationKey else { + log("[BLE-V3] duplicate mediaLoadState ignored key=\(payload.deduplicationKey)") + return + } + lastMediaLoadStateKeyByResource[payload.resource] = payload.deduplicationKey + + switch payload.resource { + case .lyrics: + switch payload.stage { + case .waiting, .preparing: + mediaLoadingState.lyric = .waitingQqQrc + case .transferring: + if fullLyrics.isEmpty { + mediaLoadingState.lyric = .fullLyrics(received: 0, expected: 0) + } + case .ready: + let lineCount = fullLyrics.count + mediaLoadingState.lyric = lineCount > 0 + ? .ready(lineCount: lineCount) + : .windowReady(lineCount: 0) + case .unavailable, .failed: + mediaLoadingState.lyric = .failed(reason: payload.reason) + } + case .artwork: + switch payload.stage { + case .waiting, .preparing, .transferring: + if artworkDisplayQuality == .placeholder { + mediaLoadingState.artwork = .preview(received: 0, expected: 0) + } + case .ready: + mediaLoadingState.artwork = artworkDisplayQuality == .hq + ? .hqReady + : .previewReady + case .unavailable, .failed: + mediaLoadingState.artwork = .failed(reason: payload.reason) + } + } + log( + "[BLE-V3] mediaLoadState resource=\(payload.resource.rawValue) " + + "stage=\(payload.stage.rawValue) reason=\(payload.reason) " + + "trackId=\(payload.trackId) generation=\(payload.generation ?? -1)" + ) + } + + private func isCurrentV3MediaPayload(trackId: String?, generation: Int64?) -> Bool { + if let trackId, !trackId.isEmpty, + !isSameTrackId(incoming: trackId, current: currentTrackID) { + return false + } + if let generation, generation > 0, + currentTrackGeneration > 0, + generation != currentTrackGeneration { + return false + } + return true + } + private func handleHistoryPayloadStart(_ object: [String: Any]) { let requestId = object["requestId"] as? String ?? "" let responseType = object["responseType"] as? String ?? "" @@ -4056,21 +6020,35 @@ extension BLETestManager: CBPeripheralDelegate { log("[HistorySync] payload end missing chunks requestId=\(requestId)") return } - var data = Data() - for index in 0.. 0 && currentTrackGeneration > 0 && generation != currentTrackGeneration) if trackChanged { + resetCurrentWordFence() lyric = "" fullLyricsTrackId = "" isFullLyricsCurrent = false @@ -4228,10 +6209,20 @@ extension BLETestManager: CBPeripheralDelegate { completedLyricSecondaryKeys.removeAll() ignoredLyricSecondaryPlaceholderKeys.removeAll() pendingLyricSecondaryModes.removeAll() + cancelLyricSecondaryTimeouts() + lyricSecondaryDeferredRequestWorkItem?.cancel() + lyricSecondaryDeferredRequestWorkItem = nil + activeLyricSecondaryRequest = nil lyricSecondaryTransfer = nil + lyricSecondaryRetryCounts.removeAll() + lyricSecondaryFailureCooldownUntilMs.removeAll() + translationLyricsState = .idle + romanizationLyricsState = .idle lyricDiagnostic = nil lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = nil + mediaLoadingState.lyric = .waitingQqQrc + mediaLoadingState.artwork = .preview(received: 0, expected: 0) log("[Lyrics-iOS] keep previous lyrics until new chunks") } title = newTitle @@ -4239,6 +6230,15 @@ extension BLETestManager: CBPeripheralDelegate { album = newAlbum if !trackID.isEmpty { currentTrackID = trackID + currentTrackGeneration = generation + isShowingLastNowPlayingSnapshot = false + if trackChanged { + restoreFullLyricsCacheIfAvailable( + trackID: trackID, + title: newTitle, + artist: newArtist + ) + } let nowMs = currentTimeMs() lyricTraceTrackInfoAtMs[trackID] = nowMs log( @@ -4256,6 +6256,7 @@ extension BLETestManager: CBPeripheralDelegate { force: trackChanged, reason: trackChanged ? "trackInfo" : "trackInfoRefresh" ) + scheduleLastNowPlayingSnapshotSave(reason: "trackInfo", force: true) } private func clearNowPlayingForNoActiveTrack() { @@ -4285,12 +6286,21 @@ extension BLETestManager: CBPeripheralDelegate { completedLyricSecondaryKeys.removeAll() ignoredLyricSecondaryPlaceholderKeys.removeAll() pendingLyricSecondaryModes.removeAll() + cancelLyricSecondaryTimeouts() + lyricSecondaryDeferredRequestWorkItem?.cancel() + lyricSecondaryDeferredRequestWorkItem = nil + activeLyricSecondaryRequest = nil lyricSecondaryTransfer = nil + lyricSecondaryRetryCounts.removeAll() + lyricSecondaryFailureCooldownUntilMs.removeAll() + translationLyricsState = .idle + romanizationLyricsState = .idle lyricTraceFullLyricsRequestAtMs.removeAll() lyricTraceFullLyricsStartAtMs.removeAll() lyricTraceFirstPlaybackLyricAtMs.removeAll() currentTrackID = "" + currentTrackGeneration = 0 title = "-" artist = "-" album = "-" @@ -4302,6 +6312,7 @@ extension BLETestManager: CBPeripheralDelegate { lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = nil isPlaying = false + updateProgressTimerState() positionMs = 0 displayPositionMs = 0 seekPositionMs = 0 @@ -4309,12 +6320,36 @@ extension BLETestManager: CBPeripheralDelegate { basePlaybackPositionMs = 0 currentWordLineIndex = -1 currentWordIndex = -1 + resetCurrentWordFence() + isShowingLastNowPlayingSnapshot = false + mediaLoadingState = MediaLoadingState() + LastNowPlayingSnapshotStore.shared.clear() albumArtReceiver.clearCurrentIdentity(reason: "no active QQ track") log("[TrackInfo] cleared stale now-playing state reason=no active QQ track") updateLiveActivity(force: true, reason: "noActiveTrack") } private func updateLiveActivity(force: Bool, reason: String) { + if force { + pendingLiveActivityUpdateWorkItem?.cancel() + pendingLiveActivityUpdateWorkItem = nil + } else { + let minimumInterval = minimumLiveActivityUpdateInterval + let elapsed = Date().timeIntervalSince(lastLiveActivityRequestAt) + if elapsed < minimumInterval { + pendingLiveActivityUpdateWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.pendingLiveActivityUpdateWorkItem = nil + self?.updateLiveActivity(force: false, reason: "coalesced:\(reason)") + } + pendingLiveActivityUpdateWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + max(0.05, minimumInterval - elapsed), + execute: workItem + ) + return + } + } let snapshotTitle = title let snapshotArtist = artist let snapshotIsPlaying = isPlaying @@ -4354,6 +6389,17 @@ extension BLETestManager: CBPeripheralDelegate { } } + private var minimumLiveActivityUpdateInterval: TimeInterval { + switch preferences.playbackPerformanceMode { + case .smooth: + return 0.4 + case .automatic: + return appLifecycleState == "active" ? 0.8 : 1.8 + case .powerSaving: + return appLifecycleState == "active" ? 2.0 : 4.0 + } + } + private func updateLiveActivityForCurrentLyricIfNeeded(reason: String) -> Bool { let resolved = resolveCurrentLyric( positionMs: displayPositionMs, @@ -4413,7 +6459,16 @@ extension BLETestManager: CBPeripheralDelegate { return true } - if secondsSinceLastRequest >= 15 { + let progressInterval: TimeInterval + switch preferences.playbackPerformanceMode { + case .smooth: + progressInterval = 10 + case .automatic: + progressInterval = 15 + case .powerSaving: + progressInterval = 30 + } + if secondsSinceLastRequest >= progressInterval { log("[LiveActivityPerf] progress request reason=interval") return true } @@ -4684,6 +6739,7 @@ extension BLETestManager: CBPeripheralDelegate { trackID: transfer.trackId, isFinal: false ) + mediaLoadingState.lyric = .windowReady(lineCount: transfer.chunks.count) log("[LyricWindow-iOS] published lines=\(transfer.chunks.count)") } @@ -4731,6 +6787,10 @@ extension BLETestManager: CBPeripheralDelegate { expectedCRC32: crc ) isFullLyricsReceiving = true + mediaLoadingState.lyric = .fullLyrics( + received: 0, + expected: fullLyricsExpectedCount + ) scheduleFullLyricsBinaryTimeout(trackID: trackID, transferID: transferID) log( "[FullLyricsV2-iOS] start transferId=\(transferID) " + @@ -4739,28 +6799,29 @@ extension BLETestManager: CBPeripheralDelegate { } private func handleFullLyricsBinaryChunk(_ data: Data) { - guard data.count > 6, - data[data.startIndex] == 0xA2, - data[data.startIndex + 1] == 1, + guard let chunk = BLEBinaryChunkCodec.decode(data, expectedMagic: 0xA2), + chunk.kindCode == 1, var transfer = fullLyricsBinaryTransfer, transfer.trackId == currentTrackID else { return } - let index = Int(data[data.startIndex + 2]) << 8 | - Int(data[data.startIndex + 3]) - let total = Int(data[data.startIndex + 4]) << 8 | - Int(data[data.startIndex + 5]) + let index = chunk.index + let total = chunk.total guard total == transfer.expectedChunks, index >= 0, index < transfer.expectedChunks else { log("[FullLyricsV2-iOS] rejected header index=\(index) total=\(total)") return } - transfer.chunks[index] = Data(data.dropFirst(6)) + transfer.chunks[index] = chunk.payload fullLyricsBinaryTransfer = transfer if index == 0 || index == total - 1 || index % 10 == 0 { log( "[FullLyricsV2-iOS] chunk=\(index) " + "received=\(transfer.chunks.count)/\(total)" ) + mediaLoadingState.lyric = .fullLyrics( + received: transfer.chunks.count, + expected: total + ) } } @@ -4769,11 +6830,15 @@ extension BLETestManager: CBPeripheralDelegate { transfer.trackId == currentTrackID, (object["trackId"] as? String ?? object["id"] as? String) == transfer.trackId, (object["transferId"] as? String ?? object["tid"] as? String) == transfer.transferId, - Self.int64Value(object["generation"] ?? object["g"]) == transfer.generation else { + Self.int64Value(object["generation"] ?? object["g"]) == transfer.generation, + fullLyricsBinaryDecodingTransferID != transfer.transferId else { return } fullLyricsTimeoutWorkItem?.cancel() - let missing = (0.. FullLyricsBinaryDecodeResult { + guard let compressed = BLEBinaryChunkCodec.reassemble( + chunks: transfer.chunks, + expectedCount: transfer.expectedChunks + ) else { + return .failure(reason: "reassemble failed") + } guard compressed.count == transfer.expectedSize else { - requestFullLyricsBinaryRetry( - transfer: transfer, - missing: [], - retryAll: true, - reason: "size mismatch" - ) - return + return .failure(reason: "size mismatch") } - guard Self.crc32(compressed) == transfer.expectedCRC32 else { - requestFullLyricsBinaryRetry( - transfer: transfer, - missing: [], - retryAll: true, - reason: "crc mismatch" - ) - return + guard crc32(compressed) == transfer.expectedCRC32 else { + return .failure(reason: "crc mismatch") } - guard let raw = Self.zlibDecompress( + guard let raw = zlibDecompress( compressed, expectedSize: transfer.uncompressedSize ), let payload = try? JSONSerialization.jsonObject(with: raw) as? [String: Any], payload["trackId"] as? String == transfer.trackId, - Self.int64Value(payload["generation"]) == transfer.generation, + int64Value(payload["generation"]) == transfer.generation, let lineObjects = payload["lines"] as? [[String: Any]] else { - requestFullLyricsBinaryRetry( - transfer: transfer, - missing: [], - retryAll: true, - reason: "decode failed" - ) - return + return .failure(reason: "decode failed") } - let lines = lineObjects.compactMap(Self.decodeLyricLine) + let lines = lineObjects.compactMap(decodeLyricLine) guard lines.count == transfer.expectedLineCount else { - requestFullLyricsBinaryRetry( - transfer: transfer, - missing: [], - retryAll: true, - reason: "line count mismatch" - ) - return + return .failure(reason: "line count mismatch") } - fullLyricsBinaryRetryCounts.removeValue(forKey: transfer.transferId) - publishFullLyrics(lines: lines, trackID: transfer.trackId, isFinal: true) - fullLyricsUnavailableTrackIDs.remove(transfer.trackId) - log( - "[FullLyricsV2-iOS] complete transferId=\(transfer.transferId) " + - "compressed=\(compressed.count) lines=\(lines.count)" - ) - resetFullLyricsTransfer() + return .success(lines: lines, compressedSize: compressed.count) } private func requestFullLyricsBinaryRetry( @@ -4917,7 +6994,7 @@ extension BLETestManager: CBPeripheralDelegate { ) } - private static func zlibDecompress(_ input: Data, expectedSize: Int) -> Data? { + static func zlibDecompress(_ input: Data, expectedSize: Int) -> Data? { guard !input.isEmpty, expectedSize > 0 else { return nil } var output = Data(count: expectedSize) var decodedSize = UInt(expectedSize) @@ -4939,15 +7016,8 @@ extension BLETestManager: CBPeripheralDelegate { return output } - private static func crc32(_ data: Data) -> UInt32 { - var crc: UInt32 = 0xffff_ffff - for byte in data { - crc ^= UInt32(byte) - for _ in 0..<8 { - crc = (crc >> 1) ^ ((crc & 1) == 1 ? 0xedb8_8320 : 0) - } - } - return crc ^ 0xffff_ffff + static func crc32(_ data: Data) -> UInt32 { + BLEBinaryChunkCodec.crc32(data) } private func handleFullLyricsStart(_ object: [String: Any]) { @@ -5014,6 +7084,10 @@ extension BLETestManager: CBPeripheralDelegate { "received=\(fullLyricsChunks.count)/\(fullLyricsExpectedCount) " + "words=\(words.count)" ) + mediaLoadingState.lyric = .fullLyrics( + received: fullLyricsChunks.count, + expected: fullLyricsExpectedCount + ) } publishPartialFullLyricsIfNeeded(trackID: trackID) } @@ -5075,6 +7149,11 @@ extension BLETestManager: CBPeripheralDelegate { ) lyricDiagnostic = diagnostic lyricDiagnosticLastUpdatedAt = Date() + if diagnostic.status == "waiting_qqmusic_cache" || + diagnostic.status == "loading" || + diagnostic.status == "retry_pending" { + mediaLoadingState.lyric = .waitingQqQrc + } if lyric.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, fullLyrics.isEmpty { requestLyricDiagnosticIfNeeded() @@ -5094,6 +7173,16 @@ extension BLETestManager: CBPeripheralDelegate { lyricDiagnostic = parseLyricDiagnostic(object) lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = Date() + if let diagnostic = lyricDiagnostic { + if diagnostic.status == "waiting_qqmusic_cache" || + diagnostic.status == "loading" || + diagnostic.status == "retry_pending" { + mediaLoadingState.lyric = .waitingQqQrc + } else if diagnostic.status != "loaded", + fullLyrics.isEmpty { + mediaLoadingState.lyric = .failed(reason: diagnostic.reason) + } + } log( "[LyricsDiag-iOS] received status=\(lyricDiagnostic?.status ?? "") " + "reason=\(lyricDiagnostic?.reason ?? "")" @@ -5175,6 +7264,9 @@ extension BLETestManager: CBPeripheralDelegate { ) lyricDiagnosticLastUpdatedAt = Date() requestLyricDiagnosticIfNeeded() + mediaLoadingState.lyric = .failed( + reason: reason.isEmpty ? status : reason + ) log("[FullLyrics] unavailable reason=\(reason)") } @@ -5185,20 +7277,39 @@ extension BLETestManager: CBPeripheralDelegate { guard trackID == currentTrackID, fullLyricsTrackId == trackID, let mode = LyricSecondaryMode(rawValue: modeRaw), + let request = activeLyricSecondaryRequest, + request.trackId == trackID, + request.mode == mode, + request.connectionAttemptId == connectionAttemptId, !transferID.isEmpty else { log("[Lyrics-iOS] secondary discarded stale trackId=\(trackID)") return } + let itemCount = Self.intValue(object["itemCount"]) + guard itemCount > 0 else { + handleLyricSecondaryFailure( + trackID: trackID, + mode: mode, + reason: "empty transfer", + explicitlyUnavailable: true + ) + return + } + lyricSecondaryStartTimeoutWorkItem?.cancel() + lyricSecondaryStartTimeoutWorkItem = nil lyricSecondaryTransfer = LyricSecondaryTransfer( trackId: trackID, transferId: transferID, mode: mode, - itemCount: Self.intValue(object["itemCount"]), + itemCount: itemCount, + requestToken: request.token, + connectionAttemptId: request.connectionAttemptId, lines: [:] ) + scheduleLyricSecondaryIdleTimeout(for: request) log( "[Lyrics-iOS] secondary start mode=\(mode.rawValue) " + - "items=\(Self.intValue(object["itemCount"]))" + "items=\(itemCount)" ) } @@ -5208,9 +7319,12 @@ extension BLETestManager: CBPeripheralDelegate { let modeRaw = object["mode"] as? String ?? "" guard trackID == currentTrackID, var transfer = lyricSecondaryTransfer, + let request = activeLyricSecondaryRequest, transfer.trackId == trackID, transfer.transferId == transferID, - transfer.mode.rawValue == modeRaw else { + transfer.mode.rawValue == modeRaw, + transfer.requestToken == request.token, + transfer.connectionAttemptId == connectionAttemptId else { return } let lineIndex = Self.intValue(object["lineIndex"]) @@ -5218,6 +7332,7 @@ extension BLETestManager: CBPeripheralDelegate { let partCount = Self.intValue(object["partCount"]) guard lineIndex >= 0, partIndex >= 0, + partIndex < partCount, partCount > 0, let text = object["text"] as? String else { return @@ -5230,6 +7345,7 @@ extension BLETestManager: CBPeripheralDelegate { lineParts.parts[partIndex] = text transfer.lines[lineIndex] = lineParts lyricSecondaryTransfer = transfer + scheduleLyricSecondaryIdleTimeout(for: request) if lineParts.parts.count == partCount { let assembled = (0.. incomingLines.count { + var merged = existingByIndex + for line in incomingLines { + merged[line.index] = line + } + sortedLines = merged.values.sorted { $0.index < $1.index } + } else { + sortedLines = incomingLines + } fullLyrics = sortedLines fullLyricsTrackId = trackID isFullLyricsCurrent = true @@ -5408,6 +7587,14 @@ extension BLETestManager: CBPeripheralDelegate { let romaCount = sortedLines.filter { sanitizedSecondaryText($0.romanization) != nil }.count if isFinal { completedFullLyricsTrackIDs.insert(trackID) + mediaLoadingState.lyric = .ready(lineCount: sortedLines.count) + if transCount > 0 { + translationLyricsState = .ready + } + if romaCount > 0 { + romanizationLyricsState = .ready + } + saveFullLyricsCacheIfCurrent(reason: "fullLyrics") log( "[FullLyrics] end count=\(fullLyrics.count) " + "transCount=\(transCount) romaCount=\(romaCount)" @@ -5417,12 +7604,242 @@ extension BLETestManager: CBPeripheralDelegate { "words=\(wordsCount) transCount=\(transCount) romaCount=\(romaCount)" ) } else { + if case .fullLyrics = mediaLoadingState.lyric { + mediaLoadingState.lyric = .fullLyrics( + received: sortedLines.count, + expected: max(fullLyricsExpectedCount, sortedLines.count) + ) + } else { + mediaLoadingState.lyric = .windowReady(lineCount: sortedLines.count) + } log( "[LyricsPerf] partial publish lines=\(sortedLines.count) " + "receiving=\(isFullLyricsReceiving) " + "transCount=\(transCount) romaCount=\(romaCount)" ) } + scheduleLastNowPlayingSnapshotSave( + reason: isFinal ? "fullLyrics" : "lyricWindow", + force: isFinal + ) + } + + private func restoreFullLyricsCacheIfAvailable( + trackID: String, + title: String, + artist: String + ) { + FullLyricsCacheStore.shared.load( + trackId: trackID, + title: title, + artist: artist + ) { [weak self] entry in + guard let self, + let entry, + self.currentTrackID == trackID, + !self.completedFullLyricsTrackIDs.contains(trackID) else { + return + } + let lines = entry.lines.map(Self.lyricLine(from:)) + guard !lines.isEmpty else { return } + self.fullLyrics = lines.sorted { $0.index < $1.index } + self.fullLyricsTrackId = trackID + self.isFullLyricsCurrent = true + self.mediaLoadingState.lyric = .windowReady(lineCount: lines.count) + if lines.contains(where: { sanitizedSecondaryText($0.translation) != nil }) { + self.translationLyricsState = .ready + } + if lines.contains(where: { sanitizedSecondaryText($0.romanization) != nil }) { + self.romanizationLyricsState = .ready + } + self.log( + "[FullLyricsCache] hit trackId=\(trackID) lines=\(lines.count) " + + "ageMs=\(Int64(Date().timeIntervalSince(entry.savedAt) * 1_000))" + ) + } + } + + private func saveFullLyricsCacheIfCurrent(reason: String) { + guard !currentTrackID.isEmpty, + fullLyricsTrackId == currentTrackID, + !fullLyrics.isEmpty, + !isShowingLastNowPlayingSnapshot else { + return + } + let entry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: currentTrackID, + title: title, + artist: artist, + album: album, + lines: fullLyrics.map(Self.cacheLine(from:)), + savedAt: Date() + ) + FullLyricsCacheStore.shared.save(entry) + log( + "[FullLyricsCache] save queued reason=\(reason) " + + "trackId=\(currentTrackID) lines=\(fullLyrics.count)" + ) + } + + private func restoreLastNowPlayingSnapshot() { + LastNowPlayingSnapshotStore.shared.load { [weak self] snapshot in + guard let self, + let snapshot, + self.currentTrackID.isEmpty, + self.title == "-" else { + return + } + self.currentTrackID = snapshot.trackId + self.title = snapshot.title + self.artist = snapshot.artist + self.album = snapshot.album + self.isPlaying = false + self.positionMs = snapshot.positionMs + self.displayPositionMs = snapshot.positionMs + self.seekPositionMs = snapshot.positionMs + self.durationMs = snapshot.durationMs + self.basePlaybackPositionMs = snapshot.positionMs + self.fullLyrics = snapshot.lyricLines.map(Self.lyricLine(from:)) + self.fullLyricsTrackId = snapshot.trackId + self.isFullLyricsCurrent = !self.fullLyrics.isEmpty + self.lyric = self.fullLyrics.first(where: { + $0.timeMs <= snapshot.positionMs && + snapshot.positionMs < $0.timeMs + max($0.durationMs, 1) + })?.text ?? self.fullLyrics.first?.text ?? "" + self.isShowingLastNowPlayingSnapshot = true + self.albumArtReceiver.handleIdentity( + id: snapshot.albumArtId.isEmpty ? snapshot.trackId : snapshot.albumArtId + ) + self.log( + "[NowPlayingSnapshot] restored trackId=\(snapshot.trackId) " + + "ageMs=\(Int64(Date().timeIntervalSince(snapshot.savedAt) * 1_000))" + ) + } + } + + private func scheduleLastNowPlayingSnapshotSave( + reason: String, + force: Bool = false + ) { + guard !isShowingLastNowPlayingSnapshot, + !currentTrackID.isEmpty, + title != "-" else { + return + } + let nowMs = currentTimeMs() + if !force, nowMs - lastSnapshotQueuedAtMs < 2_000 { + return + } + lastSnapshotQueuedAtMs = nowMs + let lyricWindow = lastSnapshotLyricWindow() + let snapshot = LastNowPlayingSnapshot( + version: LastNowPlayingSnapshot.version, + trackId: currentTrackID, + title: title, + artist: artist, + album: album, + wasPlaying: isPlaying, + positionMs: displayPositionMs, + durationMs: durationMs, + lyricLines: lyricWindow.map(Self.snapshotLine(from:)), + albumArtId: albumArtReceiver.currentAlbumArtID, + savedAt: Date() + ) + LastNowPlayingSnapshotStore.shared.save(snapshot) + if force { + log( + "[NowPlayingSnapshot] queued reason=\(reason) " + + "trackId=\(currentTrackID) lines=\(lyricWindow.count)" + ) + } + } + + private func lastSnapshotLyricWindow() -> [LyricLine] { + guard !fullLyrics.isEmpty else { return [] } + let current = currentLyricIndex( + lines: fullLyrics, + positionMs: displayPositionMs + ) ?? 0 + let lower = max(0, current - 2) + let upper = min(fullLyrics.count, current + 3) + return Array(fullLyrics[lower.. LastNowPlayingSnapshot.Line { + LastNowPlayingSnapshot.Line( + index: line.index, + timeMs: line.timeMs, + durationMs: line.durationMs, + text: line.text, + translation: line.translation, + romanization: line.romanization, + words: line.words.map { + LastNowPlayingSnapshot.Word( + id: $0.id, + startMs: $0.startMs, + durationMs: $0.durationMs, + text: $0.text + ) + } + ) + } + + private static func lyricLine(from line: LastNowPlayingSnapshot.Line) -> LyricLine { + LyricLine( + index: line.index, + timeMs: line.timeMs, + durationMs: line.durationMs, + text: line.text, + translation: line.translation, + romanization: line.romanization, + words: line.words.map { + LyricWord( + id: $0.id, + startMs: $0.startMs, + durationMs: $0.durationMs, + text: $0.text + ) + } + ) + } + + private static func cacheLine(from line: LyricLine) -> FullLyricsCacheEntry.Line { + FullLyricsCacheEntry.Line( + index: line.index, + timeMs: line.timeMs, + durationMs: line.durationMs, + text: line.text, + translation: line.translation, + romanization: line.romanization, + words: line.words.map { + FullLyricsCacheEntry.Word( + id: $0.id, + startMs: $0.startMs, + durationMs: $0.durationMs, + text: $0.text + ) + } + ) + } + + private static func lyricLine(from line: FullLyricsCacheEntry.Line) -> LyricLine { + LyricLine( + index: line.index, + timeMs: line.timeMs, + durationMs: line.durationMs, + text: line.text, + translation: line.translation, + romanization: line.romanization, + words: line.words.map { + LyricWord( + id: $0.id, + startMs: $0.startMs, + durationMs: $0.durationMs, + text: $0.text + ) + } + ) } private func handleCurrentWord(_ object: [String: Any]) { @@ -5457,10 +7874,63 @@ extension BLETestManager: CBPeripheralDelegate { let wordIndex = Self.intValue(object["word"]) let remotePositionMs = Self.int64Value(object["position"]) let timestampMs = Self.int64Value(object["timestamp"]) + let generation = Self.int64Value(object["generation"]) + let sequence = Self.int64Value(object["seq"]) let nowMs = currentTimeMs() - currentWordLineIndex = lineIndex - currentWordIndex = wordIndex + let previousGeneration = currentWordFence.generation + let previousSequence = currentWordFence.sequence + let previousPositionMs = currentWordFence.positionMs + guard currentWordFence.shouldAccept( + generation: generation, + sequence: sequence, + positionMs: remotePositionMs + ) else { + currentWordDropCount += 1 + log( + "[CurrentWordFence] ordered discard trackId=\(trackID) " + + "generation=\(generation) seq=\(sequence) " + + "position=\(remotePositionMs) " + + "lastGeneration=\(previousGeneration) " + + "lastSeq=\(previousSequence) lastPosition=\(previousPositionMs)" + ) + return + } + + let anchorResolution = resolveRemotePlaybackAnchor( + object: object, + remotePositionMs: remotePositionMs, + timestampMs: timestampMs, + isPlaying: isPlaying + ) + guard let calibratedPositionMs = calibratedRemotePosition( + from: anchorResolution, + remotePositionMs: remotePositionMs, + source: "currentWord" + ) else { + currentWordDropCount += 1 + return + } + let transportAgeMs: Int64 + if case let .resolved(_, ageMs) = anchorResolution { + transportAgeMs = ageMs + } else { + transportAgeMs = 0 + } + var effectiveLineIndex = lineIndex + var effectiveWordIndex = wordIndex + var locallyResolvedLyric: String? + if transportAgeMs > 300, + let local = locallyResolvedWord( + positionMs: karaokePositionMs(rawPositionMs: calibratedPositionMs) + ) { + effectiveLineIndex = local.lineIndex + effectiveWordIndex = local.wordIndex + locallyResolvedLyric = local.text + } + + currentWordLineIndex = effectiveLineIndex + currentWordIndex = effectiveWordIndex currentWordPushCount += 1 if lastCurrentWordReceivedAtMs > 0 { currentWordIntervalTotalMs += max(nowMs - lastCurrentWordReceivedAtMs, 0) @@ -5473,42 +7943,65 @@ extension BLETestManager: CBPeripheralDelegate { } } lastCurrentWordReceivedAtMs = nowMs - currentWordLastLatencyMs = timestampMs > 0 ? max(nowMs - timestampMs, 0) : 0 - log( - "[LyricTrace-iOS] id=\(trackID) stage=currentWordAccepted " + - "line=\(lineIndex) word=\(wordIndex) position=\(remotePositionMs) " + - "latencyMs=\(currentWordLastLatencyMs)" - ) + currentWordLastLatencyMs = transportAgeMs + let shouldLogDiagnostic = effectiveLineIndex != lastCurrentWordLoggedLineIndex || + nowMs - lastCurrentWordDiagnosticLogAtMs >= 5_000 + if shouldLogDiagnostic { + lastCurrentWordLoggedLineIndex = effectiveLineIndex + lastCurrentWordDiagnosticLogAtMs = nowMs + log( + "[LyricTrace-iOS] id=\(trackID) stage=currentWordAccepted " + + "generation=\(generation) seq=\(sequence) " + + "line=\(effectiveLineIndex) word=\(effectiveWordIndex) " + + "remotePosition=\(remotePositionMs) " + + "calibratedPosition=\(calibratedPositionMs) " + + "latencyMs=\(currentWordLastLatencyMs)" + ) + } - let lineByOffset = fullLyrics.indices.contains(lineIndex) ? fullLyrics[lineIndex] : nil + let lineByOffset = fullLyrics.indices.contains(effectiveLineIndex) + ? fullLyrics[effectiveLineIndex] + : nil if isSameTrackId(incoming: trackID, current: fullLyricsTrackId), - let line = fullLyrics.first(where: { $0.index == lineIndex }) ?? lineByOffset { + let line = fullLyricLine(withIndex: effectiveLineIndex) ?? lineByOffset { lyric = line.text + } else if let locallyResolvedLyric { + lyric = locallyResolvedLyric } if !isSeeking { - positionMs = remotePositionMs - displayPositionMs = remotePositionMs - seekPositionMs = remotePositionMs - basePlaybackPositionMs = remotePositionMs - playbackStateReceivedAt = Date() + positionMs = calibratedPositionMs + displayPositionMs = calibratedPositionMs + seekPositionMs = calibratedPositionMs + basePlaybackPositionMs = calibratedPositionMs + playbackAnchorElapsedMs = monotonicTimeMs() } - log( - "[Lyrics-iOS] currentWord line=\(lineIndex) word=\(wordIndex) " + - "position=\(remotePositionMs) latencyMs=\(currentWordLastLatencyMs) " + - "count=\(currentWordPushCount) avgIntervalMs=\(currentWordAverageUpdateIntervalMs)" - ) + if shouldLogDiagnostic { + log( + "[Lyrics-iOS] currentWord line=\(effectiveLineIndex) " + + "word=\(effectiveWordIndex) position=\(calibratedPositionMs) " + + "latencyMs=\(currentWordLastLatencyMs) " + + "count=\(currentWordPushCount) " + + "avgIntervalMs=\(currentWordAverageUpdateIntervalMs)" + ) + } if isInReconnectStateSyncWindow() { log( "[Reconnect] currentWord accepted after reconnect " + - "line=\(lineIndex) word=\(wordIndex)" + "line=\(effectiveLineIndex) word=\(effectiveWordIndex)" ) } _ = updateLiveActivityForCurrentLyricIfNeeded(reason: "currentWord") } + private func resetCurrentWordFence() { + currentWordFence.reset() + lastCurrentWordLoggedLineIndex = -1 + lastCurrentWordDiagnosticLogAtMs = 0 + } + private func isInReconnectStateSyncWindow() -> Bool { reconnectStateSyncWindowUntilMs > 0 && currentTimeMs() <= reconnectStateSyncWindowUntilMs } @@ -5523,28 +8016,81 @@ extension BLETestManager: CBPeripheralDelegate { return false } - private func startProgressTimer() { - progressTimer?.invalidate() - let timer = Timer(timeInterval: 0.2, repeats: true) { [weak self] _ in + private func updateProgressTimerState() { + let shouldRun = PlaybackClockPolicy.shouldRun( + isPlaying: isPlaying, + durationMs: durationMs, + appLifecycleState: appLifecycleState + ) + guard shouldRun else { + progressTimer?.invalidate() + progressTimer = nil + let stablePosition = durationMs > 0 + ? positionMs.clamped(to: 0...durationMs) + : 0 + if displayPositionMs != stablePosition { + displayPositionMs = stablePosition + } + return + } + guard progressTimer == nil else { return } + let timer = Timer(timeInterval: progressRefreshInterval, repeats: true) { [weak self] _ in self?.updateInterpolatedProgress() } progressTimer = timer RunLoop.main.add(timer, forMode: .common) } + private var progressRefreshInterval: TimeInterval { + switch preferences.playbackPerformanceMode { + case .smooth: + return 0.1 + case .automatic: + return ProcessInfo.processInfo.isLowPowerModeEnabled ? 0.5 : 0.25 + case .powerSaving: + return 0.5 + } + } + + private func fullLyricLine(withIndex target: Int) -> LyricLine? { + var lower = 0 + var upper = fullLyrics.count - 1 + while lower <= upper { + let middle = lower + (upper - lower) / 2 + let line = fullLyrics[middle] + if line.index == target { + return line + } + if line.index < target { + lower = middle + 1 + } else { + upper = middle - 1 + } + } + return nil + } + private func updateInterpolatedProgress() { guard !isSeeking else { return } guard durationMs > 0 else { - displayPositionMs = 0 + if displayPositionMs != 0 { + displayPositionMs = 0 + } return } guard isPlaying else { - displayPositionMs = positionMs.clamped(to: 0...durationMs) + let stablePosition = positionMs.clamped(to: 0...durationMs) + if displayPositionMs != stablePosition { + displayPositionMs = stablePosition + } return } - let elapsedMs = Int64(Date().timeIntervalSince(playbackStateReceivedAt) * 1_000) - let interpolated = (basePlaybackPositionMs + max(elapsedMs, 0)) + let elapsedMs = max(monotonicTimeMs() - playbackAnchorElapsedMs, 0) + let scaledElapsedMs = Int64( + (Double(max(elapsedMs, 0)) * max(remotePlaybackSpeed, 0)).rounded() + ) + let interpolated = (basePlaybackPositionMs + scaledElapsedMs) .clamped(to: 0...durationMs) if interpolated != displayPositionMs { displayPositionMs = interpolated @@ -5562,32 +8108,56 @@ extension BLETestManager: CBPeripheralDelegate { return } - var data = Data() - data.reserveCapacity(trackInfoExpectedSize) - for index in 0.. some View { - let horizontalPadding = size.width >= 430 ? CGFloat(32) : CGFloat(24) - return darkMainPlayerPanel - .padding(.horizontal, horizontalPadding) - .padding(.top, 22) - .padding(.bottom, 24) - .frame(width: size.width, height: size.height) - } - - private var darkMainPlayerPanel: some View { - VStack(spacing: 0) { - darkSystemHeader - - Spacer(minLength: 24) - - darkTrackInfoSection - - Spacer(minLength: 28) - - darkLyricsSection - - Spacer(minLength: 22) - - darkProgressSection - .padding(.bottom, 26) - - darkControlSection - .padding(.bottom, 30) - - darkVolumeControl - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var darkSystemHeader: some View { - HStack(spacing: 12) { - Button { - bleManager.scanSonyFromMenu() - } label: { - HStack(spacing: 10) { - Circle() - .fill(systemState.connection.color) - .frame(width: 9, height: 9) - .shadow(color: systemState.connection.color.opacity(0.55), radius: 7) - - Text("Sony PlayerAgent") - .font(.system(size: 15, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.94)) - .lineLimit(1) - - Text(systemState.connection.title) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .foregroundStyle(systemState.connection.color.opacity(0.98)) - .lineLimit(1) - } - .contentShape(Rectangle()) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.98)) - .accessibilityLabel("连接状态,点按扫描或重连") - - Spacer() - - darkMenuButton - } - } - - private var darkMenuButton: some View { - Menu { - Button { - bleManager.scanSonyFromMenu() - } label: { - Label("扫描 / 重连", systemImage: "antenna.radiowaves.left.and.right") - } - - Button { - showPlaybackHistory = true - } label: { - Label("播放历史", systemImage: "clock.arrow.circlepath") - } - - Button { - showPreferences = true - } label: { - Label("更多设置", systemImage: "gearshape") - } - - Button { - bleManager.toggleAppExperienceMode() - } label: { - Label( - preferences.appExperienceMode.toggleTitle, - systemImage: isDebugMode ? "person.fill" : "ladybug.fill" - ) - } - - if isDebugMode { - Divider() - - Button { - showSystemHealthOverview = true - } label: { - Label("系统健康总览", systemImage: "heart.text.square") - } - - Button { - showNowPlayingDiagnostic = true - } label: { - Label("当前歌曲诊断", systemImage: "waveform.path.ecg.rectangle") - } - - Button { - bleManager.requestLyricDiagnostic(manual: true) - showLyricDiagnostic = true - } label: { - Label("歌词诊断中心", systemImage: "text.magnifyingglass") - } - - Button { - showDebugPage = true - } label: { - Label("调试工具", systemImage: "slider.horizontal.3") - } - } - - Divider() +private struct PlayerBackgroundHost: View { + let store: ArtworkStore - Picker("歌词显示", selection: lyricDisplayModeBinding) { - ForEach(LyricDisplayMode.allCases) { mode in - Text(mode.menuTitle).tag(mode) - } - } - } label: { - Image(systemName: "gearshape") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(.white.opacity(0.86)) - .frame(width: 42, height: 42) - .background(.white.opacity(0.05), in: Circle()) - .overlay { - Circle().stroke(.white.opacity(0.08), lineWidth: 1) - } - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.96)) - .accessibilityLabel("设置") + var body: some View { + PlayerBackgroundView(image: store.state.image) } +} - private var darkTrackInfoSection: some View { - HStack(spacing: 27) { - albumArtwork(size: 204) - - VStack(alignment: .leading, spacing: 11) { - Text(nowPlayingInfo.title) - .font(.system(size: 34, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .lineLimit(2) - .minimumScaleFactor(0.58) - - Text(nowPlayingInfo.artist) - .font(.system(size: 21, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.74)) - .lineLimit(1) +private struct ResponsivePlayerLayout: View { + let manager: BLETestManager + let availableSize: CGSize + let safeAreaInsets: EdgeInsets + @Binding var showFullLyrics: Bool + @Binding var showDebugPage: Bool + @Binding var showPlaybackHistory: Bool + @Binding var showLyricDiagnostic: Bool + @Binding var showNowPlayingDiagnostic: Bool + @Binding var showSystemHealthOverview: Bool + @Binding var showPreferences: Bool + @Binding var showDeviceDetails: Bool + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ObservedObject private var preferences = PreferencesStore.shared - Text("专辑 · \(nowPlayingInfo.album)") - .font(.system(size: 15, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.46)) - .lineLimit(1) + var body: some View { + let metrics = PlayerLayoutMetrics.resolve( + availableSize: availableSize, + safeAreaInsets: safeAreaInsets, + dynamicTypeSize: dynamicTypeSize, + artworkPreference: preferences.artworkDisplaySize + ) - DarkPlaybackStatusBadge(state: uiState.playback) - .padding(.top, 8) + Group { + if metrics.mode == .regular { + regularContent(metrics: metrics) + } else { + compactContent(metrics: metrics) } - .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.horizontal, metrics.horizontalPadding) + .safeAreaPadding(.top, metrics.mode.isCompact ? 4 : 8) + .safeAreaPadding(.bottom, metrics.mode.isCompact ? 6 : 12) + .frame(width: availableSize.width, height: availableSize.height, alignment: .top) } - private var darkLyricsSection: some View { - HStack(spacing: 22) { - DarkLyricSideDots(color: uiState.playback.accentColor) + private func regularContent(metrics: PlayerLayoutMetrics) -> some View { + VStack(spacing: metrics.sectionSpacing) { + header(mode: .regular) - VStack(spacing: 12) { - Text(lyricPreviewLine(offset: -1)) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.38)) - .multilineTextAlignment(.center) - .lineLimit(1) - .truncationMode(.tail) - .minimumScaleFactor(0.78) - .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) - - KaraokeLyricText( - text: lyricPreviewLine(offset: 0), - progress: currentLyricProgress, - words: lyricPreviewLineModel(offset: 0)?.words ?? [], - positionMs: karaokePositionMs, - highlightColor: uiState.playback.accentColor.opacity(0.92), - normalColor: Color.white.opacity(0.90), - font: .system(size: 27, weight: .semibold, design: .rounded), - lineLimit: 2, - alignment: .center + if showsConnectionGuide { + connectionGuide + .frame(maxHeight: .infinity) + } else { + connectionBanner + TrackInfoStoreView(manager: manager, metrics: metrics) + LyricsPreviewStoreView( + manager: manager, + metrics: metrics, + showFullLyrics: $showFullLyrics ) - .minimumScaleFactor(0.76) - .truncationMode(.tail) - .frame(maxWidth: .infinity, minHeight: 64, maxHeight: 64) - .clipped() - .id(lyricPreviewIdentity) - .transition(.opacity) - .animation(.easeInOut(duration: 0.18), value: lyricPreviewIdentity) - - Text(lyricPreviewLine(offset: 1)) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.40)) - .multilineTextAlignment(.center) - .lineLimit(1) - .truncationMode(.tail) - .minimumScaleFactor(0.78) - .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) - - GeometryReader { proxy in - DarkLyricRhythmLine( - state: uiState.playback, - trackSeed: "\(nowPlayingInfo.title)|\(nowPlayingInfo.artist)|\(nowPlayingInfo.album)", - lyricProgress: currentLyricProgress, - wordSignature: "\(bleManager.currentWordLineIndex):\(bleManager.currentWordIndex)", - positionMs: karaokePositionMs - ) - .frame(width: proxy.size.width * 0.5, height: 36) - .frame(maxWidth: .infinity) - } - .frame(height: 36) + PlaybackProgressStoreView(manager: manager) + PlaybackControlsStoreView(manager: manager, mode: .regular) + VolumeControlStoreView(manager: manager) } - .frame(maxWidth: .infinity, minHeight: 176, maxHeight: 176) - .contentShape(Rectangle()) - .onTapGesture { - if bleManager.fullLyrics.isEmpty { - bleManager.sendGetFullLyrics(force: true) - } - showFullLyrics = true - } - - DarkLyricSideDots(color: uiState.playback.accentColor) } - .accessibilityLabel("当前歌词,点按打开完整歌词") + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var darkProgressSection: some View { - HStack(alignment: .center, spacing: 14) { - Text(format(milliseconds: displayedPositionMs)) - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.white.opacity(0.72)) - .frame(width: 46, alignment: .leading) + private func compactContent(metrics: PlayerLayoutMetrics) -> some View { + VStack(spacing: metrics.sectionSpacing) { + header(mode: metrics.mode) - Slider( - value: Binding( - get: { - Double( - bleManager.isSeeking - ? bleManager.seekPositionMs - : bleManager.displayPositionMs - ) - }, - set: { value in - bleManager.updateSeekPosition(value) - } - ), - in: 0...Double(max(bleManager.durationMs, 1)), - onEditingChanged: { editing in - if editing { - bleManager.beginSeeking() - } else { - bleManager.finishSeeking() - } - } + connectionBanner + PlayerContentStoreRouter( + manager: manager, + metrics: metrics, + showFullLyrics: $showFullLyrics ) - .tint(uiState.playback.accentColor) - .disabled(!isConnected || bleManager.durationMs <= 0) - - Text(format(milliseconds: bleManager.durationMs)) - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.white.opacity(0.72)) - .frame(width: 46, alignment: .trailing) } - .animation( - bleManager.isSeeking ? nil : .linear(duration: 0.18), - value: bleManager.positionMs - ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var darkControlSection: some View { - HStack(spacing: 58) { - darkTransportButton( - title: "上一首", - systemImage: "backward.end.fill", - action: bleManager.sendPrevious - ) - - DarkDynamicPlayButton( - state: uiState.playback, - isEnabled: isConnected, - action: bleManager.sendPlayPause - ) - - darkTransportButton( - title: "下一首", - systemImage: "forward.end.fill", - action: bleManager.sendNext - ) - } - .frame(maxWidth: .infinity) - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.48) + private var showsConnectionGuide: Bool { + !manager.connectionStore.presentation.isConnected && + !hasPlaybackSnapshot } - private var darkVolumeControl: some View { - HStack(spacing: 16) { - Image(systemName: volumeIcon) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white.opacity(0.92)) - .frame(width: 26) + private var hasPlaybackSnapshot: Bool { + NowPlayingSnapshotPolicy.hasDisplayableSnapshot( + title: manager.playbackStore.metadata.title, + artist: manager.playbackStore.metadata.artist, + hasArtwork: manager.artworkStore.state.image != nil, + isRestoredSnapshot: manager.artworkStore.state.isRestoredSnapshot + ) + } - Slider( - value: Binding( - get: { - Double( - bleManager.isVolumeSeeking - ? bleManager.volumeSeekValue - : bleManager.volumeCurrent - ) - }, - set: { value in - bleManager.updateVolumeSeekValue(value) - } - ), - in: 0...Double(max(bleManager.volumeMax, 1)), - step: 1, - onEditingChanged: { editing in - if editing { - bleManager.beginVolumeSeeking() - } else { - bleManager.finishVolumeSeeking() - } - } + @ViewBuilder + private var connectionBanner: some View { + if let presentation = ReconnectBannerPresentation.resolve( + connection: manager.connectionStore.presentation, + hasSnapshot: hasPlaybackSnapshot + ) { + ConnectionStatusBanner( + presentation: presentation, + onRetry: manager.scanSonyFromMenu ) - .tint(uiState.playback.accentColor) - .disabled(!isConnected || bleManager.volumeMax <= 0) - - Text("\(displayedVolume) / \(bleManager.volumeMax)") - .font(.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()) - .foregroundStyle(.white.opacity(0.80)) - .frame(width: 64, alignment: .trailing) - .contentTransition(.numericText()) - } - .padding(.horizontal, 16) - .frame(height: 48) - .background(Color.black.opacity(0.18), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(.white.opacity(0.07), lineWidth: 1) } - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.48) - .animation(.easeInOut(duration: 0.16), value: displayedVolume) - } - - private var systemState: DarkControlSystemState { - DarkControlSystemState(connection: DarkControlConnectionState(rawValue: bleManager.connectionDisplayState) ?? .disconnected) } - private var playbackState: DarkControlPlaybackState { - DarkControlPlaybackState( - isPlaying: bleManager.isPlaying, - isLoading: !isConnected && bleManager.connectionDisplayState != "disconnected" + private var connectionGuide: some View { + ConnectionGuideStoreView( + presentation: manager.connectionStore.presentation, + onConnect: manager.scanSonyFromMenu ) } - private var uiState: DarkControlUIState { - DarkControlUIState(system: systemState, playback: playbackState.visualState(connection: systemState.connection)) + private func header(mode: PlayerLayoutMode) -> some View { + PlayerHeaderStoreView( + manager: manager, + mode: mode, + showDebugPage: $showDebugPage, + showPlaybackHistory: $showPlaybackHistory, + showLyricDiagnostic: $showLyricDiagnostic, + showNowPlayingDiagnostic: $showNowPlayingDiagnostic, + showSystemHealthOverview: $showSystemHealthOverview, + showPreferences: $showPreferences, + showDeviceDetails: $showDeviceDetails + ) } +} - private func albumArtwork(size: CGFloat) -> some View { - ZStack(alignment: .bottomLeading) { - if let image = bleManager.albumArtImage { - Image(uiImage: image) - .resizable() - .interpolation(.high) - .antialiased(true) - .scaledToFill() - } else { - DefaultAlbumArtView() - } - } - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 25, style: .continuous) - .stroke(.white.opacity(0.12), lineWidth: 1) - } - .shadow(color: .black.opacity(0.36), radius: 22, x: 0, y: 14) - .accessibilityLabel("当前歌曲封面") - } +private struct PlayerHeaderStoreView: View { + let manager: BLETestManager + let mode: PlayerLayoutMode + @Binding var showDebugPage: Bool + @Binding var showPlaybackHistory: Bool + @Binding var showLyricDiagnostic: Bool + @Binding var showNowPlayingDiagnostic: Bool + @Binding var showSystemHealthOverview: Bool + @Binding var showPreferences: Bool + @Binding var showDeviceDetails: Bool - private func darkTransportButton( - title: String, - systemImage: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: systemImage) - .font(.system(size: 30, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 62, height: 62) - .contentShape(Circle()) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.90)) - .accessibilityLabel(title) - } + @ObservedObject private var preferences = PreferencesStore.shared - private var connectionSection: some View { + var body: some View { HStack(spacing: 12) { - Button { - bleManager.scanSonyFromMenu() - } label: { - HStack(spacing: 8) { - Circle() - .fill(connectionColor) - .frame(width: 8, height: 8) - .shadow(color: connectionColor.opacity(0.42), radius: 5) - - Text(connectionStatusTitle) - .font(.footnote.weight(.medium)) - .foregroundStyle(.white.opacity(0.88)) - .lineLimit(1) + Button(action: handleConnectionTap) { + HStack(spacing: 7) { + statusIndicator + if mode != .accessibility { + Text(statusTitle) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(isConnected ? .white.opacity(0.88) : statusColor) + .lineLimit(1) + .contentTransition(.opacity) + } } - .padding(.horizontal, 14) - .frame(height: 42) - .contentShape(Capsule()) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.97)) - .background(.white.opacity(0.070), in: Capsule()) - .overlay { - Capsule().stroke(.white.opacity(0.070), lineWidth: 1) + .padding(.horizontal, 11) + .frame(height: 34) + .background(statusColor.opacity(isConnected ? 0.06 : 0.13), in: Capsule()) + .overlay { Capsule().strokeBorder(statusColor.opacity(0.20), lineWidth: 1) } + .contentShape(Rectangle()) + .padding(.vertical, 5) } - .accessibilityLabel("连接状态,点按扫描或重连") + .buttonStyle(PressScaleButtonStyle(pressedScale: 0.98)) + .accessibilityLabel( + isConnected ? AppLocalization.string("Sony 已连接") : statusTitle + ) + .accessibilityHint( + AppLocalization.string( + statusPresentation.opensDeviceDetail + ? "点按查看连接详情" + : "点按扫描并连接 Sony" + ) + ) Spacer() Menu { - Button { - bleManager.scanSonyFromMenu() - } label: { + Button { manager.scanSonyFromMenu() } label: { Label("扫描 / 重连", systemImage: "antenna.radiowaves.left.and.right") } - - Button { - showPlaybackHistory = true - } label: { + Button { showPlaybackHistory = true } label: { Label("播放历史", systemImage: "clock.arrow.circlepath") } - - Button { - showPreferences = true - } label: { - Label("设置", systemImage: "gearshape") + Button { showPreferences = true } label: { + Label("更多设置", systemImage: "gearshape") } - - Button { - bleManager.toggleAppExperienceMode() - } label: { + Button { manager.toggleAppExperienceMode() } label: { Label( preferences.appExperienceMode.toggleTitle, systemImage: isDebugMode ? "person.fill" : "ladybug.fill" ) } - if isDebugMode { Divider() - - Button { - showSystemHealthOverview = true - } label: { + Button { showSystemHealthOverview = true } label: { Label("系统健康总览", systemImage: "heart.text.square") } - - Button { - showNowPlayingDiagnostic = true - } label: { + Button { showNowPlayingDiagnostic = true } label: { Label("当前歌曲诊断", systemImage: "waveform.path.ecg.rectangle") } - Button { - bleManager.requestLyricDiagnostic(manual: true) + manager.requestLyricDiagnostic(manual: true) showLyricDiagnostic = true } label: { Label("歌词诊断中心", systemImage: "text.magnifyingglass") } - - Button { - showDebugPage = true - } label: { + Button { showDebugPage = true } label: { Label("调试工具", systemImage: "slider.horizontal.3") } } - Divider() - - Picker("歌词显示", selection: lyricDisplayModeBinding) { + Picker( + "歌词显示", + selection: Binding( + get: { preferences.lyricDisplayMode }, + set: { preferences.lyricDisplayMode = $0 } + ) + ) { ForEach(LyricDisplayMode.allCases) { mode in Text(mode.menuTitle).tag(mode) } } } label: { Image(systemName: "ellipsis") - .font(.system(size: 19, weight: .bold)) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.86)) .frame(width: 42, height: 42) + .background(.white.opacity(0.05), in: Circle()) + .overlay { Circle().stroke(.white.opacity(0.08), lineWidth: 1) } } .buttonStyle(PressScaleButtonStyle(pressedScale: 0.96)) - .background(.white.opacity(0.075), in: Circle()) - .overlay { - Circle().stroke(.white.opacity(0.075), lineWidth: 1) - } - .accessibilityLabel("更多操作") + .accessibilityLabel("更多") } - .foregroundStyle(.white) - .animation(.easeInOut(duration: 0.2), value: bleManager.connectionDisplayState) } - private var nowPlayingSection: some View { - VStack(spacing: 9) { - albumArtView - .id(albumArtIdentity) - .transition(.opacity.combined(with: .scale(scale: 0.98))) - .animation(.easeInOut(duration: 0.28), value: albumArtIdentity) - - VStack(spacing: 4) { - Text(nowPlayingInfo.title) - .font(.system(size: 32, weight: .bold, design: .rounded)) - .multilineTextAlignment(.center) - .lineLimit(2) - .minimumScaleFactor(0.58) - - Text(nowPlayingInfo.artist) - .font(.system(size: 18, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.72)) - .lineLimit(1) - .padding(.top, 4) - - Text(nowPlayingInfo.album) - .font(.system(size: 15, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.46)) - .lineLimit(1) - } - - HStack(spacing: 8) { - Image(systemName: bleManager.isPlaying ? "music.note" : "pause.fill") - Text(bleManager.isPlaying ? "播放中" : "已暂停") - } - .font(.caption.weight(.semibold)) - .foregroundStyle( - bleManager.isPlaying - ? Color.green.opacity(0.95) - : Color.white.opacity(0.62) - ) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background(.white.opacity(0.095), in: Capsule()) - .overlay { - Capsule().stroke(.white.opacity(0.08), lineWidth: 1) - } - .animation(.spring(response: 0.28, dampingFraction: 0.78), value: bleManager.isPlaying) - } - .foregroundStyle(.white) - .frame(maxWidth: .infinity) + private var presentation: BLEConnectionPresentationState { + manager.connectionStore.presentation } - - private var albumArtView: some View { - ZStack { - if let image = bleManager.albumArtImage { - Image(uiImage: image) - .resizable() - .interpolation(.high) - .antialiased(true) - .scaledToFill() - } else { - DefaultAlbumArtView() - } - } - .frame(width: albumArtSize, height: albumArtSize) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) + private var isConnected: Bool { presentation.isConnected } + private var isDebugMode: Bool { preferences.appExperienceMode == .debug } + private var showsProgress: Bool { + switch presentation { + case .scanning, .connecting, .reconnecting: return true + default: return false } - .shadow(color: .black.opacity(0.28), radius: 22, y: 12) - .accessibilityLabel("当前歌曲封面") } - - private var lyricCard: some View { - VStack(spacing: 8) { - Text("当前歌词") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.46)) - .textCase(.uppercase) - .tracking(1.2) - - if currentTrackFullLyrics.isEmpty { - VStack(spacing: 8) { - Text(currentLyricText) - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .foregroundStyle( - currentLyricText == "暂无歌词" - ? Color.white.opacity(0.58) - : Color.white.opacity(0.93) - ) - .multilineTextAlignment(.center) - .lineLimit(2) - .minimumScaleFactor(0.68) - .frame(maxWidth: .infinity, minHeight: 48) - - if currentLyricText == "暂无歌词" { - if isDebugMode { - Text("原因:\(bleManager.lyricDiagnostic?.statusTitle ?? "正在确认")") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.54)) - .lineLimit(1) - - Button { - bleManager.requestLyricDiagnostic(manual: true) - showLyricDiagnostic = true - } label: { - Text("查看原因") - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .frame(height: 30) - .background(.white.opacity(0.10), in: Capsule()) - } - .buttonStyle(.plain) - } else { - Text("提示:可在 Sony QQ音乐打开歌词/桌面歌词后稍等") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.54)) - .multilineTextAlignment(.center) - .lineLimit(2) - - Button { - bleManager.refreshCurrentLyricFromNowPlayingDiagnostics() - } label: { - Text("刷新歌词") - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .frame(height: 30) - .background(.white.opacity(0.10), in: Capsule()) - } - .buttonStyle(.plain) - } - } - } - .frame(maxWidth: .infinity, minHeight: 82) - } else { - VStack(spacing: 5) { - Text(lyricPreviewLine(offset: -1)) - .font(.system(size: 17, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.44)) - .lineLimit(1) - KaraokeLyricText( - text: lyricPreviewLine(offset: 0), - progress: currentLyricProgress, - words: lyricPreviewLineModel(offset: 0)?.words ?? [], - positionMs: karaokePositionMs, - highlightColor: Color.green.opacity(0.98), - normalColor: Color.white.opacity(0.48), - font: .system(size: 24, weight: .bold, design: .rounded), - lineLimit: 2, - alignment: .center - ) - .minimumScaleFactor(0.72) - if let auxiliary = lyricPreviewAuxiliaryText(offset: 0) { - Text(auxiliary) - .font(.system(size: 14, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.52)) - .lineLimit(1) - .minimumScaleFactor(0.72) - } - Text(lyricPreviewLine(offset: 1)) - .font(.system(size: 17, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.44)) - .lineLimit(1) - } - .frame(maxWidth: .infinity, minHeight: 82) - } - } - .padding(.horizontal, 4) - .padding(.vertical, 4) - .id(lyricPreviewIdentity) - .transition(.opacity) - .animation(.easeInOut(duration: 0.22), value: lyricPreviewIdentity) - .contentShape(Rectangle()) - .onTapGesture { - if bleManager.fullLyrics.isEmpty { - bleManager.sendGetFullLyrics(force: true) - } - showFullLyrics = true - } - .accessibilityLabel("打开完整歌词") + private var statusPresentation: ConnectionStatusPresentation { + ConnectionStatusPresentation.resolve(presentation) } - - private var progressSection: some View { - VStack(spacing: 6) { - Slider( - value: Binding( - get: { - Double( - bleManager.isSeeking - ? bleManager.seekPositionMs - : bleManager.displayPositionMs - ) - }, - set: { value in - bleManager.updateSeekPosition(value) - } - ), - in: 0...Double(max(bleManager.durationMs, 1)), - onEditingChanged: { editing in - if editing { - bleManager.beginSeeking() - } else { - bleManager.finishSeeking() - } - } - ) - .tint(.white.opacity(0.94)) - .disabled(!isConnected || bleManager.durationMs <= 0) - - HStack { - Text(format(milliseconds: displayedPositionMs)) - Spacer() - Text(format(milliseconds: bleManager.durationMs)) - } - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.white.opacity(0.58)) + private var statusColor: Color { statusPresentation.color } + private var statusTitle: String { + AppLocalization.string(statusPresentation.title) + } + @ViewBuilder + private var statusIndicator: some View { + if showsProgress { + ProgressView() + .controlSize(.mini) + .tint(statusColor) + .frame(width: 12, height: 12) + } else if isConnected { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .shadow(color: statusColor.opacity(0.48), radius: 4) + .frame(width: 12, height: 12) + } else { + Image(systemName: statusIcon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(statusColor) + .frame(width: 12, height: 12) + } + } + private var statusIcon: String { + switch presentation { + case .connected: return "antenna.radiowaves.left.and.right" + case .unavailable(.poweredOff): return "power" + case .unavailable(.unauthorized): return "lock.trianglebadge.exclamationmark" + case .failed: return "exclamationmark.triangle.fill" + default: return "antenna.radiowaves.left.and.right.slash" + } + } + private func handleConnectionTap() { + if statusPresentation.opensDeviceDetail { + showDeviceDetails = true + } else if case .unavailable(.unauthorized) = presentation, + let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } else if statusPresentation.isBusy { + return + } else { + manager.scanSonyFromMenu() } - .padding(.horizontal, 4) - .animation( - bleManager.isSeeking ? nil : .linear(duration: 0.18), - value: bleManager.positionMs - ) } +} - private var playbackControls: some View { - HStack(spacing: 26) { - playerControlButton( - title: "上一首", - systemImage: "backward.fill", - size: 52, - fontSize: 22, - action: bleManager.sendPrevious - ) +private struct PlayerContentStoreRouter: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool - Button(action: bleManager.sendPlayPause) { - Image(systemName: bleManager.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 30, weight: .bold)) - .foregroundStyle(.black) - .frame(width: 70, height: 70) - .background(.white, in: Circle()) - .shadow(color: .black.opacity(0.18), radius: 12, y: 7) - .scaleEffect(bleManager.isPlaying ? 1.0 : 0.96) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.92)) - .accessibilityLabel("播放 / 暂停") - - playerControlButton( - title: "下一首", - systemImage: "forward.fill", - size: 52, - fontSize: 22, - action: bleManager.sendNext + var body: some View { + if !manager.connectionStore.presentation.isConnected, + !NowPlayingSnapshotPolicy.hasDisplayableSnapshot( + title: manager.playbackStore.metadata.title, + artist: manager.playbackStore.metadata.artist, + hasArtwork: manager.artworkStore.state.image != nil, + isRestoredSnapshot: manager.artworkStore.state.isRestoredSnapshot + ) { + ConnectionGuideStoreView( + presentation: manager.connectionStore.presentation, + onConnect: manager.scanSonyFromMenu + ) + } else { + PlayerStoreSections( + manager: manager, + metrics: metrics, + showFullLyrics: $showFullLyrics ) } - .frame(maxWidth: .infinity) - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.46) - .animation(.spring(response: 0.28, dampingFraction: 0.72), value: bleManager.isPlaying) } +} - private var volumeSection: some View { - HStack(spacing: 12) { - Image(systemName: volumeIcon) - .font(.headline) - .frame(width: 24) - - Slider( - value: Binding( - get: { - Double( - bleManager.isVolumeSeeking - ? bleManager.volumeSeekValue - : bleManager.volumeCurrent - ) - }, - set: { value in - bleManager.updateVolumeSeekValue(value) - } - ), - in: 0...Double(max(bleManager.volumeMax, 1)), - step: 1, - onEditingChanged: { editing in - if editing { - bleManager.beginVolumeSeeking() - } else { - bleManager.finishVolumeSeeking() - } - } - ) - .tint(.white.opacity(0.92)) - .disabled(!isConnected || bleManager.volumeMax <= 0) +private struct ConnectionGuideStoreView: View { + let presentation: BLEConnectionPresentationState + let onConnect: () -> Void - Text("\(displayedVolume) / \(bleManager.volumeMax)") - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.white.opacity(0.76)) - .frame(width: 58, alignment: .trailing) - .contentTransition(.numericText()) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(.white) - .background(.white.opacity(0.070), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 1) - } - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.52) - .animation(.easeInOut(duration: 0.16), value: displayedVolume) - } + var body: some View { + VStack(spacing: 18) { + Image(systemName: icon) + .font(.system(size: 46, weight: .semibold)) + .foregroundStyle(.orange.opacity(0.90)) + .frame(width: 86, height: 86) + .background(.white.opacity(0.06), in: Circle()) + + VStack(spacing: 8) { + Text(title) + .font(.title2.bold()) + .multilineTextAlignment(.center) + Text(detail) + .font(.body) + .foregroundStyle(.white.opacity(0.64)) + .multilineTextAlignment(.center) + } - private var connectionColor: Color { - switch bleManager.connectionDisplayState { - case "connected": - return .green - case "reconnecting": - return .orange - case "disconnected": - return .secondary - default: - return .secondary + Button(action: primaryAction) { + Label(buttonTitle, systemImage: isBusy ? "hourglass" : "antenna.radiowaves.left.and.right") + .font(.headline) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.75) + .padding(.vertical, 10) + .frame(maxWidth: 280, minHeight: 50) + .background(.white, in: Capsule()) + .foregroundStyle(.black) + } + .buttonStyle(PressScaleButtonStyle(pressedScale: 0.97)) + .disabled(isPrimaryActionDisabled) } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 430) + .padding(.horizontal, 24) } - private var connectionStatusTitle: String { - switch bleManager.connectionDisplayState { - case "connected": - return "Sony 已连接" - case "reconnecting": - return "正在重连" - case "disconnected": - return "未连接" + private var isBusy: Bool { + switch presentation { + case .scanning, .connecting, .reconnecting: return true + default: return false + } + } + private var isPrimaryActionDisabled: Bool { + if isBusy { return true } + switch presentation { + case .unavailable(.poweredOff), .unavailable(.unsupported), .unavailable(.resetting), + .unavailable(.unknown), .unavailable(.available): + return true default: - return "未连接" + return false + } + } + private var title: String { + let key: String = switch presentation { + case .unavailable(.poweredOff): "请打开蓝牙" + case .unavailable(.unauthorized): "需要蓝牙权限" + case .unavailable(.unsupported): "此设备不支持蓝牙" + case .unavailable: "蓝牙暂不可用" + case .scanning: "正在查找 Sony" + case .connecting: "正在连接 Sony" + case .reconnecting: "正在恢复连接" + case .failed: "连接没有成功" + case .disconnected: "连接你的 Sony 设备" + case .connected: "已连接" + } + return AppLocalization.string(key) + } + private var detail: String { + let key: String = switch presentation { + case .unavailable(.poweredOff): "打开系统蓝牙后,应用会自动继续连接。" + case .unavailable(.unauthorized): "请在系统设置中允许 Sony Music 使用蓝牙。" + case .unavailable(.unsupported): "需要支持低功耗蓝牙的 iPhone。" + case .unavailable: "蓝牙正在恢复,请稍后再试。" + case .scanning: "请确保 Sony 端已启动并处于可连接状态。" + case .connecting, .reconnecting: "正在同步播放状态、歌词和封面。" + case .failed: "确认 Sony 端已启动,然后重新扫描。" + case .disconnected: "连接后可控制播放,并同步歌词、封面与灵动岛。" + case .connected: "" + } + return AppLocalization.string(key) + } + private var icon: String { + switch presentation { + case .unavailable(.poweredOff): return "power" + case .unavailable(.unauthorized): return "lock.trianglebadge.exclamationmark" + case .failed: return "exclamationmark.triangle.fill" + case .scanning, .connecting, .reconnecting: return "antenna.radiowaves.left.and.right" + default: return "hifispeaker.2.fill" + } + } + private var buttonTitle: String { + let key: String = switch presentation { + case .unavailable(.unauthorized): "打开设置" + case .unavailable(.poweredOff): "等待蓝牙开启" + case .unavailable(.unsupported): "当前设备不支持" + case .unavailable: "等待蓝牙恢复" + case .scanning: "正在扫描" + case .connecting: "正在连接" + case .reconnecting: "正在恢复" + default: "扫描并连接" + } + return AppLocalization.string(key) + } + private func primaryAction() { + if case .unavailable(.unauthorized) = presentation, + let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } else { + onConnect() } } +} - private var isConnected: Bool { - bleManager.connectionDisplayState == "connected" - } +private struct PlayerStoreSections: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool - private var isDebugMode: Bool { - preferences.appExperienceMode == .debug + var body: some View { + VStack(spacing: metrics.sectionSpacing) { + TrackInfoStoreView(manager: manager, metrics: metrics) + LyricsPreviewStoreView( + manager: manager, + metrics: metrics, + showFullLyrics: $showFullLyrics + ) + PlaybackProgressStoreView(manager: manager) + PlaybackControlsStoreView(manager: manager, mode: metrics.mode) + VolumeControlStoreView(manager: manager) + } } +} - private var displayedPositionMs: Int64 { - bleManager.isSeeking ? bleManager.seekPositionMs : bleManager.displayPositionMs - } +private struct TrackInfoStoreView: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics - private var karaokePositionMs: Int64 { - bleManager.karaokePositionMs(rawPositionMs: displayedPositionMs) - } + private var mode: PlayerLayoutMode { metrics.mode } - private var displayedVolume: Int { - bleManager.isVolumeSeeking ? bleManager.volumeSeekValue : bleManager.volumeCurrent - } - - private var nowPlayingInfo: NowPlayingInfoProvider { - NowPlayingInfoProvider( - title: displayText(bleManager.title, fallback: "Sony Music"), - artist: displayText(bleManager.artist, fallback: "未知歌手"), - album: displayText(bleManager.album, fallback: "未知专辑"), - albumArt: bleManager.albumArtImage, - positionMs: displayedPositionMs, - durationMs: bleManager.durationMs, - isPlaying: bleManager.isPlaying - ) + var body: some View { + Group { + if mode == .regular || mode == .accessibility { + VStack(spacing: mode == .regular ? 12 : 8) { + artwork + metadata.multilineTextAlignment(.center) + } + } else { + HStack(spacing: mode.isCompact ? 16 : 27) { + artwork + metadata + } + } + } + .frame(maxWidth: .infinity) } - private var lyricDisplayMode: LyricDisplayMode { - preferences.lyricDisplayMode + private var artworkSize: CGFloat { metrics.artworkSize } + private var artwork: some View { + Group { + if let image = manager.artworkStore.state.image { + Image(uiImage: image) + .resizable() + .interpolation(.high) + .scaledToFill() + } else { + DefaultAlbumArtView() + } + } + .frame(width: artworkSize, height: artworkSize) + .clipShape(RoundedRectangle(cornerRadius: mode.isCompact ? 20 : 25, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: mode.isCompact ? 20 : 25) + .stroke(.white.opacity(0.12)) + } + .shadow(color: .black.opacity(0.34), radius: 20, y: 12) + .accessibilityLabel("当前歌曲封面") } - - private func requestOptionalLyricsIfNeeded() { - bleManager.requestFullLyricsOptionalFieldsIfNeeded(displayMode: lyricDisplayMode) + private var metadata: some View { + let metadata = manager.playbackStore.metadata + let centered = mode == .regular || mode == .accessibility + return VStack(alignment: centered ? .center : .leading, spacing: mode.isCompact ? 5 : 7) { + Text(display(metadata.title, fallback: AppLocalization.string("等待同步"))) + .font( + mode.isCompact + ? .system(.title2, design: .rounded, weight: .bold) + : .system(size: 31, weight: .bold, design: .rounded) + ) + .foregroundStyle(.white) + .lineLimit(mode == .accessibility ? 3 : 2) + .minimumScaleFactor(0.58) + Text(display(metadata.artist, fallback: AppLocalization.string("等待同步"))) + .font(mode.isCompact ? .body.weight(.medium) : .body.weight(.semibold)) + .foregroundStyle(.white.opacity(0.74)) + .lineLimit(2) + Text( + String( + format: AppLocalization.string("专辑 · %@"), + display(metadata.album, fallback: AppLocalization.string("等待同步")) + ) + ) + .font(.system(.caption, design: .rounded, weight: .medium)) + .foregroundStyle(.white.opacity(0.48)) + .lineLimit(2) + DarkPlaybackStatusBadge(state: playerVisualState(manager: manager)) + .overlay(alignment: .bottomLeading) { + if manager.artworkStore.state.isRestoredSnapshot { + Text(AppLocalization.string("上次播放 · 等待同步")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.orange) + .fixedSize() + .offset(y: 20) + } + } + } + .frame(maxWidth: .infinity, alignment: centered ? .center : .leading) } - - private var lyricDisplayModeBinding: Binding { - Binding( - get: { lyricDisplayMode }, - set: { preferences.lyricDisplayMode = $0 } - ) + private func display(_ value: String, fallback: String) -> String { + value == "-" || value.isEmpty ? fallback : value } +} - private var currentLyricText: String { - let trimmed = bleManager.lyric.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "暂无歌词" : trimmed - } +private struct LyricsPreviewStoreView: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool + @ObservedObject private var preferences = PreferencesStore.shared - private var currentFullLyricIndex: Int { - LyricTimelineHelper.currentIndex( - lines: currentTrackFullLyrics, - positionMs: karaokePositionMs - ) ?? -1 + private var mode: PlayerLayoutMode { metrics.mode } + + var body: some View { + Button(action: openFullLyrics) { + HStack(spacing: mode.isCompact ? 12 : 22) { + DarkLyricSideDots(color: visualState.accentColor) + VStack(spacing: mode.isCompact ? 7 : 12) { + Text(line(offset: -1)) + .font(secondaryLineFont) + .foregroundStyle(.white.opacity(0.38)) + .lineLimit(1) + .minimumScaleFactor(0.78) + KaraokeLyricText( + text: line(offset: 0), + progress: lineProgress, + words: lineModel(offset: 0)?.words ?? [], + positionMs: karaokePosition, + isPlaying: timeline.isPlaying && !timeline.isSeeking, + highlightColor: visualState.accentColor.opacity(0.92), + normalColor: .white.opacity(0.90), + font: currentLineFont, + lineLimit: mode == .accessibility ? 4 : 2, + alignment: .center + ) + .minimumScaleFactor(0.76) + .frame(maxWidth: .infinity, minHeight: mode.isCompact ? 38 : 50) + .clipped() + if let auxiliaryLine { + Text(auxiliaryLine) + .font(.caption.weight(.medium)) + .foregroundStyle(PlayerDesignTokens.secondaryText.opacity(0.72)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + Text(line(offset: 1)) + .font(secondaryLineFont) + .foregroundStyle(.white.opacity(0.40)) + .lineLimit(1) + .minimumScaleFactor(0.78) + GeometryReader { proxy in + DarkLyricRhythmLine( + state: visualState, + trackSeed: manager.playbackStore.metadata.title + manager.playbackStore.metadata.artist, + lyricProgress: lineProgress, + wordSignature: "\(manager.lyricsStore.live.currentWordLineIndex):\(manager.lyricsStore.live.currentWordIndex)", + positionMs: karaokePosition + ) + .frame(width: proxy.size.width * 0.5, height: mode.isCompact ? 24 : 36) + .frame(maxWidth: .infinity) + } + .frame(height: mode.isCompact ? 18 : 24) + } + .frame( + maxWidth: .infinity, + minHeight: metrics.lyricHeight, + maxHeight: metrics.lyricHeight + ) + DarkLyricSideDots(color: visualState.accentColor) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("当前歌词,点按打开完整歌词") + .onChange(of: displayedPosition) { _, value in + manager.logKaraokeOffset(rawPositionMs: value) + } } - private var currentLyricProgress: Double { + private var timeline: BLEPlaybackTimelineState { manager.playbackStore.timeline } + private var document: BLEFullLyricsViewState { manager.lyricsStore.document } + private var lines: [LyricLine] { document.isCurrent ? document.lines : [] } + private var displayedPosition: Int64 { + timeline.isSeeking ? timeline.seekPositionMs : timeline.displayPositionMs + } + private var karaokePosition: Int64 { + manager.karaokePositionMs(rawPositionMs: displayedPosition) + } + private var currentIndex: Int { + LyricTimelineHelper.currentIndex(lines: lines, positionMs: karaokePosition) ?? -1 + } + private var lineProgress: Double { LyricTimelineHelper.lineProgress( - lines: currentTrackFullLyrics, - index: currentFullLyricIndex, - positionMs: karaokePositionMs + lines: lines, + index: currentIndex, + positionMs: karaokePosition ) } - - private var lyricPreviewIdentity: String { - if currentTrackFullLyrics.isEmpty { - return currentLyricText - } - return "\(bleManager.fullLyricsTrackId)-\(currentFullLyricIndex)" + private var visualState: DarkPlaybackVisualState { playerVisualState(manager: manager) } + private var auxiliaryLine: String? { + guard let line = lineModel(offset: 0) else { return nil } + let candidate: String? + switch preferences.lyricDisplayMode { + case .original: + candidate = nil + case .originalRomanization: + candidate = line.romanization + case .originalTranslation, .originalTranslationRomanization: + candidate = line.translation + } + let text = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + private var secondaryLineFont: Font { + if mode == .accessibility { + return .system(.body, design: .rounded, weight: .medium) + } + return .system( + size: mode.isCompact ? 14 : 16, + weight: .medium, + design: .rounded + ) } - - private func lyricPreviewLine(offset: Int) -> String { - guard let line = lyricPreviewLineModel(offset: offset) else { - return offset == 0 ? currentLyricText : " " + private var currentLineFont: Font { + if mode == .accessibility { + return .system(.title2, design: .rounded, weight: .semibold) } - let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? " " : text + return .system( + size: mode.isCompact ? 21 : 27, + weight: .semibold, + design: .rounded + ) } - - private func lyricPreviewLineModel(offset: Int) -> LyricLine? { - let index = currentFullLyricIndex + offset - guard currentTrackFullLyrics.indices.contains(index) else { - return nil - } - return currentTrackFullLyrics[index] + private func lineModel(offset: Int) -> LyricLine? { + let index = currentIndex + offset + return lines.indices.contains(index) ? lines[index] : nil } - - private func lyricPreviewAuxiliaryText(offset: Int) -> String? { - guard let line = lyricPreviewLineModel(offset: offset) else { return nil } - if lyricDisplayMode.showsTranslation, - let translation = sanitizedSecondaryText(line.translation) { - return translation + private func line(offset: Int) -> String { + if let model = lineModel(offset: offset) { + let text = model.text.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? " " : text } - if lyricDisplayMode.showsRomanization, - let romanization = sanitizedSecondaryText(line.romanization) { - return romanization + if offset == 0 { + let text = manager.lyricsStore.live.text.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "暂无歌词" : text } - return nil + return " " } - - private var currentTrackFullLyrics: [LyricLine] { - bleManager.isFullLyricsCurrent ? bleManager.fullLyrics : [] + private func openFullLyrics() { + if document.lines.isEmpty { manager.sendGetFullLyrics(force: true) } + manager.requestFullLyricsOptionalFieldsIfNeeded(displayMode: preferences.lyricDisplayMode) + showFullLyrics = true } +} - private var albumArtIdentity: String { - if bleManager.albumArtImage == nil { - return "default-\(bleManager.title)-\(bleManager.artist)" +private struct PlaybackProgressStoreView: View { + let manager: BLETestManager + + var body: some View { + let timeline = manager.playbackStore.timeline + HStack(spacing: 12) { + Text(format(displayedPosition, duration: timeline.durationMs)) + .frame(width: 46, alignment: .leading) + Slider( + value: Binding( + get: { Double(displayedPosition) }, + set: manager.updateSeekPosition + ), + in: 0...Double(max(timeline.durationMs, 1)), + onEditingChanged: { editing in + editing ? manager.beginSeeking() : manager.finishSeeking() + } + ) + .tint(playerVisualState(manager: manager).accentColor) + .disabled(!manager.connectionStore.presentation.isConnected || timeline.durationMs <= 0) + .accessibilityLabel("播放进度") + .accessibilityValue( + "\(format(displayedPosition, duration: timeline.durationMs)),共 \(format(timeline.durationMs, duration: timeline.durationMs))" + ) + Text(format(timeline.durationMs, duration: timeline.durationMs)) + .frame(width: 46, alignment: .trailing) } - return "art-\(bleManager.title)-\(bleManager.artist)-\(bleManager.album)-\(bleManager.artworkDisplayQuality.label)" + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white.opacity(0.72)) } - private var albumArtSize: CGFloat { - preferences.artworkDisplaySize.pointSize + private var displayedPosition: Int64 { + let timeline = manager.playbackStore.timeline + return timeline.isSeeking ? timeline.seekPositionMs : timeline.displayPositionMs } + private func format(_ milliseconds: Int64, duration: Int64) -> String { + guard duration > 0 else { return "00:00" } + let seconds = max(milliseconds, 0) / 1_000 + return String(format: "%02lld:%02lld", seconds / 60, seconds % 60) + } +} - private var volumeIcon: String { - if displayedVolume <= 0 { - return "speaker.slash.fill" - } - if displayedVolume < max(bleManager.volumeMax / 2, 1) { - return "speaker.wave.1.fill" +private struct PlaybackControlsStoreView: View { + let manager: BLETestManager + let mode: PlayerLayoutMode + + var body: some View { + HStack(spacing: mode.isCompact ? 32 : 58) { + transport("上一首", image: "backward.end.fill", action: manager.sendPrevious) + DarkDynamicPlayButton( + state: playerVisualState(manager: manager), + isEnabled: isConnected, + action: manager.sendPlayPause + ) + .scaleEffect(mode.isCompact ? 0.84 : 1) + transport("下一首", image: "forward.end.fill", action: manager.sendNext) } - return "speaker.wave.2.fill" + .frame(maxWidth: .infinity, minHeight: mode.isCompact ? 72 : 88) + .disabled(!isConnected) + .opacity(isConnected ? 1 : 0.48) } - private func playerControlButton( - title: String, - systemImage: String, - size: CGFloat, - fontSize: CGFloat, - action: @escaping () -> Void - ) -> some View { + private var isConnected: Bool { manager.connectionStore.presentation.isConnected } + private func transport(_ title: String, image: String, action: @escaping () -> Void) -> some View { Button(action: action) { - Image(systemName: systemImage) - .font(.system(size: fontSize, weight: .bold)) + Image(systemName: image) + .font(.system(size: mode.isCompact ? 26 : 30, weight: .bold)) .foregroundStyle(.white) - .frame(width: size, height: size) - .background(.white.opacity(0.10), in: Circle()) - .overlay { - Circle().stroke(.white.opacity(0.10), lineWidth: 1) - } + .frame( + width: mode.isCompact ? 52 : 62, + height: mode.isCompact ? 52 : 62 + ) + .contentShape(Circle()) } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.92)) + .buttonStyle(PressScaleButtonStyle(pressedScale: 0.90)) .accessibilityLabel(title) } +} - private func compactButton( - title: String, - systemImage: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Label(title, systemImage: systemImage) - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.96)) - .background(.white.opacity(0.10), in: Capsule()) - .overlay { - Capsule().stroke(.white.opacity(0.09), lineWidth: 1) +private struct VolumeControlStoreView: View { + let manager: BLETestManager + @State private var feedbackVisible = false + @State private var feedbackGeneration = 0 + + var body: some View { + let volume = manager.playbackStore.volume + HStack(spacing: 12) { + Image(systemName: volumeIcon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.92)) + .frame(width: 24) + .accessibilityHidden(true) + + GeometryReader { proxy in + ZStack(alignment: .leading) { + Slider( + value: Binding( + get: { Double(displayedVolume) }, + set: manager.updateVolumeSeekValue + ), + in: 0...Double(max(volume.maximum, 1)), + step: 1, + onEditingChanged: handleEditing + ) + .tint(playerVisualState(manager: manager).accentColor) + .disabled(!isAvailable) + .accessibilityLabel("音量") + .accessibilityValue( + volume.maximum > 0 ? "\(displayedVolume),最大 \(volume.maximum)" : "尚未同步" + ) + + if feedbackVisible, isAvailable { + Text("音量 \(displayedVolume)") + .font(.system(size: 11, weight: .semibold, design: .rounded).monospacedDigit()) + .foregroundStyle(.white.opacity(0.94)) + .padding(.horizontal, 8) + .frame(height: 24) + .background(.black.opacity(0.72), in: Capsule()) + .overlay { Capsule().strokeBorder(.white.opacity(0.10), lineWidth: 1) } + .fixedSize() + .position(x: feedbackXPosition(in: proxy.size.width), y: -7) + .transition(.opacity.combined(with: .scale(scale: 0.94))) + .allowsHitTesting(false) + } + } + } + .frame(height: 32) } + .padding(.horizontal, 14) + .frame(height: 44) + .background(.black.opacity(0.16), in: RoundedRectangle(cornerRadius: 20)) + .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.white.opacity(0.07)) } + .opacity(isAvailable ? 1 : 0.48) } - private func displayText(_ value: String, fallback: String) -> String { - value == "-" || value.isEmpty ? fallback : value + private var displayedVolume: Int { + let volume = manager.playbackStore.volume + return volume.isSeeking ? volume.seekValue : volume.current + } + private var isAvailable: Bool { + manager.connectionStore.presentation.isConnected && manager.playbackStore.volume.maximum > 0 + } + private var volumeIcon: String { + if displayedVolume <= 0 { return "speaker.slash.fill" } + return displayedVolume < max(manager.playbackStore.volume.maximum / 2, 1) + ? "speaker.wave.1.fill" + : "speaker.wave.2.fill" + } + private func handleEditing(_ editing: Bool) { + feedbackGeneration += 1 + let generation = feedbackGeneration + if editing { + withAnimation(.easeOut(duration: 0.12)) { feedbackVisible = true } + manager.beginVolumeSeeking() + } else { + manager.finishVolumeSeeking() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { + guard generation == feedbackGeneration else { return } + withAnimation(.easeInOut(duration: 0.16)) { feedbackVisible = false } + } + } } - private func format(milliseconds: Int64) -> String { - guard bleManager.durationMs > 0 else { return "00:00" } - let totalSeconds = max(milliseconds, 0) / 1_000 - return String(format: "%02lld:%02lld", totalSeconds / 60, totalSeconds % 60) + private func feedbackXPosition(in width: CGFloat) -> CGFloat { + let horizontalInset: CGFloat = 18 + let usableWidth = max(width - horizontalInset * 2, 0) + let progress = CGFloat( + CompactVolumePresentation.normalizedProgress( + current: displayedVolume, + maximum: manager.playbackStore.volume.maximum + ) + ) + return min(max(horizontalInset + usableWidth * progress, 30), max(width - 30, 30)) } } +private func playerVisualState(manager: BLETestManager) -> DarkPlaybackVisualState { + let connection = DarkControlConnectionState( + rawValue: manager.connectionStore.state.displayState + ) ?? .disconnected + return DarkControlPlaybackState( + isPlaying: manager.playbackStore.timeline.isPlaying, + isLoading: !manager.connectionStore.presentation.isConnected + ).visualState(connection: connection) +} + private struct PlayerBackgroundView: View { let image: UIImage? @@ -1231,36 +1094,42 @@ private struct DarkControlUIState { let playback: DarkPlaybackVisualState } -private enum DarkControlConnectionState: String { +enum DarkControlConnectionState: String { case connected case reconnecting case connecting case disconnected - var title: String { - switch self { + var compactTitle: String { + let key: String = switch self { case .connected: - return "已连接" + "Sony" case .connecting: - return "连接中" + "连接中" case .reconnecting: - return "重连中" + "重连中" case .disconnected: - return "未连接" + "连接" } + return AppLocalization.string(key) } - var detail: String { - switch self { + var showsProgressIndicator: Bool { + self == .connecting || self == .reconnecting + } + + var accessibilityLabel: String { + let key: String = switch self { case .connected: - return "设备连接正常" + "Sony 已连接" case .connecting: - return "正在连接 Sony" + "正在连接 Sony" case .reconnecting: - return "正在恢复连接" + "正在重新连接 Sony" case .disconnected: - return "请检查设备连接" + "Sony 未连接" } + return AppLocalization.string(key) } var color: Color { @@ -1270,23 +1139,17 @@ private enum DarkControlConnectionState: String { case .connecting: return .orange case .reconnecting: - return .purple + return .orange case .disconnected: - return .gray + return .orange } } +} - var icon: String { - switch self { - case .connected: - return "checkmark.circle" - case .connecting: - return "dot.radiowaves.left.and.right" - case .reconnecting: - return "arrow.clockwise.circle" - case .disconnected: - return "link.slash" - } +struct CompactVolumePresentation { + static func normalizedProgress(current: Int, maximum: Int) -> Double { + guard maximum > 0 else { return 0 } + return min(max(Double(current) / Double(maximum), 0), 1) } } @@ -1298,47 +1161,49 @@ private enum DarkPlaybackVisualState: Equatable { case stopped var title: String { - switch self { + let key: String = switch self { case .playing: - return "正在播放" + "正在播放" case .paused: - return "已暂停" + "已暂停" case .loading: - return "加载中" + "加载中" case .reconnecting: - return "重连中" + "重连中" case .stopped: - return "已停止" + "已停止" } + return AppLocalization.string(key) } var detail: String { - switch self { + let key: String = switch self { case .playing: - return "正在播放音乐" + "正在播放音乐" case .paused: - return "音乐暂停状态" + "音乐暂停状态" case .loading: - return "内容加载中" + "内容加载中" case .reconnecting: - return "设备重连中" + "设备重连中" case .stopped: - return "音乐已停止" + "音乐已停止" } + return AppLocalization.string(key) } var accentColor: Color { switch self { case .playing: - return .green + return PlayerDesignTokens.stableAccent case .paused: return .blue case .loading: - return .orange + return PlayerDesignTokens.warning case .reconnecting: - return .purple + return PlayerDesignTokens.warning case .stopped: - return .gray + return PlayerDesignTokens.disconnected } } @@ -1396,6 +1261,7 @@ private struct DarkDynamicPlayButton: View { let action: () -> Void @State private var isPulsing = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button(action: action) { @@ -1423,7 +1289,7 @@ private struct DarkDynamicPlayButton: View { color: state == .playing ? state.accentColor.opacity(isPulsing ? 0.28 : 0.12) : .clear, radius: state == .playing ? (isPulsing ? 18 : 8) : 0 ) - .scaleEffect(state == .playing && isPulsing ? 1.018 : 1.0) + .scaleEffect(state == .playing && isPulsing && !reduceMotion ? 1.018 : 1.0) if state == .loading || state == .reconnecting { ProgressView() @@ -1442,15 +1308,15 @@ private struct DarkDynamicPlayButton: View { .disabled(!isEnabled || state == .loading || state == .reconnecting) .opacity(isEnabled ? 1 : 0.55) .onAppear { - isPulsing = true + isPulsing = !reduceMotion } .animation( - state == .playing + state == .playing && !reduceMotion ? .easeInOut(duration: 1.15).repeatForever(autoreverses: true) - : .default, + : nil, value: isPulsing ) - .animation(.spring(response: 0.26, dampingFraction: 0.72), value: state) + .animation(reduceMotion ? nil : .spring(response: 0.26, dampingFraction: 0.72), value: state) .accessibilityLabel("播放 / 暂停") } } @@ -1497,6 +1363,8 @@ private struct DarkLyricRhythmLine: View { let positionMs: Int64 @StateObject private var spectrumEngine = NaturalPseudoSpectrumEngine() + @ObservedObject private var preferences = PreferencesStore.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var opacity: Double { switch state { @@ -1512,22 +1380,56 @@ private struct DarkLyricRhythmLine: View { } var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - let time = timeline.date.timeIntervalSinceReferenceDate - GeometryReader { proxy in - let levels = spectrumEngine.levels( - time: time, - width: proxy.size.width, - state: state, - trackSeed: trackSeed, - lyricProgress: lyricProgress, - wordSignature: wordSignature, - positionMs: positionMs - ) - spectrumCanvas(levels: levels) + Group { + if let frameInterval { + TimelineView(.animation(minimumInterval: frameInterval)) { timeline in + spectrumFrame( + time: timeline.date.timeIntervalSinceReferenceDate + ) + } + } else { + spectrumFrame(time: 0) } } .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + + private var frameInterval: TimeInterval? { + guard !reduceMotion else { + return nil + } + let performanceMode = preferences.playbackPerformanceMode + if performanceMode == .powerSaving { + return nil + } + if performanceMode == .automatic, + ProcessInfo.processInfo.isLowPowerModeEnabled { + return nil + } + switch state { + case .playing: + return performanceMode == .smooth ? 1.0 / 20.0 : 1.0 / 15.0 + case .loading, .reconnecting: + return performanceMode == .smooth ? 1.0 / 15.0 : 1.0 / 12.0 + case .paused, .stopped: + return nil + } + } + + private func spectrumFrame(time: TimeInterval) -> some View { + GeometryReader { proxy in + let levels = spectrumEngine.levels( + time: time, + width: proxy.size.width, + state: state, + trackSeed: trackSeed, + lyricProgress: lyricProgress, + wordSignature: wordSignature, + positionMs: positionMs + ) + spectrumCanvas(levels: levels) + } } private func spectrumCanvas(levels: [Double]) -> some View { @@ -1926,46 +1828,284 @@ struct KaraokeLyricText: View { let progress: Double var words: [LyricWord] = [] var positionMs: Int64? = nil + var isPlaying = false let highlightColor: Color let normalColor: Color let font: Font var lineLimit: Int? = nil var alignment: TextAlignment = .leading - private var highlightCount: Int { - let characters = Array(text) - let count = characters.count - guard count > 0 else { return 0 } - if let positionMs, !words.isEmpty { - let wordCharacterCount = words.reduce(0) { partial, word in - guard positionMs >= word.startMs else { return partial } - return partial + Array(word.text).count - } - return min(max(wordCharacterCount, 0), count) - } - let boundedProgress = min(max(progress, 0), 1) - let rawCount = Int((Double(count) * boundedProgress).rounded(.down)) - return min(max(rawCount, 0), count) + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ObservedObject private var preferences = PreferencesStore.shared + @State private var renderedProgress = 0.0 + @State private var previousTargetProgress: Double? + + private var rawHighlightResult: KaraokeHighlightResult { + KaraokeHighlightResolver.resolve( + text: text, + words: words, + positionMs: positionMs, + fallbackProgress: progress + ) } - private var splitText: (String, String) { - guard highlightCount > 0 else { return ("", text) } - let characters = Array(text) - guard highlightCount < characters.count else { return (text, "") } - return ( - String(characters.prefix(highlightCount)), - String(characters.dropFirst(highlightCount)) + private var targetHighlightResult: KaraokeHighlightResult { + KaraokeHighlightResolver.resolve( + text: text, + words: words, + positionMs: positionMs, + fallbackProgress: progress, + lookAheadMs: isPlaying && !reduceMotion ? interpolationLookAheadMs : 0 ) } + private var targetProgress: Double { + targetHighlightResult.normalizedProgress + } + + private var interpolationDuration: TimeInterval { + KaraokeProgressAnimationPolicy.duration( + for: preferences.playbackPerformanceMode, + isLowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled + ) + } + + private var interpolationLookAheadMs: Int64 { + Int64((interpolationDuration * 1_000).rounded()) + } + var body: some View { - let parts = splitText - (Text(parts.0).foregroundColor(highlightColor) + - Text(parts.1).foregroundColor(normalColor)) + ZStack { + lyricText(color: normalColor) + lyricText(color: highlightColor) + .textRenderer(KaraokeProgressTextRenderer(progress: renderedProgress)) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .onAppear(perform: resetProgress) + .onChange(of: text) { _, _ in resetProgress() } + .onChange(of: targetProgress) { oldValue, newValue in + updateRenderedProgress(from: oldValue, to: newValue) + } + .onChange(of: reduceMotion) { _, _ in resetProgress() } + .onChange(of: isPlaying) { _, playing in + if !playing { resetProgress() } + } + } + + private func lyricText(color: Color) -> some View { + Text(text) + .foregroundStyle(color) .font(font) .multilineTextAlignment(alignment) .lineLimit(lineLimit) - .animation(.linear(duration: 0.25), value: highlightCount) + } + + private func resetProgress() { + let progress = rawHighlightResult.normalizedProgress + previousTargetProgress = progress + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + renderedProgress = progress + } + } + + private func updateRenderedProgress(from oldValue: Double, to newValue: Double) { + let previous = previousTargetProgress ?? oldValue + previousTargetProgress = newValue + guard KaraokeProgressAnimationPolicy.shouldAnimate( + from: previous, + to: newValue, + isPlaying: isPlaying, + reduceMotion: reduceMotion + ) else { + resetProgress() + return + } + withAnimation(.linear(duration: interpolationDuration)) { + renderedProgress = newValue + } + } +} + +struct KaraokeHighlightResult: Equatable { + let highlightedText: String + let remainingText: String + let highlightedCharacterCount: Int + let highlightedCharacterProgress: Double + let totalCharacterCount: Int + + var normalizedProgress: Double { + guard totalCharacterCount > 0 else { return 0 } + return min(max(highlightedCharacterProgress / Double(totalCharacterCount), 0), 1) + } +} + +enum KaraokeHighlightResolver { + static func resolve( + text: String, + words: [LyricWord], + positionMs: Int64?, + fallbackProgress: Double, + lookAheadMs: Int64 = 0 + ) -> KaraokeHighlightResult { + let characters = Array(text) + let count = characters.count + guard count > 0 else { + return KaraokeHighlightResult( + highlightedText: "", + remainingText: "", + highlightedCharacterCount: 0, + highlightedCharacterProgress: 0, + totalCharacterCount: 0 + ) + } + let highlightProgress: Double + if let positionMs, !words.isEmpty { + highlightProgress = wordHighlightProgress( + textCharacters: characters, + words: words, + positionMs: positionMs + max(lookAheadMs, 0) + ) + } else { + let boundedProgress = min(max(fallbackProgress, 0), 1) + highlightProgress = Double(count) * boundedProgress + } + let boundedHighlightProgress = min(max(highlightProgress, 0), Double(count)) + let highlightCount = Int(boundedHighlightProgress.rounded(.down)) + return KaraokeHighlightResult( + highlightedText: String(characters.prefix(highlightCount)), + remainingText: String(characters.dropFirst(highlightCount)), + highlightedCharacterCount: highlightCount, + highlightedCharacterProgress: boundedHighlightProgress, + totalCharacterCount: count + ) + } + + private static func wordHighlightProgress( + textCharacters: [Character], + words: [LyricWord], + positionMs: Int64 + ) -> Double { + var searchStart = 0 + var completedEnd = 0 + for word in words { + let wordCharacters = Array(word.text) + guard !wordCharacters.isEmpty else { continue } + let wordStart = find( + wordCharacters, + in: textCharacters, + startingAt: searchStart + ) ?? min(searchStart, textCharacters.count) + let wordEnd = min(wordStart + wordCharacters.count, textCharacters.count) + guard positionMs >= word.startMs else { + return Double(min(completedEnd, textCharacters.count)) + } + let durationMs = max(word.durationMs, 1) + let elapsedMs = positionMs - word.startMs + if elapsedMs < durationMs { + let fraction = min(max(Double(elapsedMs) / Double(durationMs), 0), 1) + let partialProgress = Double(wordStart) + + Double(max(wordEnd - wordStart, 0)) * fraction + return min( + max(partialProgress, Double(completedEnd)), + Double(textCharacters.count) + ) + } + completedEnd = max(completedEnd, wordEnd) + searchStart = wordEnd + } + return Double(textCharacters.count) + } + + private static func find( + _ needle: [Character], + in haystack: [Character], + startingAt start: Int + ) -> Int? { + guard !needle.isEmpty, + start < haystack.count, + needle.count <= haystack.count else { return nil } + let lastStart = haystack.count - needle.count + guard start <= lastStart else { return nil } + for index in start...lastStart { + if haystack[index..<(index + needle.count)].elementsEqual(needle) { + return index + } + } + return nil + } +} + +struct KaraokeProgressTextRenderer: TextRenderer { + var progress: Double + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + func draw(layout: Text.Layout, in context: inout GraphicsContext) { + let boundedProgress = min(max(progress, 0), 1) + let totalWidth = layout.reduce(CGFloat.zero) { partialResult, line in + partialResult + max(line.typographicBounds.width, 0) + } + guard totalWidth > 0, boundedProgress > 0 else { return } + + var remainingWidth = totalWidth * boundedProgress + for line in layout { + let bounds = line.typographicBounds + let lineWidth = max(bounds.width, 0) + guard lineWidth > 0, remainingWidth > 0 else { continue } + + if remainingWidth >= lineWidth { + for run in line { context.draw(run) } + remainingWidth -= lineWidth + continue + } + + var clippedContext = context + let isRightToLeft = line.first?.layoutDirection == .rightToLeft + let revealWidth = min(max(remainingWidth, 0), lineWidth) + let rect = bounds.rect + let clipRect = CGRect( + x: isRightToLeft ? rect.maxX - revealWidth : rect.minX, + y: rect.minY - 1, + width: revealWidth, + height: rect.height + 2 + ) + clippedContext.clip(to: Path(clipRect)) + for run in line { clippedContext.draw(run) } + remainingWidth = 0 + } + } +} + +enum KaraokeProgressAnimationPolicy { + static func duration( + for mode: PlaybackPerformanceMode, + isLowPowerModeEnabled: Bool + ) -> TimeInterval { + switch mode { + case .smooth: + return 0.10 + case .automatic: + return isLowPowerModeEnabled ? 0.50 : 0.25 + case .powerSaving: + return 0.50 + } + } + + static func shouldAnimate( + from oldProgress: Double, + to newProgress: Double, + isPlaying: Bool, + reduceMotion: Bool + ) -> Bool { + guard isPlaying, !reduceMotion else { return false } + let delta = newProgress - oldProgress + return delta >= 0 && delta <= 0.30 } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift b/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift index 6d87dc3..07f2861 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift @@ -39,6 +39,12 @@ struct DebugToolsView: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .onAppear { + bleManager.setUILogStreamingEnabled(true) + } + .onDisappear { + bleManager.setUILogStreamingEnabled(false) + } .sheet(isPresented: $showLyricDiagnostic) { LyricDiagnosticView( bleManager: bleManager, @@ -151,7 +157,7 @@ struct DebugToolsView: View { private var autoReconnectSection: some View { DebugCard { VStack(alignment: .leading, spacing: 12) { - Label("Auto Reconnect", systemImage: "arrow.triangle.2.circlepath") + Label("自动重连", systemImage: "arrow.triangle.2.circlepath") .font(.headline) Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { @@ -224,7 +230,7 @@ struct DebugToolsView: View { let status = bleManager.artworkEnhancementStatus return DebugCard { VStack(alignment: .leading, spacing: 12) { - Label("Artwork Enhancement", systemImage: "photo.on.rectangle.angled") + Label("封面增强", systemImage: "photo.on.rectangle.angled") .font(.headline) Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { @@ -241,7 +247,7 @@ struct DebugToolsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Target Size") + Text("目标尺寸") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) LazyVGrid( @@ -267,7 +273,7 @@ struct DebugToolsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Sharpness") + Text("锐度") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) LazyVGrid( @@ -334,7 +340,7 @@ struct DebugToolsView: View { let status = bleManager.liveActivityControlStatus return DebugCard { VStack(alignment: .leading, spacing: 12) { - Label("Live Activity Control", systemImage: "playpause.circle") + Label("实时活动控制", systemImage: "playpause.circle") .font(.headline) Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { @@ -356,7 +362,7 @@ struct DebugToolsView: View { DebugCard { VStack(alignment: .leading, spacing: 12) { HStack { - Label("Karaoke Offset", systemImage: "textformat") + Label("歌词偏移", systemImage: "textformat") .font(.headline) Spacer() Text(offsetLabel(bleManager.karaokeOffsetMs)) @@ -499,18 +505,18 @@ struct DebugToolsView: View { if showIOSLogs { HStack(spacing: 10) { Button(action: bleManager.copyIOSLogs) { - Label("Copy iOS Logs", systemImage: "doc.on.doc") + Label("复制 iOS 日志", systemImage: "doc.on.doc") } .buttonStyle(.bordered) Button(role: .destructive, action: bleManager.clearIOSLogs) { - Label("Clear", systemImage: "trash") + Label("清空", systemImage: "trash") } .buttonStyle(.bordered) if AppLogStore.shared.currentLogFileExists() { ShareLink(item: AppLogStore.shared.currentLogURL) { - Label("Share", systemImage: "square.and.arrow.up") + Label("分享", systemImage: "square.and.arrow.up") } .buttonStyle(.bordered) } diff --git a/IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift new file mode 100644 index 0000000..d05a6f7 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift @@ -0,0 +1,255 @@ +import CryptoKit +import Foundation + +struct FullLyricsCacheEntry: Codable, Equatable { + static let version = 1 + static let maximumAge: TimeInterval = 30 * 24 * 60 * 60 + + struct Word: Codable, Equatable { + let id: Int + let startMs: Int64 + let durationMs: Int64 + let text: String + } + + struct Line: Codable, Equatable { + let index: Int + let timeMs: Int64 + let durationMs: Int64 + let text: String + let translation: String? + let romanization: String? + let words: [Word] + } + + let version: Int + let trackId: String + let title: String + let artist: String + let album: String + let lines: [Line] + let savedAt: Date + + var isValid: Bool { + version == Self.version && + !trackId.isEmpty && + !normalized(title).isEmpty && + !lines.isEmpty && + lines.count <= 2_000 + } + + func matches(title: String, artist: String) -> Bool { + normalized(self.title) == normalized(title) && + normalized(self.artist) == normalized(artist) + } + + private func normalized(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +final class FullLyricsCacheStore { + static let shared = FullLyricsCacheStore() + + private final class EntryBox { + let entry: FullLyricsCacheEntry + + init(_ entry: FullLyricsCacheEntry) { + self.entry = entry + } + } + + private let ioQueue = DispatchQueue( + label: "com.musicblecontroller.full-lyrics-cache", + qos: .utility + ) + private let directoryURL: URL + private let maximumEntryCount: Int + private let maximumDiskBytes: Int64 + private let memoryCache = NSCache() + + init( + directoryURL: URL? = nil, + maximumEntryCount: Int = 80, + maximumDiskBytes: Int64 = 8 * 1024 * 1024 + ) { + if let directoryURL { + self.directoryURL = directoryURL + } else { + let baseURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + self.directoryURL = baseURL + .appendingPathComponent("MusicBleController", isDirectory: true) + .appendingPathComponent("FullLyricsCache-v1", isDirectory: true) + } + self.maximumEntryCount = max(maximumEntryCount, 1) + self.maximumDiskBytes = max(maximumDiskBytes, 64 * 1024) + memoryCache.countLimit = 16 + memoryCache.totalCostLimit = 2 * 1024 * 1024 + } + + func load( + trackId: String, + title: String, + artist: String, + now: Date = Date(), + completion: @escaping (FullLyricsCacheEntry?) -> Void + ) { + guard !trackId.isEmpty else { + DispatchQueue.main.async { completion(nil) } + return + } + let key = trackId as NSString + if let cached = memoryCache.object(forKey: key)?.entry, + isUsable(cached, title: title, artist: artist, now: now) { + DispatchQueue.main.async { completion(cached) } + return + } + + ioQueue.async { [weak self] in + guard let self else { return } + let fileURL = self.fileURL(for: trackId) + let entry = Self.readEntry( + fileURL: fileURL, + expectedTrackId: trackId, + now: now + ) + let usable = entry.flatMap { + self.isUsable($0, title: title, artist: artist, now: now) ? $0 : nil + } + if let usable { + self.memoryCache.setObject( + EntryBox(usable), + forKey: key, + cost: Self.estimatedCost(usable) + ) + try? FileManager.default.setAttributes( + [.modificationDate: now], + ofItemAtPath: fileURL.path + ) + } else { + try? FileManager.default.removeItem(at: fileURL) + } + DispatchQueue.main.async { + completion(usable) + } + } + } + + func save(_ entry: FullLyricsCacheEntry) { + guard entry.isValid else { return } + memoryCache.setObject( + EntryBox(entry), + forKey: entry.trackId as NSString, + cost: Self.estimatedCost(entry) + ) + ioQueue.async { [weak self] in + guard let self else { return } + do { + try FileManager.default.createDirectory( + at: self.directoryURL, + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(entry) + try data.write(to: self.fileURL(for: entry.trackId), options: .atomic) + self.prune(now: Date()) + } catch { + // A cache failure must never block live lyrics. A later stable + // full-lyrics result will try again. + } + } + } + + func remove(trackId: String) { + guard !trackId.isEmpty else { return } + memoryCache.removeObject(forKey: trackId as NSString) + ioQueue.async { [weak self] in + guard let self else { return } + try? FileManager.default.removeItem(at: self.fileURL(for: trackId)) + } + } + + static func readEntry( + fileURL: URL, + expectedTrackId: String, + now: Date = Date() + ) -> FullLyricsCacheEntry? { + guard let data = try? Data(contentsOf: fileURL), + let entry = try? JSONDecoder().decode(FullLyricsCacheEntry.self, from: data), + entry.isValid, + entry.trackId == expectedTrackId, + now.timeIntervalSince(entry.savedAt) >= 0, + now.timeIntervalSince(entry.savedAt) <= FullLyricsCacheEntry.maximumAge else { + return nil + } + return entry + } + + private func isUsable( + _ entry: FullLyricsCacheEntry, + title: String, + artist: String, + now: Date + ) -> Bool { + entry.isValid && + now.timeIntervalSince(entry.savedAt) >= 0 && + now.timeIntervalSince(entry.savedAt) <= FullLyricsCacheEntry.maximumAge && + entry.matches(title: title, artist: artist) + } + + private func fileURL(for trackId: String) -> URL { + let digest = SHA256.hash(data: Data(trackId.utf8)) + let filename = digest.map { String(format: "%02x", $0) }.joined() + ".json" + return directoryURL.appendingPathComponent(filename) + } + + private func prune(now: Date) { + let keys: Set = [ + .isRegularFileKey, + .contentModificationDateKey, + .fileSizeKey + ] + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) else { + return + } + var records: [(url: URL, date: Date, size: Int64)] = [] + for url in urls where url.pathExtension == "json" { + guard let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true else { + continue + } + let date = values.contentModificationDate ?? .distantPast + if now.timeIntervalSince(date) > FullLyricsCacheEntry.maximumAge { + try? FileManager.default.removeItem(at: url) + continue + } + records.append((url, date, Int64(values.fileSize ?? 0))) + } + records.sort { $0.date > $1.date } + var retainedBytes: Int64 = 0 + for (index, record) in records.enumerated() { + let exceedsCount = index >= maximumEntryCount + let exceedsBytes = retainedBytes + record.size > maximumDiskBytes + if exceedsCount || exceedsBytes { + try? FileManager.default.removeItem(at: record.url) + } else { + retainedBytes += record.size + } + } + } + + private static func estimatedCost(_ entry: FullLyricsCacheEntry) -> Int { + entry.lines.reduce(0) { result, line in + result + line.text.utf8.count + + (line.translation?.utf8.count ?? 0) + + (line.romanization?.utf8.count ?? 0) + + line.words.reduce(0) { $0 + $1.text.utf8.count + 32 } + } + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift index 6669c4f..7fcf329 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift @@ -6,8 +6,11 @@ struct FullLyricsView: View { let artist: String let albumArtImage: UIImage? let lyrics: [LyricLine] + let lyricsIdentity: String let currentIndex: Int let positionMs: Int64 + let translationState: LyricSecondaryLoadState + let romanizationState: LyricSecondaryLoadState let isPlaying: Bool let isConnected: Bool let onDismiss: () -> Void @@ -18,12 +21,13 @@ struct FullLyricsView: View { var showDiagnosticButton = true let onShowDiagnostic: () -> Void - @State private var isBrowsingLyrics = false - @State private var selectedLyricIndex: Int? + @State private var followState = FullLyricsFollowState() @State private var lastAutoScrolledIndex: Int? @State private var isProgrammaticScroll = false - @State private var browseResetWorkItem: DispatchWorkItem? @ObservedObject private var preferences = PreferencesStore.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var isBrowsingLyrics: Bool { followState.showsReturnToCurrent } var body: some View { ZStack { @@ -68,14 +72,54 @@ struct FullLyricsView: View { guard !lyrics.isEmpty else { return nil } switch displayMode { case .originalTranslation: - return hasAnyTranslation ? nil : "该歌曲暂无翻译" + return secondaryMessage( + state: translationState, + hasContent: hasAnyTranslation, + title: "翻译" + ) case .originalRomanization: - return hasAnyRomanization ? nil : "该歌曲暂无罗马音" - case .original, .originalTranslationRomanization: + return secondaryMessage( + state: romanizationState, + hasContent: hasAnyRomanization, + title: "罗马音" + ) + case .originalTranslationRomanization: + return [ + secondaryMessage( + state: translationState, + hasContent: hasAnyTranslation, + title: "翻译" + ), + secondaryMessage( + state: romanizationState, + hasContent: hasAnyRomanization, + title: "罗马音" + ) + ] + .compactMap { $0 } + .joined(separator: " · ") + .nilIfEmpty + case .original: return nil } } + private func secondaryMessage( + state: LyricSecondaryLoadState, + hasContent: Bool, + title: String + ) -> String? { + guard !hasContent else { return nil } + switch state { + case .idle, .loading: + return "正在获取\(title)…" + case .ready, .unavailable: + return "该歌曲暂无\(title)" + case .failed: + return "\(title)获取失败,可稍后切换模式重试" + } + } + private var hasAnyTranslation: Bool { lyrics.contains { sanitizedSecondaryText($0.translation) != nil } } @@ -133,19 +177,9 @@ struct FullLyricsView: View { if lyrics.isEmpty { emptyLyricsView } else { - ForEach(Array(lyrics.enumerated()), id: \.element.id) { index, line in - lyricRow(index: index, line: line) + ForEach(lyrics) { line in + lyricRow(index: line.index, line: line) .id(line.id) - .background( - GeometryReader { rowProxy in - Color.clear.preference( - key: LyricLineCenterPreferenceKey.self, - value: [ - index: rowProxy.frame(in: .named("lyricsScroll")).midY - ] - ) - } - ) } } Color.clear @@ -153,22 +187,12 @@ struct FullLyricsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .coordinateSpace(name: "lyricsScroll") .simultaneousGesture( DragGesture(minimumDistance: 8) .onChanged { _ in enterBrowseMode() } - .onEnded { _ in - scheduleFollowModeRestore(proxy) - } ) - .onPreferenceChange(LyricLineCenterPreferenceKey.self) { centers in - updateSelectedLine( - centers: centers, - viewportCenterY: viewport.size.height / 2 - ) - } if isBrowsingLyrics, !lyrics.isEmpty { Button { @@ -195,9 +219,9 @@ struct FullLyricsView: View { guard !isBrowsingLyrics else { return } scrollToCurrent(proxy) } - .onChange(of: lyrics) { _, _ in + .onChange(of: lyricsIdentity) { _, _ in resetBrowseState() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.async { scrollToCurrent(proxy) } } @@ -232,44 +256,40 @@ struct FullLyricsView: View { private func lyricRow(index: Int, line: LyricLine) -> some View { let isCurrent = index == currentIndex - let isSelected = isBrowsingLyrics && index == selectedLyricIndex - return Group { + return Button { + guard isConnected else { return } + seekToLine(line) + } label: { HStack(alignment: .center, spacing: 14) { lyricText( index: index, line: line, - isCurrent: isCurrent, - isSelected: isSelected + isCurrent: isCurrent + ) + .frame(maxWidth: .infinity, alignment: .leading) + .animation( + reduceMotion ? nil : .easeInOut(duration: 0.18), + value: currentIndex ) - .frame(maxWidth: .infinity, alignment: .leading) - .animation(.easeInOut(duration: 0.18), value: currentIndex) - .animation(.easeInOut(duration: 0.18), value: selectedLyricIndex) - if isSelected { - seekTimeCapsule(line: line) - .transition(.opacity.combined(with: .scale(scale: 0.96))) + if isBrowsingLyrics { + Image(systemName: "play.circle.fill") + .font(.system(size: 25, weight: .semibold)) + .foregroundStyle(.white.opacity(isConnected ? 0.82 : 0.28)) + .transition(.opacity) } } .contentShape(Rectangle()) } - .onTapGesture { - guard isBrowsingLyrics, isConnected else { - selectedLyricIndex = index - enterBrowseMode() - return - } - seekToLine(line) - } - .opacity((!isConnected && isSelected) ? 0.58 : 1) + .buttonStyle(.plain) + .disabled(!isConnected) .accessibilityLabel("歌词 \(line.text)") + .accessibilityHint(isConnected ? "跳转到这句" : "Sony 未连接") } - private func lyricColor(isCurrent: Bool, isSelected: Bool) -> Color { + private func lyricColor(isCurrent: Bool) -> Color { if isCurrent { - return Color.green.opacity(0.98) - } - if isSelected { - return .white.opacity(0.92) + return PlayerDesignTokens.stableAccent } return .white.opacity(0.42) } @@ -278,22 +298,21 @@ struct FullLyricsView: View { private func lyricText( index: Int, line: LyricLine, - isCurrent: Bool, - isSelected: Bool + isCurrent: Bool ) -> some View { if isCurrent { lyricStack( line: line, - isCurrent: true, - isSelected: isSelected + isCurrent: true ) { KaraokeLyricText( text: line.text, progress: lineProgress(index: index), words: line.words, positionMs: positionMs, - highlightColor: Color.green.opacity(0.98), - normalColor: Color.white.opacity(isSelected ? 0.58 : 0.36), + isPlaying: isPlaying, + highlightColor: PlayerDesignTokens.stableAccent, + normalColor: Color.white.opacity(0.36), font: .system(size: 28, weight: .bold, design: .rounded), lineLimit: 3, alignment: .leading @@ -302,18 +321,17 @@ struct FullLyricsView: View { } else { lyricStack( line: line, - isCurrent: false, - isSelected: isSelected + isCurrent: false ) { Text(line.text) .font( .system( - size: isSelected ? 23 : 21, - weight: isSelected ? .semibold : .medium, + size: 21, + weight: .medium, design: .rounded ) ) - .foregroundStyle(lyricColor(isCurrent: false, isSelected: isSelected)) + .foregroundStyle(lyricColor(isCurrent: false)) .multilineTextAlignment(.leading) .lineLimit(3) } @@ -323,7 +341,6 @@ struct FullLyricsView: View { private func lyricStack( line: LyricLine, isCurrent: Bool, - isSelected: Bool, @ViewBuilder original: () -> Original ) -> some View { VStack(alignment: .leading, spacing: isCurrent ? 5 : 4) { @@ -332,16 +349,14 @@ struct FullLyricsView: View { let translation = sanitizedSecondaryText(line.translation) { auxiliaryLyricText( translation, - isCurrent: isCurrent, - isSelected: isSelected + isCurrent: isCurrent ) } if displayMode.showsRomanization, let romanization = sanitizedSecondaryText(line.romanization) { auxiliaryLyricText( romanization, - isCurrent: isCurrent, - isSelected: isSelected + isCurrent: isCurrent ) } } @@ -349,21 +364,20 @@ struct FullLyricsView: View { private func auxiliaryLyricText( _ text: String, - isCurrent: Bool, - isSelected: Bool + isCurrent: Bool ) -> some View { Text(text) .font( .system( size: isCurrent ? 17 : 15, - weight: isSelected ? .semibold : .medium, + weight: .medium, design: .rounded ) ) .foregroundStyle( isCurrent ? Color.white.opacity(0.70) - : Color.white.opacity(isSelected ? 0.62 : 0.34) + : Color.white.opacity(0.34) ) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -377,29 +391,6 @@ struct FullLyricsView: View { ) } - private func seekTimeCapsule(line: LyricLine) -> some View { - Button { - guard isConnected else { return } - seekToLine(line) - } label: { - HStack(spacing: 4) { - Image(systemName: "play.fill") - .font(.system(size: 9, weight: .bold)) - Text(formatTime(line.timeMs)) - .font(.caption.monospacedDigit().weight(.semibold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(.white.opacity(0.14), in: Capsule()) - .overlay { - Capsule().stroke(.white.opacity(0.12), lineWidth: 1) - } - } - .buttonStyle(FullLyricsPressStyle(pressedScale: 0.94)) - .disabled(!isConnected) - } - private var controls: some View { HStack(spacing: 32) { controlButton(systemImage: "backward.fill", size: 52, action: onPrevious) @@ -412,10 +403,12 @@ struct FullLyricsView: View { .shadow(color: .black.opacity(0.18), radius: 12, y: 7) } .buttonStyle(FullLyricsPressStyle(pressedScale: 0.92)) - .accessibilityLabel("播放 / 暂停") + .accessibilityLabel(isPlaying ? "暂停" : "播放") controlButton(systemImage: "forward.fill", size: 52, action: onNext) } .padding(.top, 4) + .disabled(!isConnected) + .opacity(isConnected ? 1 : 0.42) } private func controlButton( @@ -441,8 +434,12 @@ struct FullLyricsView: View { guard currentIndex != lastAutoScrolledIndex || !isProgrammaticScroll else { return } lastAutoScrolledIndex = currentIndex isProgrammaticScroll = true - withAnimation(.easeInOut(duration: 0.28)) { + if reduceMotion { proxy.scrollTo(lyrics[currentIndex].id, anchor: .center) + } else { + withAnimation(.easeInOut(duration: 0.28)) { + proxy.scrollTo(lyrics[currentIndex].id, anchor: .center) + } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { isProgrammaticScroll = false @@ -451,65 +448,30 @@ struct FullLyricsView: View { private func enterBrowseMode() { guard !isProgrammaticScroll else { return } - isBrowsingLyrics = true - browseResetWorkItem?.cancel() - } - - private func scheduleFollowModeRestore(_ proxy: ScrollViewProxy) { - browseResetWorkItem?.cancel() - let workItem = DispatchWorkItem { - restoreFollowMode(proxy) - } - browseResetWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 4, execute: workItem) + followState.userDidBrowse() } private func restoreFollowMode(_ proxy: ScrollViewProxy) { - browseResetWorkItem?.cancel() - isBrowsingLyrics = false - selectedLyricIndex = nil + followState.returnToCurrent() scrollToCurrent(proxy) } private func resetBrowseState() { - browseResetWorkItem?.cancel() - isBrowsingLyrics = false - selectedLyricIndex = nil + followState.trackDidChange() lastAutoScrolledIndex = nil isProgrammaticScroll = false } - private func updateSelectedLine( - centers: [Int: CGFloat], - viewportCenterY: CGFloat - ) { - guard isBrowsingLyrics, !centers.isEmpty else { return } - selectedLyricIndex = centers.min { - abs($0.value - viewportCenterY) < abs($1.value - viewportCenterY) - }?.key - } - private func seekToLine(_ line: LyricLine) { onSeekToLine(line.timeMs) UIImpactFeedbackGenerator(style: .light).impactOccurred() - isBrowsingLyrics = false - selectedLyricIndex = nil - } - - private func formatTime(_ milliseconds: Int64) -> String { - let totalSeconds = max(milliseconds, 0) / 1_000 - return String(format: "%02lld:%02lld", totalSeconds / 60, totalSeconds % 60) + followState.returnToCurrent() } } -private struct LyricLineCenterPreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGFloat] = [:] - - static func reduce( - value: inout [Int: CGFloat], - nextValue: () -> [Int: CGFloat] - ) { - value.merge(nextValue(), uniquingKeysWith: { _, new in new }) +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/IOSBleFeasibilityApp.swift b/IOSBleFeasibility/IOSBleFeasibility/IOSBleFeasibilityApp.swift index bcde5fe..7cfd26d 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/IOSBleFeasibilityApp.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/IOSBleFeasibilityApp.swift @@ -2,9 +2,16 @@ import SwiftUI @main struct IOSBleFeasibilityApp: App { + @ObservedObject private var preferences = PreferencesStore.shared + + init() { + MetricDiagnosticsSubscriber.shared.start() + } + var body: some Scene { WindowGroup { ContentView() + .environment(\.locale, preferences.appLanguage.locale) } } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/Info.plist b/IOSBleFeasibility/IOSBleFeasibility/Info.plist index eec93bb..0cbc14a 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/Info.plist +++ b/IOSBleFeasibility/IOSBleFeasibility/Info.plist @@ -23,9 +23,9 @@ LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription - Used to test BLE connection with Sony PlayerAgent. + Bluetooth connects to your Sony player to control playback and sync lyrics, artwork, and Live Activities. NSBluetoothPeripheralUsageDescription - Used to test BLE connection with Sony PlayerAgent. + Bluetooth connects to your Sony player to control playback and sync media information. NSSupportsLiveActivities UIBackgroundModes diff --git a/IOSBleFeasibility/IOSBleFeasibility/InfoPlist.xcstrings b/IOSBleFeasibility/IOSBleFeasibility/InfoPlist.xcstrings new file mode 100644 index 0000000..d770afd --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/InfoPlist.xcstrings @@ -0,0 +1,27 @@ +{ + "sourceLanguage" : "zh-Hans", + "strings" : { + "CFBundleDisplayName" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sony Music" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Sony 音乐" } } + } + }, + "NSBluetoothAlwaysUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth connects to your Sony player to control playback and sync lyrics, artwork, and Live Activities." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "蓝牙用于连接 Sony 播放端,以控制播放并同步歌词、封面和实时活动。" } } + } + }, + "NSBluetoothPeripheralUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth connects to your Sony player to control playback and sync media information." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "蓝牙用于连接 Sony 播放端,以控制播放并同步媒体信息。" } } + } + } + }, + "version" : "1.0" +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/LastNowPlayingSnapshotStore.swift b/IOSBleFeasibility/IOSBleFeasibility/LastNowPlayingSnapshotStore.swift new file mode 100644 index 0000000..99a052f --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/LastNowPlayingSnapshotStore.swift @@ -0,0 +1,133 @@ +import Foundation + +struct LastNowPlayingSnapshot: Codable, Equatable { + static let version = 1 + static let maximumAge: TimeInterval = 24 * 60 * 60 + + struct Word: Codable, Equatable { + let id: Int + let startMs: Int64 + let durationMs: Int64 + let text: String + } + + struct Line: Codable, Equatable { + let index: Int + let timeMs: Int64 + let durationMs: Int64 + let text: String + let translation: String? + let romanization: String? + let words: [Word] + } + + let version: Int + let trackId: String + let title: String + let artist: String + let album: String + let wasPlaying: Bool + let positionMs: Int64 + let durationMs: Int64 + let lyricLines: [Line] + let albumArtId: String + let savedAt: Date + + var isValid: Bool { + version == Self.version && + !trackId.isEmpty && + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +final class LastNowPlayingSnapshotStore { + static let shared = LastNowPlayingSnapshotStore() + + private let ioQueue = DispatchQueue( + label: "com.musicblecontroller.last-now-playing", + qos: .utility + ) + private let fileURL: URL + private var pendingSaveWorkItem: DispatchWorkItem? + + init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let baseURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + self.fileURL = baseURL + .appendingPathComponent("MusicBleController", isDirectory: true) + .appendingPathComponent("LastNowPlayingSnapshot-v1.json") + } + } + + func load( + now: Date = Date(), + completion: @escaping (LastNowPlayingSnapshot?) -> Void + ) { + ioQueue.async { [fileURL] in + let snapshot = Self.readSnapshot(fileURL: fileURL, now: now) + DispatchQueue.main.async { + completion(snapshot) + } + } + } + + func save(_ snapshot: LastNowPlayingSnapshot) { + ioQueue.async { [weak self] in + guard let self else { return } + self.pendingSaveWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.write(snapshot) + } + self.pendingSaveWorkItem = workItem + self.ioQueue.asyncAfter(deadline: .now() + 0.35, execute: workItem) + } + } + + func clear() { + ioQueue.async { [weak self] in + guard let self else { return } + self.pendingSaveWorkItem?.cancel() + self.pendingSaveWorkItem = nil + try? FileManager.default.removeItem(at: self.fileURL) + } + } + + static func readSnapshot( + fileURL: URL, + now: Date = Date() + ) -> LastNowPlayingSnapshot? { + guard let data = try? Data(contentsOf: fileURL), + let snapshot = try? JSONDecoder().decode( + LastNowPlayingSnapshot.self, + from: data + ), + snapshot.isValid, + now.timeIntervalSince(snapshot.savedAt) >= 0, + now.timeIntervalSince(snapshot.savedAt) <= LastNowPlayingSnapshot.maximumAge else { + return nil + } + return snapshot + } + + private func write(_ snapshot: LastNowPlayingSnapshot) { + guard snapshot.isValid, + let data = try? JSONEncoder().encode(snapshot) else { + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: .atomic) + } catch { + // The next stable media update will retry. Snapshot persistence must + // never block or destabilize the playback path. + } + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift index b59bb4c..ae4dd68 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift @@ -1,161 +1,174 @@ import Foundation import UIKit -final class LiveActivityArtworkStore { - static let shared = LiveActivityArtworkStore() +struct LiveActivityArtworkWriteResult: Sendable { + let succeeded: Bool + let messages: [String] +} - private let fileManager = FileManager.default - private let cleanupQueue = DispatchQueue( - label: "com.sqz.IOSBleFeasibility.LiveActivityArtworkCleanup", - qos: .utility - ) +private actor LiveActivityArtworkFileWriter { private let maxCachedFiles = 50 - private let thumbnailSize = CGSize(width: 80, height: 80) - private let jpegQuality: CGFloat = 0.78 - - private init() {} - func writeThumbnail( - image: UIImage, - key: String, - revision: Int, - logger: ((String) -> Void)? = nil - ) -> Bool { - logger?("[LiveArtwork] write start key=\(key) revision=\(revision)") - guard let directoryURL = artworkDirectoryURL(logger: logger) else { - logger?("[LiveArtwork] file validation failed reason=container unavailable") - return false + func write(data: Data, key: String, revision: Int) -> LiveActivityArtworkWriteResult { + var messages = ["[LiveArtwork] write start key=\(key) revision=\(revision)"] + let fileManager = FileManager.default + guard let containerURL = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: LiveActivitySharedConstants.appGroupIdentifier + ) else { + messages.append("[LiveArtwork] file validation failed reason=container unavailable") + return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) } + let directoryURL = containerURL.appendingPathComponent( + LiveActivitySharedConstants.artworkDirectoryName, + isDirectory: true + ) + messages.append("[LiveArtwork] group container=\(containerURL.path)") + do { - try fileManager.createDirectory( - at: directoryURL, - withIntermediateDirectories: true - ) - guard let data = thumbnailJPEGData(from: image) else { - logger?("[LiveArtwork] file validation failed reason=encode failed") - return false - } - guard data.count > 0, data.count < 20_000 else { - logger?( - "[LiveArtwork] file validation failed reason=size bytes=\(data.count)" - ) - return false + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + guard !data.isEmpty, data.count < 20_000 else { + messages.append("[LiveArtwork] file validation failed reason=size bytes=\(data.count)") + return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) } let fileURL = directoryURL.appendingPathComponent( - LiveActivitySharedConstants.artworkFileName( - key: key, - revision: revision - ) + LiveActivitySharedConstants.artworkFileName(key: key, revision: revision) ) try data.write(to: fileURL, options: .atomic) - - let exists = fileManager.fileExists(atPath: fileURL.path) - let savedData = try Data(contentsOf: fileURL) - let decoded = UIImage(data: savedData) != nil - logger?( - "[LiveArtwork] write success path=\(fileURL.path) bytes=\(savedData.count)" - ) - logger?( - "[LiveArtwork] validate exists=\(exists) decode=\(decoded)" - ) - guard exists, !savedData.isEmpty, decoded else { - logger?("[LiveArtwork] file validation failed reason=decode") - return false + guard fileManager.fileExists(atPath: fileURL.path) else { + messages.append("[LiveArtwork] file validation failed reason=file missing") + return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) } - cleanupOldFiles(keeping: fileURL, logger: logger) - return true + messages.append("[LiveArtwork] write success path=\(fileURL.path) bytes=\(data.count)") + cleanupOldFiles(in: directoryURL, keeping: fileURL, fileManager: fileManager) + return LiveActivityArtworkWriteResult(succeeded: true, messages: messages) } catch { - logger?("[LiveArtwork] file validation failed reason=\(error.localizedDescription)") - return false - } - } - - func removeAll(logger: ((String) -> Void)? = nil) { - guard let directoryURL = artworkDirectoryURL(logger: logger) else { return } - cleanupQueue.async { [fileManager] in - guard let urls = try? fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil - ) else { return } - urls.forEach { try? fileManager.removeItem(at: $0) } + messages.append("[LiveArtwork] file validation failed reason=\(error.localizedDescription)") + return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) } } - private func artworkDirectoryURL(logger: ((String) -> Void)?) -> URL? { - let containerURL = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: - LiveActivitySharedConstants.appGroupIdentifier - ) - logger?( - "[LiveArtwork] group container=\(containerURL?.path ?? "nil")" - ) - return containerURL?.appendingPathComponent( + func removeAll() { + let fileManager = FileManager.default + guard let containerURL = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: LiveActivitySharedConstants.appGroupIdentifier + ) else { return } + let directoryURL = containerURL.appendingPathComponent( LiveActivitySharedConstants.artworkDirectoryName, isDirectory: true ) + guard let urls = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) else { return } + urls.forEach { try? fileManager.removeItem(at: $0) } + } + + private func cleanupOldFiles( + in directoryURL: URL, + keeping currentURL: URL, + fileManager: FileManager + ) { + guard let urls = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ), urls.count > maxCachedFiles else { return } + + let sorted = urls.sorted { lhs, rhs in + let lhsDate = (try? lhs.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate) ?? .distantPast + let rhsDate = (try? rhs.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate) ?? .distantPast + return lhsDate < rhsDate + } + sorted.prefix(max(0, urls.count - maxCachedFiles)).forEach { url in + guard url != currentURL else { return } + try? fileManager.removeItem(at: url) + } + } +} + +final class LiveActivityArtworkStore: @unchecked Sendable { + static let shared = LiveActivityArtworkStore() + + private final class ImageBox: @unchecked Sendable { + let image: UIImage + init(_ image: UIImage) { self.image = image } } - private func thumbnailJPEGData(from image: UIImage) -> Data? { + private let encodeQueue = DispatchQueue( + label: "com.sqz.IOSBleFeasibility.LiveActivityArtworkEncode", + qos: .utility + ) + private let writer = LiveActivityArtworkFileWriter() + private let thumbnailSize = CGSize(width: 80, height: 80) + private let jpegQuality: CGFloat = 0.78 + + private init() {} + + func writeThumbnail( + image: UIImage, + key: String, + revision: Int, + completion: @escaping @MainActor @Sendable (LiveActivityArtworkWriteResult) -> Void + ) { + let imageBox = ImageBox(image) + let thumbnailSize = thumbnailSize + let jpegQuality = jpegQuality + let writer = writer + encodeQueue.async { + let data = Self.thumbnailJPEGData( + from: imageBox.image, + size: thumbnailSize, + jpegQuality: jpegQuality + ) + guard let data else { + Task { @MainActor in + completion( + LiveActivityArtworkWriteResult( + succeeded: false, + messages: ["[LiveArtwork] file validation failed reason=encode failed"] + ) + ) + } + return + } + Task { + let result = await writer.write(data: data, key: key, revision: revision) + await completion(result) + } + } + } + + func removeAll() { + Task { await writer.removeAll() } + } + + private static func thumbnailJPEGData( + from image: UIImage, + size: CGSize, + jpegQuality: CGFloat + ) -> Data? { let format = UIGraphicsImageRendererFormat() format.scale = 1 format.opaque = true - let renderer = UIGraphicsImageRenderer(size: thumbnailSize, format: format) + let renderer = UIGraphicsImageRenderer(size: size, format: format) let rendered = renderer.image { _ in let sourceSize = image.size guard sourceSize.width > 0, sourceSize.height > 0 else { return } - let scale = max( - thumbnailSize.width / sourceSize.width, - thumbnailSize.height / sourceSize.height - ) - let drawSize = CGSize( - width: sourceSize.width * scale, - height: sourceSize.height * scale - ) + let scale = max(size.width / sourceSize.width, size.height / sourceSize.height) + let drawSize = CGSize(width: sourceSize.width * scale, height: sourceSize.height * scale) let origin = CGPoint( - x: (thumbnailSize.width - drawSize.width) / 2, - y: (thumbnailSize.height - drawSize.height) / 2 + x: (size.width - drawSize.width) / 2, + y: (size.height - drawSize.height) / 2 ) image.draw(in: CGRect(origin: origin, size: drawSize)) } return rendered.jpegData(compressionQuality: jpegQuality) } - - private func cleanupOldFiles( - keeping currentURL: URL, - logger: ((String) -> Void)? - ) { - cleanupQueue.async { [fileManager, maxCachedFiles] in - guard let urls = try? fileManager.contentsOfDirectory( - at: currentURL.deletingLastPathComponent(), - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ), urls.count > maxCachedFiles else { - return - } - - let sorted = urls.sorted { lhs, rhs in - let lhsDate = ( - try? lhs.resourceValues( - forKeys: [.contentModificationDateKey] - ).contentModificationDate - ) ?? Date.distantPast - let rhsDate = ( - try? rhs.resourceValues( - forKeys: [.contentModificationDateKey] - ).contentModificationDate - ) ?? Date.distantPast - return lhsDate < rhsDate - } - - let removable = sorted.prefix(max(0, urls.count - maxCachedFiles)) - removable.forEach { url in - guard url != currentURL else { return } - try? fileManager.removeItem(at: url) - } - logger?("[LiveArtwork] cleanup checked files=\(urls.count)") - } - } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift index 9fde26d..9a24d8c 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift @@ -1,4 +1,4 @@ -import ActivityKit +@preconcurrency import ActivityKit import Foundation @MainActor @@ -212,7 +212,7 @@ final class LiveActivityManager { logger: ((String) -> Void)? ) -> SonyMusicActivityAttributes.ContentState { let cleanTitle = trimmed( - cleaned(title, fallback: "Sony Music"), + cleaned(title, fallback: "等待同步"), limit: 48, field: "title", logger: logger @@ -368,30 +368,26 @@ final class LiveActivityManager { let startedAt = Date() logger?("[LiveActivityPerf] update start reason=\(reason)") logger?("[LiveActivityState] update start version=\(version)") - do { - let target = await self.ensureActivity(for: state, logger: logger) - if let target { - let content = ActivityContent( - state: state, - staleDate: self.staleDate(for: state) - ) - await target.update(content) - await MainActor.run { - self.activity = target - self.lastSentState = state - self.lastSentVersion = version - self.lastCalibrationDate = Date() - self.recordUpdateSent(logger: logger) - } - logger?( - "[LiveActivity] update sent reason=\(reason) " + - "title=\(state.title) lyric=\(state.lyric) " + - "lineIndex=\(state.lyricLineIndex) position=\(state.positionAtAnchorMs)" - ) - logger?("[LiveActivityState] update success version=\(version)") + let target = await self.ensureActivity(for: state, logger: logger) + if let target { + let content = ActivityContent( + state: state, + staleDate: self.staleDate(for: state) + ) + await target.update(content) + await MainActor.run { + self.activity = target + self.lastSentState = state + self.lastSentVersion = version + self.lastCalibrationDate = Date() + self.recordUpdateSent(logger: logger) } - } catch { - logger?("[LiveActivity] error=\(error.localizedDescription)") + logger?( + "[LiveActivity] update sent reason=\(reason) " + + "title=\(state.title) lyric=\(state.lyric) " + + "lineIndex=\(state.lyricLineIndex) position=\(state.positionAtAnchorMs)" + ) + logger?("[LiveActivityState] update success version=\(version)") } let costMs = Int(Date().timeIntervalSince(startedAt) * 1_000) logger?("[LiveActivityPerf] update end costMs=\(costMs)") diff --git a/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings new file mode 100644 index 0000000..20335e7 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings @@ -0,0 +1,195 @@ +{ + "sourceLanguage" : "zh-Hans", + "strings" : { + "Sony 已连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sony connected" } } } }, + "上一次" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous" } } } }, + "上一首" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous track" } } } }, + "上次播放 · 等待同步" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Last played · Waiting to sync" } } } }, + "不支持蓝牙" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth unsupported" } } } }, + "下一首" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Next track" } } } }, + "专辑" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Album" } } } }, + "当前歌词,点按打开完整歌词" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current lyrics. Tap to open full lyrics." } } } }, + "强制使用 V2 协议" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Force V2 protocol" } } } }, + "恢复连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Restoring connection" } } } }, + "播放 / 暂停" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play / Pause" } } } }, + "播放中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playing" } } } }, + "播放历史" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playback history" } } } }, + "播放进度" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playback progress" } } } }, + "无歌词" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No lyrics" } } } }, + "暂无歌词" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No lyrics available" } } } }, + "未授权" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Permission required" } } } }, + "未连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Disconnected" } } } }, + "正在恢复连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Restoring connection" } } } }, + "正在扫描" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scanning" } } } }, + "正在查找 Sony" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Looking for Sony" } } } }, + "正在连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connecting" } } } }, + "正在连接 Sony" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connecting to Sony" } } } }, + "歌词优先" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics" } } } }, + "歌词同步中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Syncing lyrics" } } } }, + "歌词显示" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics display" } } } }, + "歌词诊断中心" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics diagnostics" } } } }, + "流畅" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Smooth" } } } }, + "灵动岛样式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dynamic Island style" } } } }, + "省电" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Power saving" } } } }, + "等待连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Waiting to connect" } } } }, + "等待同步" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Waiting to sync" } } } }, + "系统健康总览" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System health" } } } }, + "节奏优先" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Rhythm" } } } }, + "自动" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Automatic" } } } }, + "自动同步歌词时间" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Automatically sync lyric timing" } } } }, + "自动重连" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Auto reconnect" } } } }, + "蓝牙不可用" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth unavailable" } } } }, + "蓝牙已关闭" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth off" } } } }, + "扫描 / 重连" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scan / Reconnect" } } } }, + "扫描中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scanning" } } } }, + "扫描并连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Scan and connect" } } } }, + "打开设置" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Settings" } } } }, + "设置" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Settings" } } } }, + "调试工具" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Debug tools" } } } }, + "连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connect" } } } }, + "连接中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connecting" } } } }, + "连接你的 Sony 设备" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connect your Sony device" } } } }, + "连接失败" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connection failed" } } } }, + "音量" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Volume" } } } }, + "默认" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Default" } } } }, + "更多设置" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "More settings" } } } }, + "当前歌曲封面" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current track artwork" } } } }, + "当前歌曲诊断" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Now playing diagnostics" } } } }, + "完成" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } } } }, + "请打开蓝牙" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Turn on Bluetooth" } } } }, + "需要蓝牙权限" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth permission required" } } } }, + "%@ · %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ · %2$@" } } } }, + "%@ · %lldpt" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ · %lld pt" } } } }, + "%@ · 听了 %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ · Listened for %2$@" } } } }, + "%@,共 %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ of %2$@" } } } }, + "%lld" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld" } } } }, + "%lld bytes" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld bytes" } } } }, + "%lldx%lld" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld×%2$lld" } } } }, + "%lld,最大 %lld" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld, maximum %2$lld" } } } }, + "Sony 日志传输中..." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Transferring Sony logs…" } } } }, + "专辑 · %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Album · %@" } } } }, + "人工微调" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Manual adjustment" } } } }, + "仅用于跨端 A/B 与紧急回退;切换后请重新连接。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "For cross-device A/B testing and emergency rollback only. Reconnect after changing it." } } } }, + "会断开当前 BLE 连接并立即重新扫描连接。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This disconnects the current Bluetooth connection and immediately scans again." } } } }, + "使用模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Experience mode" } } } }, + "健康摘要" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Health summary" } } } }, + "关闭" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Close" } } } }, + "关闭歌词" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Close lyrics" } } } }, + "关闭自动重连后,手动扫描 / 重连仍然可用。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Manual scan and reconnect remain available when automatic reconnect is off." } } } }, + "分享" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Share" } } } }, + "刷新" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Refresh" } } } }, + "占位图" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Placeholder" } } } }, + "取消" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancel" } } } }, + "只影响逐字高亮提前/延后,不影响进度条和歌词点击跳转。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Only adjusts word highlighting; playback progress and lyric seeking are unchanged." } } } }, + "可在 Sony QQ音乐打开歌词/桌面歌词后稍等" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open lyrics or desktop lyrics in QQ Music on Sony, then wait briefly." } } } }, + "回到当前歌词" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Return to current lyric" } } } }, + "复制 Media Dump" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy media dump" } } } }, + "复制 Sony 日志" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy Sony logs" } } } }, + "复制 iOS 日志" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy iOS logs" } } } }, + "复制后可直接发给 Codex 分析,不包含完整歌词或图片。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The copied summary can be sent to Codex and contains no full lyrics or images." } } } }, + "存在" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Present" } } } }, + "实时活动控制" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Live Activity controls" } } } }, + "封面增强" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Artwork enhancement" } } } }, + "封面显示尺寸" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Artwork size" } } } }, + "封面状态" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Artwork status" } } } }, + "尚未同步" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Not synced yet" } } } }, + "尚未获取" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Not available yet" } } } }, + "当前歌曲" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current track" } } } }, + "快捷操作" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Quick actions" } } } }, + "性能模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Performance mode" } } } }, + "恢复:%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Recover: %@" } } } }, + "控制 Sony 播放上一首" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play the previous track on Sony" } } } }, + "控制 Sony 播放下一首" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play the next track on Sony" } } } }, + "控制当前 Sony 播放或暂停" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play or pause the current Sony track" } } } }, + "播放/暂停" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Play/Pause" } } } }, + "操作" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Actions" } } } }, + "日常模式保留播放器核心功能;调试模式显示诊断、日志和高级入口。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Daily mode keeps core player features; Debug mode adds diagnostics, logs, and advanced tools." } } } }, + "暂无播放历史" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No playback history" } } } }, + "最近播放" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Recently played" } } } }, + "最近趋势" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Recent trends" } } } }, + "最近问题" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Recent issues" } } } }, + "查看原因" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "View reason" } } } }, + "歌词 %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics %@" } } } }, + "歌词偏移" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyric offset" } } } }, + "歌词显示模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyric display mode" } } } }, + "歌词状态" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics status" } } } }, + "歌词诊断" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Lyrics diagnostics" } } } }, + "正在生成健康总览" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Generating health overview" } } } }, + "正在生成诊断" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Generating diagnostics" } } } }, + "清空" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Clear" } } } }, + "清除 iPhone 本地缓存" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Clear local iPhone cache" } } } }, + "点按扫描并连接 Sony" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Tap to scan for and connect to Sony" } } } }, + "点按查看连接详情" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Tap to view connection details" } } } }, + "状态细节" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Status details" } } } }, + "生成于 %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Generated at %@" } } } }, + "界面语言" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Interface language" } } } }, + "目标尺寸" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Target size" } } } }, + "统计" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Statistics" } } } }, + "缺失" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Missing" } } } }, + "自动补偿 Sony 与 iPhone 时钟差及蓝牙传输延迟" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Automatically compensates for Sony/iPhone clock differences and Bluetooth latency." } } } }, + "自愈引擎" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Self-healing" } } } }, + "连接 Sony 后会从 PlayerAgent 同步历史。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playback history syncs from PlayerAgent after connecting to Sony." } } } }, + "连接健康" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connection health" } } } }, + "连接状态" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connection status" } } } }, + "重新连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnect" } } } }, + "重新连接 Sony" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnect Sony" } } } }, + "重新连接 Sony?" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnect Sony?" } } } }, + "锐度" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sharpness" } } } }, + "音量 %lld" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Volume %lld" } } } }, + "验证:%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Verify: %@" } } } }, + "已连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connected" } } } }, + "此设备不支持蓝牙" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth is not supported on this device" } } } }, + "蓝牙暂不可用" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth is temporarily unavailable" } } } }, + "连接没有成功" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn’t connect" } } } }, + "打开系统蓝牙后,应用会自动继续连接。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The app will continue connecting after Bluetooth is turned on." } } } }, + "请在系统设置中允许 Sony Music 使用蓝牙。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Allow Sony Music to use Bluetooth in Settings." } } } }, + "需要支持低功耗蓝牙的 iPhone。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "An iPhone with Bluetooth Low Energy is required." } } } }, + "蓝牙正在恢复,请稍后再试。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth is recovering. Try again shortly." } } } }, + "请确保 Sony 端已启动并处于可连接状态。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Make sure the Sony app is running and ready to connect." } } } }, + "正在同步播放状态、歌词和封面。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Syncing playback, lyrics, and artwork." } } } }, + "确认 Sony 端已启动,然后重新扫描。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Make sure the Sony app is running, then scan again." } } } }, + "连接后可控制播放,并同步歌词、封面与灵动岛。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connect to control playback and sync lyrics, artwork, and Live Activities." } } } }, + "等待蓝牙开启" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Waiting for Bluetooth" } } } }, + "当前设备不支持" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Not supported on this device" } } } }, + "等待蓝牙恢复" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Waiting for Bluetooth" } } } }, + "正在恢复" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Restoring" } } } }, + "正在播放" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Playing" } } } }, + "已暂停" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Paused" } } } }, + "加载中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading" } } } }, + "重连中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnecting" } } } }, + "已停止" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Stopped" } } } }, + "正在播放音乐" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Music is playing" } } } }, + "音乐暂停状态" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Music is paused" } } } }, + "内容加载中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Content is loading" } } } }, + "设备重连中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Device is reconnecting" } } } }, + "音乐已停止" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Music has stopped" } } } }, + "正在重新连接 Sony" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnecting to Sony" } } } }, + "Sony 未连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sony disconnected" } } } }, + "根据低电量模式和播放状态自动平衡实时性与耗电。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Balances responsiveness and power use based on playback and Low Power Mode." } } } }, + "优先频谱、封面和灵动岛的响应速度。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Prioritizes responsiveness for the spectrum, artwork, and Dynamic Island." } } } }, + "降低动画和后台刷新频率,高清封面仍会延后加载。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reduces animation and background refresh rates and delays HQ artwork." } } } }, + "小" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Small" } } } }, + "中" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Medium" } } } }, + "大" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Large" } } } }, + "日常模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Daily" } } } }, + "调试模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Debug" } } } }, + "进入调试模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Enter Debug mode" } } } }, + "退出调试模式" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Exit Debug mode" } } } }, + "原文" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original" } } } }, + "翻译" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Translation" } } } }, + "罗马音" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Romanization" } } } }, + "全部" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "All" } } } }, + "原文 + 翻译" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original + Translation" } } } }, + "原文 + 罗马音" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original + Romanization" } } } }, + "原文 + 翻译 + 罗马音" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original + Translation + Romanization" } } } }, + "预览图" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Preview" } } } }, + "高清图" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "HQ" } } } }, + "增强图" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Enhanced" } } } }, + "已请求清理增强封面缓存" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Artwork cache cleanup requested" } } } }, + "已复制日志路径" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Log path copied" } } } }, + "已复制诊断摘要" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Diagnostic summary copied" } } } }, + "正在重连" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnecting" } } } }, + "默认展示封面、标题与歌手;歌词优先突出当前歌词;节奏优先展示播放状态与节奏条。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Default shows artwork, title, and artist; Lyrics emphasizes the current lyric; Rhythm emphasizes playback state and the rhythm strip." } } } } + }, + "version" : "1.0" +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift index 371be0e..061bce3 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift @@ -108,7 +108,13 @@ struct LyricDiagnostic: Equatable { if normalized.contains("loading") { return "歌词正在加载。" } - return raw.isEmpty ? statusTitle : raw + if !raw.isEmpty { + return raw + } + let fallback = normalizedStatus + .replacingOccurrences(of: "_", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return fallback.isEmpty ? "未知歌词状态" : fallback } var suggestionText: String { diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricDisplayMode.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricDisplayMode.swift index c2d0a56..367c1ab 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricDisplayMode.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricDisplayMode.swift @@ -11,26 +11,26 @@ enum LyricDisplayMode: String, CaseIterable, Identifiable { var title: String { switch self { case .original: - return "原文" + return AppLocalization.string("原文") case .originalTranslation: - return "翻译" + return AppLocalization.string("翻译") case .originalRomanization: - return "罗马音" + return AppLocalization.string("罗马音") case .originalTranslationRomanization: - return "全部" + return AppLocalization.string("全部") } } var menuTitle: String { switch self { case .original: - return "原文" + return AppLocalization.string("原文") case .originalTranslation: - return "原文 + 翻译" + return AppLocalization.string("原文 + 翻译") case .originalRomanization: - return "原文 + 罗马音" + return AppLocalization.string("原文 + 罗马音") case .originalTranslationRomanization: - return "原文 + 翻译 + 罗马音" + return AppLocalization.string("原文 + 翻译 + 罗马音") } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/MediaLoadingState.swift b/IOSBleFeasibility/IOSBleFeasibility/MediaLoadingState.swift new file mode 100644 index 0000000..a140ec5 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/MediaLoadingState.swift @@ -0,0 +1,74 @@ +import Foundation + +enum LyricLoadingStage: Equatable { + case idle + case waitingQqQrc + case windowReady(lineCount: Int) + case fullLyrics(received: Int, expected: Int) + case ready(lineCount: Int) + case failed(reason: String) + + var title: String { + switch self { + case .idle: + return "歌词待同步" + case .waitingQqQrc: + return "等待 QQ QRC" + case let .windowReady(lineCount): + return "歌词窗口就绪 · \(lineCount)行" + case let .fullLyrics(received, expected): + return "完整歌词 · \(received)/\(max(expected, 1))" + case let .ready(lineCount): + return "完整歌词就绪 · \(lineCount)行" + case let .failed(reason): + return "歌词失败 · \(Self.short(reason))" + } + } + + var isFailure: Bool { + if case .failed = self { return true } + return false + } + + private static func short(_ value: String) -> String { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "未找到" : String(text.prefix(24)) + } +} + +enum ArtworkLoadingStage: Equatable { + case idle + case preview(received: Int, expected: Int) + case previewReady + case hq(received: Int, expected: Int) + case hqReady + case failed(reason: String) + + var title: String { + switch self { + case .idle: + return "封面待同步" + case let .preview(received, expected): + return expected > 0 ? "封面 Preview · \(received)/\(expected)" : "等待封面 Preview" + case .previewReady: + return "封面 Preview 就绪" + case let .hq(received, expected): + return expected > 0 ? "封面 HQ · \(received)/\(expected)" : "等待封面 HQ" + case .hqReady: + return "封面 HQ 就绪" + case let .failed(reason): + let text = reason.trimmingCharacters(in: .whitespacesAndNewlines) + return "封面失败 · \(text.isEmpty ? "未找到" : String(text.prefix(24)))" + } + } + + var isFailure: Bool { + if case .failed = self { return true } + return false + } +} + +struct MediaLoadingState: Equatable { + var lyric: LyricLoadingStage = .idle + var artwork: ArtworkLoadingStage = .idle +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/NextLiveActivityIntent.swift b/IOSBleFeasibility/IOSBleFeasibility/NextLiveActivityIntent.swift index 93f0e07..3e45e4d 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/NextLiveActivityIntent.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/NextLiveActivityIntent.swift @@ -8,7 +8,7 @@ struct NextLiveActivityIntent: LiveActivityIntent { @MainActor func perform() async throws -> some IntentResult { - LiveActivityCommandBridge.shared.performIntent(command: .next) + _ = LiveActivityCommandBridge.shared.performIntent(command: .next) return .result() } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift index 95b6874..97a8788 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift @@ -76,6 +76,14 @@ struct CurrentWordDiagnosticSnapshot: Equatable { let dropCount: Int64 let averageUpdateIntervalMs: Int64 let lastLatencyMs: Int64 + let automaticSyncEnabled: Bool + let automaticCompensationMs: Int64 + let manualFineTuneMs: Int64 + let legacyFallbackMs: Int64 + let clockBestRoundTripMs: Int64 + let clockOffsetJitterMs: Int64 + let clockSampleCount: Int + let clockSyncConfident: Bool } struct NowPlayingDiagnosticSnapshot { @@ -231,6 +239,14 @@ struct NowPlayingDiagnosticSnapshot { lines.append("currentWordDropCount: \(currentWord.dropCount)") lines.append("currentWordAverageIntervalMs: \(currentWord.averageUpdateIntervalMs)") lines.append("currentWordLastLatencyMs: \(currentWord.lastLatencyMs)") + lines.append("automaticLyricSync: \(currentWord.automaticSyncEnabled)") + lines.append("automaticCompensationMs: \(currentWord.automaticCompensationMs)") + lines.append("manualFineTuneMs: \(currentWord.manualFineTuneMs)") + lines.append("legacyFallbackMs: \(currentWord.legacyFallbackMs)") + lines.append("clockBestRoundTripMs: \(currentWord.clockBestRoundTripMs)") + lines.append("clockOffsetJitterMs: \(currentWord.clockOffsetJitterMs)") + lines.append("clockSampleCount: \(currentWord.clockSampleCount)") + lines.append("clockSyncConfident: \(currentWord.clockSyncConfident)") lines.append("") lines.append("[Artwork]") lines.append("displayQuality: \(albumArtDisplayQuality)") @@ -713,7 +729,7 @@ struct SystemHealthSnapshot { return "封面传输中断,可点击重试封面。" } if artwork.status == "图片过大" { - return "HQ 图片过大,已使用 fallback 或 preview。" + return "高清图片过大,已使用回退图或预览图。" } if selfHealing.level == .warning || selfHealing.level == .critical { return "查看 Self-Healing 报告中的失败或诊断项。" @@ -725,13 +741,13 @@ struct SystemHealthSnapshot { } private static func cacheSummary(_ cache: ArtworkCacheDiagnostic?) -> String { - guard let cache else { return "unknown" } - guard cache.exists else { return "missing" } + guard let cache else { return "未知" } + guard cache.exists else { return "缺失" } return "\(cache.pixelWidth)x\(cache.pixelHeight), \(cache.bytes) bytes" } private static func formatTimestamp(_ value: Int64) -> String { - guard value > 0 else { return "unknown" } + guard value > 0 else { return "未知" } return NowPlayingDiagnosticSnapshot.optionalDate( Date(timeIntervalSince1970: TimeInterval(value) / 1_000) ) diff --git a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift index 4676b79..1042eda 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift @@ -156,6 +156,33 @@ struct NowPlayingDiagnosticView: View { diagnosticRow("轻量词丢弃", "\(snapshot.currentWord.dropCount) 次") diagnosticRow("平均间隔", "\(snapshot.currentWord.averageUpdateIntervalMs)ms") diagnosticRow("最近延迟", "\(snapshot.currentWord.lastLatencyMs)ms") + diagnosticRow( + "自动校时", + snapshot.currentWord.automaticSyncEnabled ? "开启" : "关闭" + ) + diagnosticRow( + "自动补偿", + "\(snapshot.currentWord.automaticCompensationMs)ms" + ) + diagnosticRow("人工微调", "\(snapshot.currentWord.manualFineTuneMs)ms") + if snapshot.currentWord.legacyFallbackMs != 0 { + diagnosticRow( + "旧协议补偿", + "\(snapshot.currentWord.legacyFallbackMs)ms" + ) + } + diagnosticRow( + "校时质量", + snapshot.currentWord.clockSyncConfident ? "可信" : "采样中" + ) + diagnosticRow( + "校时 RTT", + "\(snapshot.currentWord.clockBestRoundTripMs)ms" + ) + diagnosticRow( + "校时抖动", + "\(snapshot.currentWord.clockOffsetJitterMs)ms" + ) } } } @@ -250,7 +277,7 @@ struct NowPlayingDiagnosticView: View { private func selfHealingCard(_ snapshot: NowPlayingDiagnosticSnapshot) -> some View { DiagnosticCard { VStack(alignment: .leading, spacing: 12) { - Label("Self-Healing", systemImage: "cross.case") + Label("自愈引擎", systemImage: "cross.case") .font(.headline.weight(.bold)) Text(snapshot.selfHealing.overallStatus) @@ -281,7 +308,7 @@ struct NowPlayingDiagnosticView: View { private func recentIssuesCard(_ snapshot: NowPlayingDiagnosticSnapshot) -> some View { DiagnosticCard { VStack(alignment: .leading, spacing: 12) { - Label("Recent Issues", systemImage: "exclamationmark.triangle") + Label("最近问题", systemImage: "exclamationmark.triangle") .font(.headline.weight(.bold)) VStack(alignment: .leading, spacing: 8) { @@ -305,7 +332,7 @@ struct NowPlayingDiagnosticView: View { private func quickActionsCard(_ snapshot: NowPlayingDiagnosticSnapshot) -> some View { DiagnosticCard { VStack(alignment: .leading, spacing: 12) { - Label("Quick Actions", systemImage: "bolt.fill") + Label("快捷操作", systemImage: "bolt.fill") .font(.headline.weight(.bold)) LazyVGrid( @@ -404,11 +431,11 @@ struct NowPlayingDiagnosticView: View { .font(.caption) .foregroundStyle(.white.opacity(0.78)) .lineLimit(2) - Text("Recover: \(report.recoveryAction)") + Text("恢复:\(report.recoveryAction)") .font(.caption2) .foregroundStyle(.white.opacity(0.54)) .lineLimit(3) - Text("Verify: \(report.verifySignal)") + Text("验证:\(report.verifySignal)") .font(.caption2) .foregroundStyle(.white.opacity(0.54)) .lineLimit(3) diff --git a/IOSBleFeasibility/IOSBleFeasibility/PlayPauseLiveActivityIntent.swift b/IOSBleFeasibility/IOSBleFeasibility/PlayPauseLiveActivityIntent.swift index 8aed2b2..6f6bb2c 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PlayPauseLiveActivityIntent.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PlayPauseLiveActivityIntent.swift @@ -8,7 +8,7 @@ struct PlayPauseLiveActivityIntent: LiveActivityIntent { @MainActor func perform() async throws -> some IntentResult { - LiveActivityCommandBridge.shared.performIntent(command: .playPause) + _ = LiveActivityCommandBridge.shared.performIntent(command: .playPause) return .result() } } @@ -20,7 +20,7 @@ struct ReconnectLiveActivityIntent: LiveActivityIntent { @MainActor func perform() async throws -> some IntentResult { - LiveActivityCommandBridge.shared.performIntent(command: .reconnect) + _ = LiveActivityCommandBridge.shared.performIntent(command: .reconnect) return .result() } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift b/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift index 4243a91..793600c 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift @@ -5,21 +5,26 @@ struct PlaybackHistoryView: View { @ObservedObject var bleManager: BLETestManager @Environment(\.dismiss) private var dismiss @State private var tab = 0 + @State private var query = "" + @State private var range: PlaybackHistoryRange = .week var body: some View { NavigationStack { - VStack(spacing: 0) { - Picker("播放历史", selection: $tab) { - Text("最近播放").tag(0) - Text("统计").tag(1) - } - .pickerStyle(.segmented) - .padding() + ZStack { + PlayerDesignTokens.canvas.ignoresSafeArea() + VStack(spacing: 0) { + Picker("播放历史", selection: $tab) { + Text("最近播放").tag(0) + Text("统计").tag(1) + } + .pickerStyle(.segmented) + .padding() - if tab == 0 { - recentList - } else { - PlaybackStatsView(stats: bleManager.playbackStats) + if tab == 0 { + recentList + } else { + PlaybackStatsView(stats: bleManager.playbackStats) + } } } .navigationTitle("播放历史") @@ -29,9 +34,8 @@ struct PlaybackHistoryView: View { } ToolbarItem(placement: .topBarTrailing) { Menu { - Button("刷新") { - bleManager.syncPlaybackHistory() - } + Button("刷新") { syncIfIdle() } + .disabled(bleManager.isPlaybackHistorySyncing) Button("清除 iPhone 本地缓存", role: .destructive) { bleManager.clearLocalPlaybackHistory() } @@ -42,40 +46,123 @@ struct PlaybackHistoryView: View { } .onAppear { bleManager.loadCachedPlaybackHistory() - bleManager.syncPlaybackHistory() + syncIfIdle() } + .searchable(text: $query, prompt: "搜索歌曲、歌手或专辑") } + .preferredColorScheme(.dark) } private var recentList: some View { List { + Picker("时间范围", selection: $range) { + ForEach(PlaybackHistoryRange.allCases) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + if !bleManager.playbackHistoryStatus.isEmpty { Text(bleManager.playbackHistoryStatus) .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) + .listRowBackground(Color.clear) } - if bleManager.playbackHistorySessions.isEmpty { + if filteredSessions.isEmpty { ContentUnavailableView( - "暂无播放历史", + query.isEmpty ? "暂无播放历史" : "未找到播放记录", systemImage: "music.note.list", - description: Text("连接 Sony 后会从 PlayerAgent 同步历史。") + description: Text( + query.isEmpty + ? "连接 Sony 后会从 PlayerAgent 同步历史。" + : "试试其他歌曲、歌手或专辑。" + ) ) + .listRowBackground(Color.clear) } else { - ForEach(bleManager.playbackHistorySessions) { session in - PlaybackHistoryRow(session: session) - .onAppear { - if session.sessionId == bleManager.playbackHistorySessions.last?.sessionId { - bleManager.loadMorePlaybackHistory() - } + ForEach(groupedSessions, id: \.title) { group in + Section(group.title) { + ForEach(group.sessions) { session in + PlaybackHistoryRow(session: session) + .listRowBackground(PlayerDesignTokens.surface1) + .onAppear { + if session.sessionId == filteredSessions.last?.sessionId, + !bleManager.isPlaybackHistorySyncing { + bleManager.loadMorePlaybackHistory() + } + } } + } } } } - .refreshable { - bleManager.syncPlaybackHistory() + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + .refreshable { syncIfIdle() } + } + + private var filteredSessions: [PlaybackHistorySession] { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedLowercase + return bleManager.playbackHistorySessions.filter { session in + range.contains(timestampMs: session.startedAt) && ( + normalized.isEmpty || [session.title, session.artist, session.album] + .contains { $0.localizedLowercase.contains(normalized) } + ) } } + + private var groupedSessions: [(title: String, sessions: [PlaybackHistorySession])] { + var result: [(String, [PlaybackHistorySession])] = [] + for session in filteredSessions { + let title = PlaybackHistoryFormat.dayTitle(session.startedAt) + if result.last?.0 == title { + result[result.count - 1].1.append(session) + } else { + result.append((title, [session])) + } + } + return result + } + + private func syncIfIdle() { + guard !bleManager.isPlaybackHistorySyncing else { return } + bleManager.syncPlaybackHistory() + } +} + +private enum PlaybackHistoryRange: String, CaseIterable, Identifiable { + case today + case week + case month + case all + + var id: String { rawValue } + var title: String { + switch self { + case .today: "今日" + case .week: "7 天" + case .month: "30 天" + case .all: "全部" + } + } + + func contains(timestampMs: Int64, now: Date = Date()) -> Bool { + guard self != .all else { return true } + let calendar = Calendar.current + let days: Int + switch self { + case .today: days = 0 + case .week: days = 6 + case .month: days = 29 + case .all: return true + } + let startOfToday = calendar.startOfDay(for: now) + let lowerBound = calendar.date(byAdding: .day, value: -days, to: startOfToday) ?? startOfToday + return Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1_000) >= lowerBound + } } private struct PlaybackHistoryRow: View { @@ -84,26 +171,26 @@ private struct PlaybackHistoryRow: View { var body: some View { HStack(spacing: 12) { HistoryArtworkView(artworkId: session.artworkId) - .frame(width: 48, height: 48) - + .frame(width: 52, height: 52) VStack(alignment: .leading, spacing: 4) { Text(session.title.ifBlank("-")) .font(.headline) .lineLimit(1) Text("\(session.artist.ifBlank("未知歌手")) · \(session.album.ifBlank("未知专辑"))") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) .lineLimit(1) Text("\(PlaybackHistoryFormat.dateTime(session.startedAt)) · 听了 \(PlaybackHistoryFormat.duration(session.listenedMs))") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) .monospacedDigit() Text(stateText) .font(.caption2.weight(.semibold)) .foregroundStyle(stateColor) } } - .padding(.vertical, 4) + .padding(.vertical, 7) + .accessibilityElement(children: .combine) } private var stateText: String { @@ -114,9 +201,9 @@ private struct PlaybackHistoryRow: View { } private var stateColor: Color { - if session.completed { return .green } - if session.skipped { return .orange } - return .secondary + if session.completed { return PlayerDesignTokens.stableAccent } + if session.skipped { return PlayerDesignTokens.warning } + return PlayerDesignTokens.secondaryText } } @@ -125,7 +212,7 @@ struct PlaybackStatsView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 18) { summaryGrid if let week = stats["WEEK"] { rankingSection(title: "本周最常听歌曲", tracks: week.topTracks) @@ -133,7 +220,7 @@ struct PlaybackStatsView: View { trendSection(stats: week.dailyTrend) } } - .padding() + .padding(20) } } @@ -153,101 +240,104 @@ struct PlaybackStatsView: View { private func statCard(_ title: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.headline.monospacedDigit()) + Text(title).font(.caption).foregroundStyle(PlayerDesignTokens.secondaryText) + Text(value).font(.headline.monospacedDigit()) } .frame(maxWidth: .infinity, alignment: .leading) .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .background(PlayerDesignTokens.surface1, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) } private func rankingSection(title: String, tracks: [PlaybackTopTrack]) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(title).font(.headline) + darkCard(title: title) { ForEach(tracks.prefix(10)) { track in HStack { - Text(track.title.ifBlank("-")) - .lineLimit(1) + Text(track.title.ifBlank("-")).lineLimit(1) Spacer() Text(PlaybackHistoryFormat.duration(track.listenedMs)) .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) } } } } private func artistSection(title: String, artists: [PlaybackTopArtist]) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(title).font(.headline) + darkCard(title: title) { ForEach(artists.prefix(10)) { artist in HStack { - Text(artist.artist.ifBlank("未知歌手")) - .lineLimit(1) + Text(artist.artist.ifBlank("未知歌手")).lineLimit(1) Spacer() Text(PlaybackHistoryFormat.duration(artist.listenedMs)) .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) } } } } private func trendSection(stats: [DailyListenStat]) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text("最近趋势").font(.headline) + darkCard(title: "最近趋势") { let maxValue = max(stats.map(\.listenedMs).max() ?? 1, 1) HStack(alignment: .bottom, spacing: 8) { ForEach(stats.suffix(7)) { day in VStack(spacing: 6) { RoundedRectangle(cornerRadius: 4) - .fill(.green) + .fill(PlayerDesignTokens.stableAccent) .frame(height: max(8, CGFloat(day.listenedMs) / CGFloat(maxValue) * 80)) Text(String(day.dateKey.suffix(5))) .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) } .frame(maxWidth: .infinity) } } } } + + private func darkCard(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title).font(.headline) + content() + } + .padding(16) + .background(PlayerDesignTokens.surface1, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) + } } private struct HistoryArtworkView: View { let artworkId: String? + @State private var cachedImage: UIImage? var body: some View { Group { if let image = cachedImage { - Image(uiImage: image) - .resizable() - .scaledToFill() + Image(uiImage: image).resizable().scaledToFill() } else { Image(systemName: "music.note") .font(.title3) - .foregroundStyle(.secondary) + .foregroundStyle(PlayerDesignTokens.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.quaternary) + .background(PlayerDesignTokens.surface2) } } .clipShape(RoundedRectangle(cornerRadius: 9)) + .task(id: artworkId) { cachedImage = await loadCachedImage() } } - private var cachedImage: UIImage? { + private func loadCachedImage() async -> UIImage? { guard let artworkId, !artworkId.isEmpty else { return nil } let base = sha256(artworkId) let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let directory = documents.appendingPathComponent("AlbumArtCache", isDirectory: true) - for suffix in ["_hq", "_preview", ""] { + for (suffix, quality) in [("_hq", "hq"), ("_preview", "preview"), ("", "legacy")] { let url = directory.appendingPathComponent("\(base)\(suffix)").appendingPathExtension("jpg") - if let data = try? Data(contentsOf: url), - let image = UIImage(data: data) { - return image - } + if let image = await ArtworkImageCache.shared.load( + artworkId: artworkId, + quality: "history-\(quality)", + fileURL: url, + maximumPixelSize: ArtworkImageCache.historyArtworkMaximumPixelSize + ) { return image } } return nil } @@ -258,25 +348,40 @@ private struct HistoryArtworkView: View { } enum PlaybackHistoryFormat { + private static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.doesRelativeDateFormatting = true + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "M 月 d 日 EEEE" + return formatter + }() + static func duration(_ ms: Int64) -> String { let totalSeconds = max(ms, 0) / 1_000 let hours = totalSeconds / 3_600 let minutes = (totalSeconds % 3_600) / 60 let seconds = totalSeconds % 60 - if hours > 0 { - return "\(hours)小时\(minutes)分钟" - } + if hours > 0 { return "\(hours)小时\(minutes)分钟" } return String(format: "%02d:%02d", minutes, seconds) } static func dateTime(_ ms: Int64) -> String { + dateTimeFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(ms) / 1_000)) + } + + static func dayTitle(_ ms: Int64) -> String { let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1_000) - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "zh_CN") - formatter.doesRelativeDateFormatting = true - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: date) + if Calendar.current.isDateInToday(date) { return "今天" } + if Calendar.current.isDateInYesterday(date) { return "昨天" } + return dayFormatter.string(from: date) } static func percent(_ value: Double) -> String { diff --git a/IOSBleFeasibility/IOSBleFeasibility/PlayerProductUI.swift b/IOSBleFeasibility/IOSBleFeasibility/PlayerProductUI.swift new file mode 100644 index 0000000..c3f244b --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/PlayerProductUI.swift @@ -0,0 +1,493 @@ +import SwiftUI + +enum PlayerDesignTokens { + static let canvas = Color(red: 6 / 255, green: 9 / 255, blue: 14 / 255) + static let surface1 = Color(red: 15 / 255, green: 21 / 255, blue: 31 / 255) + static let surface2 = Color(red: 24 / 255, green: 34 / 255, blue: 48 / 255) + static let primaryText = Color(red: 245 / 255, green: 247 / 255, blue: 251 / 255) + static let secondaryText = Color(red: 167 / 255, green: 176 / 255, blue: 189 / 255) + static let quietText = Color(red: 105 / 255, green: 116 / 255, blue: 132 / 255) + static let healthy = Color(red: 100 / 255, green: 217 / 255, blue: 140 / 255) + static let warning = Color(red: 242 / 255, green: 181 / 255, blue: 109 / 255) + static let disconnected = Color(red: 236 / 255, green: 119 / 255, blue: 116 / 255) + static let stableAccent = Color(red: 120 / 255, green: 219 / 255, blue: 195 / 255) + + static let smallRadius: CGFloat = 12 + static let cardRadius: CGFloat = 18 + static let heroRadius: CGFloat = 28 + static let minimumHitTarget: CGFloat = 44 +} + +enum PlayerLayoutMode: Equatable { + case regular + case compact + case accessibility + + static func resolve(availableHeight: CGFloat, dynamicTypeSize: DynamicTypeSize) -> Self { + resolve( + availableSize: CGSize(width: 393, height: availableHeight), + safeAreaInsets: EdgeInsets(), + dynamicTypeSize: dynamicTypeSize + ) + } + + static func resolve( + availableSize: CGSize, + safeAreaInsets: EdgeInsets, + dynamicTypeSize: DynamicTypeSize + ) -> Self { + if dynamicTypeSize.isAccessibilitySize { return .accessibility } + let usableHeight = availableSize.height - safeAreaInsets.top - safeAreaInsets.bottom + let isLandscape = availableSize.width > availableSize.height + if isLandscape || usableHeight < 735 || availableSize.width < 360 { + return .compact + } + return .regular + } + + var isCompact: Bool { self != .regular } +} + +struct PlayerLayoutMetrics: Equatable { + let mode: PlayerLayoutMode + let artworkSize: CGFloat + let horizontalPadding: CGFloat + let sectionSpacing: CGFloat + let lyricHeight: CGFloat + + static func resolve( + availableSize: CGSize, + safeAreaInsets: EdgeInsets, + dynamicTypeSize: DynamicTypeSize, + artworkPreference: ArtworkDisplaySizeOption + ) -> Self { + let mode = PlayerLayoutMode.resolve( + availableSize: availableSize, + safeAreaInsets: safeAreaInsets, + dynamicTypeSize: dynamicTypeSize + ) + let usableHeight = max( + availableSize.height - safeAreaInsets.top - safeAreaInsets.bottom, + 1 + ) + let horizontalPadding: CGFloat = availableSize.width >= 430 ? 28 : 20 + let artworkSize: CGFloat + if mode.isCompact { + let preferred: CGFloat = switch artworkPreference { + case .small: 126 + case .medium: 136 + case .large: 144 + } + artworkSize = min( + preferred, + max(112, min(availableSize.width * 0.38, usableHeight * 0.23)) + ) + } else { + let widthLimit = max(184, availableSize.width - horizontalPadding * 2 - 72) + let heightLimit = max(184, usableHeight * 0.325) + artworkSize = min(artworkPreference.pointSize, widthLimit, heightLimit) + } + return PlayerLayoutMetrics( + mode: mode, + artworkSize: artworkSize, + horizontalPadding: horizontalPadding, + sectionSpacing: mode.isCompact ? 10 : 14, + lyricHeight: mode.isCompact ? 112 : 150 + ) + } +} + +enum ConnectionStatusKind: Equatable { + case connected + case connecting + case reconnecting + case disconnected + case actionRequired +} + +struct ConnectionStatusPresentation: Equatable { + let kind: ConnectionStatusKind + let title: String + let detail: String + let opensDeviceDetail: Bool + + static func resolve(_ state: BLEConnectionPresentationState) -> Self { + switch state { + case .connected: + return ConnectionStatusPresentation( + kind: .connected, + title: "已连接", + detail: "Sony 已可控制", + opensDeviceDetail: true + ) + case .scanning, .connecting: + return ConnectionStatusPresentation( + kind: .connecting, + title: "正在连接", + detail: "正在同步播放状态、歌词和封面", + opensDeviceDetail: false + ) + case .reconnecting: + return ConnectionStatusPresentation( + kind: .reconnecting, + title: "正在重连", + detail: "保留上次播放内容,恢复后自动同步", + opensDeviceDetail: true + ) + case .unavailable(.poweredOff), .unavailable(.unauthorized), + .unavailable(.unsupported), .failed: + return ConnectionStatusPresentation( + kind: .actionRequired, + title: "需要处理", + detail: "检查蓝牙、权限和 Sony 端服务", + opensDeviceDetail: false + ) + case .unavailable, .disconnected: + return ConnectionStatusPresentation( + kind: .disconnected, + title: "已断开", + detail: "上次播放内容不是实时状态", + opensDeviceDetail: false + ) + } + } + + var color: Color { + switch kind { + case .connected: PlayerDesignTokens.healthy + case .connecting, .reconnecting: PlayerDesignTokens.warning + case .disconnected, .actionRequired: PlayerDesignTokens.disconnected + } + } + + var isBusy: Bool { + kind == .connecting || kind == .reconnecting + } +} + +struct ReconnectBannerPresentation: Equatable { + let title: String + let detail: String + let showsRetry: Bool + + static func resolve( + connection: BLEConnectionPresentationState, + hasSnapshot: Bool + ) -> Self? { + guard hasSnapshot, !connection.isConnected else { return nil } + let status = ConnectionStatusPresentation.resolve(connection) + return ReconnectBannerPresentation( + title: status.kind == .reconnecting ? "正在恢复 Sony 连接" : status.title, + detail: status.detail, + showsRetry: !status.isBusy + ) + } +} + +enum NowPlayingSnapshotPolicy { + static func hasDisplayableSnapshot( + title: String, + artist: String, + hasArtwork: Bool, + isRestoredSnapshot: Bool + ) -> Bool { + let normalized = [title, artist].map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + return isRestoredSnapshot || hasArtwork || normalized.contains { + !$0.isEmpty && $0 != "-" && $0 != "等待同步" + } + } +} + +struct FullLyricsFollowState: Equatable { + enum Mode: Equatable { + case following + case browsing + } + + private(set) var mode: Mode = .following + var showsReturnToCurrent: Bool { mode == .browsing } + + mutating func userDidBrowse() { + mode = .browsing + } + + mutating func returnToCurrent() { + mode = .following + } + + mutating func trackDidChange() { + mode = .following + } +} + +struct FullLyricsStoreHost: View { + let manager: BLETestManager + let onDismiss: () -> Void + let onShowDiagnostic: () -> Void + @ObservedObject private var preferences = PreferencesStore.shared + + var body: some View { + let metadata = manager.playbackStore.metadata + let timeline = manager.playbackStore.timeline + let document = manager.lyricsStore.document + let lines = document.isCurrent ? document.lines : [] + let rawPosition = timeline.isSeeking ? timeline.seekPositionMs : timeline.displayPositionMs + let position = manager.karaokePositionMs(rawPositionMs: rawPosition) + + FullLyricsView( + title: display(metadata.title, fallback: "等待同步"), + artist: display(metadata.artist, fallback: "等待同步"), + albumArtImage: manager.artworkStore.state.image, + lyrics: lines, + lyricsIdentity: "\(document.trackId)|\(lines.count)|\(lines.first?.timeMs ?? -1)|\(lines.last?.timeMs ?? -1)", + currentIndex: LyricTimelineHelper.currentIndex(lines: lines, positionMs: position) ?? -1, + positionMs: position, + translationState: document.translationState, + romanizationState: document.romanizationState, + isPlaying: timeline.isPlaying, + isConnected: manager.connectionStore.presentation.isConnected, + onDismiss: onDismiss, + onPrevious: manager.sendPrevious, + onPlayPause: manager.sendPlayPause, + onNext: manager.sendNext, + onSeekToLine: manager.seekToLyricLine, + showDiagnosticButton: preferences.appExperienceMode == .debug, + onShowDiagnostic: onShowDiagnostic + ) + } + + private func display(_ value: String, fallback: String) -> String { + value == "-" || value.isEmpty ? fallback : value + } +} + +struct ConnectionStatusBanner: View { + let presentation: ReconnectBannerPresentation + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: presentation.showsRetry ? "antenna.radiowaves.left.and.right.slash" : "arrow.triangle.2.circlepath") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(PlayerDesignTokens.warning) + .frame(width: 36, height: 36) + .background(PlayerDesignTokens.warning.opacity(0.12), in: Circle()) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(AppLocalization.string(presentation.title)) + .font(.subheadline.weight(.bold)) + .foregroundStyle(PlayerDesignTokens.primaryText) + Text(AppLocalization.string(presentation.detail)) + .font(.caption) + .foregroundStyle(PlayerDesignTokens.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + if !presentation.showsRetry { + ProgressView() + .tint(PlayerDesignTokens.warning) + .accessibilityLabel("正在重连") + } + } + if presentation.showsRetry { + Button(action: onRetry) { + Text(AppLocalization.string("立即重试连接")) + .font(.subheadline.weight(.bold)) + .frame(maxWidth: .infinity, minHeight: PlayerDesignTokens.minimumHitTarget) + .foregroundStyle(PlayerDesignTokens.warning) + .background( + PlayerDesignTokens.warning.opacity(0.12), + in: RoundedRectangle(cornerRadius: PlayerDesignTokens.smallRadius) + ) + } + .buttonStyle(.plain) + } + } + .padding(14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) + .overlay { + RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius) + .strokeBorder(.white.opacity(0.09), lineWidth: 1) + } + .accessibilityElement(children: .contain) + } +} + +struct DeviceDetailView: View { + @ObservedObject var manager: BLETestManager + let onShowAdvancedDiagnostics: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var confirmsForget = false + + var body: some View { + NavigationStack { + ZStack { + PlayerDesignTokens.canvas.ignoresSafeArea() + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + deviceHeader + metrics + actionCard + Button(role: .destructive) { + confirmsForget = true + } label: { + Label("忘记此设备", systemImage: "trash") + .font(.subheadline.weight(.bold)) + .frame(maxWidth: .infinity, minHeight: 48) + .foregroundStyle(PlayerDesignTokens.disconnected) + .background( + PlayerDesignTokens.disconnected.opacity(0.10), + in: RoundedRectangle(cornerRadius: PlayerDesignTokens.smallRadius) + ) + } + .buttonStyle(.plain) + } + .padding(20) + } + } + .navigationTitle("设备详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { dismiss() } + } + } + } + .preferredColorScheme(.dark) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .confirmationDialog( + "忘记此设备?", + isPresented: $confirmsForget, + titleVisibility: .visible + ) { + Button("忘记设备", role: .destructive) { + manager.forgetLastSonyDevice() + dismiss() + } + Button("取消", role: .cancel) {} + } message: { + Text("只会移除本机记住的 Sony 设备;不会清理歌词、封面或播放历史。") + } + } + + private var deviceHeader: some View { + HStack(spacing: 14) { + Image(systemName: "hifispeaker.2.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(status.color) + .frame(width: 54, height: 54) + .background(status.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 17)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(deviceName) + .font(.title3.bold()) + .foregroundStyle(PlayerDesignTokens.primaryText) + .lineLimit(2) + Text("\(status.title) · \(status.detail)") + .font(.caption) + .foregroundStyle(PlayerDesignTokens.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(PlayerDesignTokens.surface1, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) + } + + private var metrics: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + if manager.currentMtuBytesForPreferences > 0 { + metric("MTU", "\(manager.currentMtuBytesForPreferences)") + } + if manager.lyricClockBestRoundTripMs > 0 { + metric("时钟同步 RTT", "\(manager.lyricClockBestRoundTripMs) ms") + } + if manager.connectionHealthLastNotifyAgeMs >= 0 { + metric("最近同步", ageText(manager.connectionHealthLastNotifyAgeMs)) + } + metric("链路状态", healthText) + if manager.serverSessionId != "-" { + metric("协议", "V3") + } + } + } + + private var actionCard: some View { + VStack(spacing: 0) { + detailAction("重新同步当前播放", systemImage: "arrow.clockwise") { + manager.resyncCurrentPlaybackFromDevice() + } + Divider().overlay(.white.opacity(0.08)) + detailAction("重新连接", systemImage: "antenna.radiowaves.left.and.right") { + manager.forceReconnect() + } + Divider().overlay(.white.opacity(0.08)) + detailAction("打开高级诊断", systemImage: "waveform.path.ecg.rectangle") { + dismiss() + onShowAdvancedDiagnostics() + } + } + .background(PlayerDesignTokens.surface1, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) + .clipShape(RoundedRectangle(cornerRadius: PlayerDesignTokens.cardRadius)) + } + + private func detailAction( + _ title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(PlayerDesignTokens.primaryText) + .frame(maxWidth: .infinity, minHeight: 54, alignment: .leading) + .padding(.horizontal, 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func metric(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(PlayerDesignTokens.quietText) + Text(value) + .font(.subheadline.weight(.bold).monospacedDigit()) + .foregroundStyle(PlayerDesignTokens.primaryText) + .lineLimit(2) + .minimumScaleFactor(0.78) + } + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .padding(12) + .background(PlayerDesignTokens.surface2, in: RoundedRectangle(cornerRadius: PlayerDesignTokens.smallRadius)) + } + + private var status: ConnectionStatusPresentation { + ConnectionStatusPresentation.resolve(manager.connectionStore.presentation) + } + + private var deviceName: String { + let name = manager.connectedDeviceName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty || name == "-" ? "Sony PlayerAgent" : name + } + + private var healthText: String { + switch manager.connectionHealthState.lowercased() { + case "healthy": "健康" + case "suspect": "需要观察" + case "stale": "需要处理" + default: status.title + } + } + + private func ageText(_ milliseconds: Int64) -> String { + if milliseconds < 1_000 { return "刚刚" } + return "\(milliseconds / 1_000) 秒前" + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift index dce70c7..facc7a2 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift @@ -1,18 +1,59 @@ import Foundation import SwiftUI +enum PlaybackPerformanceMode: String, CaseIterable, Identifiable { + case automatic + case smooth + case powerSaving + + var id: String { rawValue } + + var title: String { + switch self { + case .automatic: return AppLocalization.string("自动") + case .smooth: return AppLocalization.string("流畅") + case .powerSaving: return AppLocalization.string("省电") + } + } + + var detail: String { + switch self { + case .automatic: + return AppLocalization.string("根据低电量模式和播放状态自动平衡实时性与耗电。") + case .smooth: + return AppLocalization.string("优先频谱、封面和灵动岛的响应速度。") + case .powerSaving: + return AppLocalization.string("降低动画和后台刷新频率,高清封面仍会延后加载。") + } + } + + var hqDelayMultiplier: Double { + switch self { + case .automatic: + return ProcessInfo.processInfo.isLowPowerModeEnabled ? 1.8 : 1.0 + case .smooth: + return 0.75 + case .powerSaving: + return 2.5 + } + } + + static let userDefaultsKey = "playbackPerformanceMode" + static let defaultMode: PlaybackPerformanceMode = .automatic +} + enum ArtworkDisplaySizeOption: Int, CaseIterable, Identifiable { - case small = 200 - case medium = 220 - case large = 260 + case small = 184 + case medium = 224 + case large = 272 var id: Int { rawValue } var title: String { switch self { - case .small: return "小" - case .medium: return "中" - case .large: return "大" + case .small: return AppLocalization.string("小") + case .medium: return AppLocalization.string("中") + case .large: return AppLocalization.string("大") } } @@ -28,10 +69,17 @@ final class PreferencesStore: ObservableObject { static let shared = PreferencesStore() static let autoReconnectEnabledKey = "autoReconnectEnabled" + static let automaticLyricSyncEnabledKey = "automaticLyricSyncEnabled" static let lyricOffsetMsKey = "lyricOffsetMs" + static let automaticLyricSyncMigrationV1Key = "automaticLyricSyncMigrationV1" static let artworkEnhancementEnabledKey = "artworkEnhancementEnabled" static let artworkEnhancementTargetPixelSizeKey = "artworkEnhancementTargetPixelSize" static let artworkEnhancementSharpnessKey = "artworkEnhancementSharpness" + static let forceProtocolV2Key = "forceProtocolV2" + + @Published var appLanguage: AppLanguage { + didSet { persistAppLanguage(oldValue: oldValue) } + } @Published var appExperienceMode: AppExperienceMode { didSet { persistAppExperienceMode(oldValue: oldValue) } @@ -45,6 +93,16 @@ final class PreferencesStore: ObservableObject { didSet { persistInt(lyricOffsetMs, oldValue: oldValue, key: Self.lyricOffsetMsKey) } } + @Published var automaticLyricSyncEnabled: Bool { + didSet { + persistBool( + automaticLyricSyncEnabled, + oldValue: oldValue, + key: Self.automaticLyricSyncEnabledKey + ) + } + } + @Published var lyricDisplayMode: LyricDisplayMode { didSet { persistLyricDisplayMode(oldValue: oldValue) } } @@ -61,10 +119,20 @@ final class PreferencesStore: ObservableObject { didSet { persistDynamicIslandStyle(oldValue: oldValue) } } + @Published var playbackPerformanceMode: PlaybackPerformanceMode { + didSet { persistPlaybackPerformanceMode(oldValue: oldValue) } + } + + @Published var forceProtocolV2: Bool { + didSet { persistBool(forceProtocolV2, oldValue: oldValue, key: Self.forceProtocolV2Key) } + } + private let defaults: UserDefaults private init(defaults: UserDefaults = .standard) { self.defaults = defaults + Self.migrateAutomaticLyricSyncIfNeeded(defaults: defaults) + appLanguage = Self.loadAppLanguage(defaults: defaults) appExperienceMode = Self.loadAppExperienceMode(defaults: defaults) autoReconnectEnabled = Self.loadBool( defaults: defaults, @@ -74,7 +142,12 @@ final class PreferencesStore: ObservableObject { lyricOffsetMs = Self.loadInt( defaults: defaults, key: Self.lyricOffsetMsKey, - defaultValue: 600 + defaultValue: 0 + ) + automaticLyricSyncEnabled = Self.loadBool( + defaults: defaults, + key: Self.automaticLyricSyncEnabledKey, + defaultValue: true ) lyricDisplayMode = Self.loadLyricDisplayMode(defaults: defaults) artworkEnhancementEnabled = Self.loadBool( @@ -84,10 +157,17 @@ final class PreferencesStore: ObservableObject { ) artworkDisplaySize = Self.loadArtworkDisplaySize(defaults: defaults) dynamicIslandStyle = Self.loadDynamicIslandStyle(defaults: defaults) + playbackPerformanceMode = Self.loadPlaybackPerformanceMode(defaults: defaults) + forceProtocolV2 = Self.loadBool( + defaults: defaults, + key: Self.forceProtocolV2Key, + defaultValue: false + ) logLoaded() } func load() { + appLanguage = Self.loadAppLanguage(defaults: defaults) appExperienceMode = Self.loadAppExperienceMode(defaults: defaults) autoReconnectEnabled = Self.loadBool( defaults: defaults, @@ -97,7 +177,12 @@ final class PreferencesStore: ObservableObject { lyricOffsetMs = Self.loadInt( defaults: defaults, key: Self.lyricOffsetMsKey, - defaultValue: 600 + defaultValue: 0 + ) + automaticLyricSyncEnabled = Self.loadBool( + defaults: defaults, + key: Self.automaticLyricSyncEnabledKey, + defaultValue: true ) lyricDisplayMode = Self.loadLyricDisplayMode(defaults: defaults) artworkEnhancementEnabled = Self.loadBool( @@ -107,17 +192,27 @@ final class PreferencesStore: ObservableObject { ) artworkDisplaySize = Self.loadArtworkDisplaySize(defaults: defaults) dynamicIslandStyle = Self.loadDynamicIslandStyle(defaults: defaults) + playbackPerformanceMode = Self.loadPlaybackPerformanceMode(defaults: defaults) + forceProtocolV2 = Self.loadBool( + defaults: defaults, + key: Self.forceProtocolV2Key, + defaultValue: false + ) logLoaded() } func resetToDefaults() { + appLanguage = .defaultLanguage appExperienceMode = .defaultMode autoReconnectEnabled = true - lyricOffsetMs = 600 + automaticLyricSyncEnabled = true + lyricOffsetMs = 0 lyricDisplayMode = .originalTranslation artworkEnhancementEnabled = true artworkDisplaySize = .defaultOption dynamicIslandStyle = .defaultStyle + playbackPerformanceMode = .defaultMode + forceProtocolV2 = false } private static func loadAppExperienceMode(defaults: UserDefaults) -> AppExperienceMode { @@ -125,6 +220,38 @@ final class PreferencesStore: ObservableObject { return raw.flatMap(AppExperienceMode.init(rawValue:)) ?? .defaultMode } + private static func loadAppLanguage(defaults: UserDefaults) -> AppLanguage { + let raw = defaults.string(forKey: AppLanguage.userDefaultsKey) + return raw.flatMap(AppLanguage.init(rawValue:)) ?? .defaultLanguage + } + + static func migratedLegacyLyricOffset( + storedOffset: Int?, + migrationCompleted: Bool + ) -> Int? { + guard !migrationCompleted else { return storedOffset } + if storedOffset == nil || storedOffset == 600 { + return 0 + } + return storedOffset + } + + private static func migrateAutomaticLyricSyncIfNeeded(defaults: UserDefaults) { + let completed = defaults.bool(forKey: automaticLyricSyncMigrationV1Key) + guard !completed else { return } + let storedOffset = (defaults.object(forKey: lyricOffsetMsKey) as? NSNumber)?.intValue + let migrated = migratedLegacyLyricOffset( + storedOffset: storedOffset, + migrationCompleted: completed + ) ?? 0 + defaults.set(migrated, forKey: lyricOffsetMsKey) + defaults.set(true, forKey: automaticLyricSyncEnabledKey) + defaults.set(true, forKey: automaticLyricSyncMigrationV1Key) + AppLogStore.shared.append( + "[Preferences] migrated automatic lyric sync offsetMs=\(migrated)" + ) + } + private static func loadLyricDisplayMode(defaults: UserDefaults) -> LyricDisplayMode { let raw = defaults.string(forKey: LyricDisplayMode.userDefaultsKey) return raw.flatMap(LyricDisplayMode.init(rawValue:)) ?? .originalTranslation @@ -132,7 +259,23 @@ final class PreferencesStore: ObservableObject { private static func loadArtworkDisplaySize(defaults: UserDefaults) -> ArtworkDisplaySizeOption { let value = defaults.integer(forKey: ArtworkDisplaySizeOption.userDefaultsKey) - return ArtworkDisplaySizeOption(rawValue: value) ?? .defaultOption + let option = migratedArtworkDisplaySize(storedValue: value) + if value > 0, value != option.rawValue { + defaults.set(option.rawValue, forKey: ArtworkDisplaySizeOption.userDefaultsKey) + } + return option + } + + static func migratedArtworkDisplaySize(storedValue: Int) -> ArtworkDisplaySizeOption { + if let current = ArtworkDisplaySizeOption(rawValue: storedValue) { + return current + } + switch storedValue { + case 200: return .small + case 220: return .medium + case 260: return .large + default: return .defaultOption + } } private static func loadDynamicIslandStyle(defaults: UserDefaults) -> DynamicIslandStyle { @@ -140,6 +283,11 @@ final class PreferencesStore: ObservableObject { return raw.flatMap(DynamicIslandStyle.init(rawValue:)) ?? .defaultStyle } + private static func loadPlaybackPerformanceMode(defaults: UserDefaults) -> PlaybackPerformanceMode { + let raw = defaults.string(forKey: PlaybackPerformanceMode.userDefaultsKey) + return raw.flatMap(PlaybackPerformanceMode.init(rawValue:)) ?? .defaultMode + } + private static func loadBool(defaults: UserDefaults, key: String, defaultValue: Bool) -> Bool { defaults.object(forKey: key) as? Bool ?? defaultValue } @@ -154,6 +302,15 @@ final class PreferencesStore: ObservableObject { logChanged(key: AppExperienceMode.userDefaultsKey, value: appExperienceMode.rawValue) } + private func persistAppLanguage(oldValue: AppLanguage) { + guard appLanguage != oldValue else { return } + defaults.set(appLanguage.rawValue, forKey: AppLanguage.userDefaultsKey) + UserDefaults( + suiteName: LiveActivitySharedConstants.appGroupIdentifier + )?.set(appLanguage.rawValue, forKey: AppLanguage.userDefaultsKey) + logChanged(key: AppLanguage.userDefaultsKey, value: appLanguage.rawValue) + } + private func persistLyricDisplayMode(oldValue: LyricDisplayMode) { guard lyricDisplayMode != oldValue else { return } defaults.set(lyricDisplayMode.rawValue, forKey: LyricDisplayMode.userDefaultsKey) @@ -172,6 +329,18 @@ final class PreferencesStore: ObservableObject { logChanged(key: DynamicIslandStyle.userDefaultsKey, value: dynamicIslandStyle.rawValue) } + private func persistPlaybackPerformanceMode(oldValue: PlaybackPerformanceMode) { + guard playbackPerformanceMode != oldValue else { return } + defaults.set( + playbackPerformanceMode.rawValue, + forKey: PlaybackPerformanceMode.userDefaultsKey + ) + logChanged( + key: PlaybackPerformanceMode.userDefaultsKey, + value: playbackPerformanceMode.rawValue + ) + } + private func persistBool(_ value: Bool, oldValue: Bool, key: String) { guard value != oldValue else { return } defaults.set(value, forKey: key) @@ -186,12 +355,17 @@ final class PreferencesStore: ObservableObject { private func logLoaded() { AppLogStore.shared.append( - "[Preferences] loaded mode=\(appExperienceMode.rawValue) " + - "autoReconnect=\(autoReconnectEnabled) lyricOffsetMs=\(lyricOffsetMs) " + + "[Preferences] loaded language=\(appLanguage.rawValue) " + + "mode=\(appExperienceMode.rawValue) " + + "autoReconnect=\(autoReconnectEnabled) " + + "automaticLyricSync=\(automaticLyricSyncEnabled) " + + "lyricOffsetMs=\(lyricOffsetMs) " + "lyricDisplayMode=\(lyricDisplayMode.rawValue) " + "artworkEnhancement=\(artworkEnhancementEnabled) " + "artworkDisplaySize=\(artworkDisplaySize.rawValue) " + - "dynamicIslandStyle=\(dynamicIslandStyle.rawValue)" + "dynamicIslandStyle=\(dynamicIslandStyle.rawValue) " + + "performanceMode=\(playbackPerformanceMode.rawValue) " + + "forceProtocolV2=\(forceProtocolV2)" ) } diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift index 1084b8f..45a6a84 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift @@ -25,11 +25,15 @@ struct PreferencesView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 14) { - modeSection - connectionSection + currentDeviceSection + playerDisplaySection lyricSection artworkSection - cacheAndLogSection + connectionAndSystemSection + playbackHistorySection + if preferences.appExperienceMode == .debug { + advancedSection + } aboutSection } .padding(.horizontal, 18) @@ -54,8 +58,36 @@ struct PreferencesView: View { .presentationDragIndicator(.visible) } - private var modeSection: some View { - PreferencesCard(title: "使用模式", systemImage: "person.crop.circle") { + private var currentDeviceSection: some View { + PreferencesCard(title: "当前设备", systemImage: "hifispeaker.2") { + preferencesRow( + "设备", + bleManager.connectedDeviceName == "-" ? "Sony" : bleManager.connectedDeviceName + ) + preferencesRow("状态", displayConnectionState, valueColor: connectionStatusColor) + if bleManager.currentMtuBytesForPreferences > 0 { + preferencesRow("MTU", "\(bleManager.currentMtuBytesForPreferences)") + } + actionButton("重新连接", "antenna.radiowaves.left.and.right") { + bleManager.forceReconnect() + actionStatus = AppLocalization.string("已请求重新连接") + } + } + } + + private var playerDisplaySection: some View { + PreferencesCard(title: "播放器显示", systemImage: "rectangle.inset.filled.and.person.filled") { + VStack(alignment: .leading, spacing: 8) { + Text("界面语言") + .font(.subheadline.weight(.semibold)) + Picker("界面语言", selection: appLanguageBinding) { + ForEach(AppLanguage.allCases) { language in + Text(language.title).tag(language) + } + } + .pickerStyle(.segmented) + } + Picker("使用模式", selection: appModeBinding) { ForEach(AppExperienceMode.allCases) { mode in Text(mode.title).tag(mode) @@ -63,7 +95,22 @@ struct PreferencesView: View { } .pickerStyle(.segmented) - Text("日常模式保留播放器核心功能;调试模式显示诊断、日志和高级入口。") + Text("日常模式保留核心功能;调试模式显示诊断、日志和协议入口。") + .font(.caption) + .foregroundStyle(.white.opacity(0.58)) + + VStack(alignment: .leading, spacing: 8) { + Text("性能模式") + .font(.subheadline.weight(.semibold)) + Picker("性能模式", selection: performanceModeBinding) { + ForEach(PlaybackPerformanceMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + } + + Text(preferences.playbackPerformanceMode.detail) .font(.caption) .foregroundStyle(.white.opacity(0.58)) @@ -78,26 +125,18 @@ struct PreferencesView: View { .pickerStyle(.segmented) } - Text("默认使用歌词优先样式;其它样式先作为本地设置保留,方便后续扩展。") + Text("默认展示封面、标题与歌手;歌词优先突出当前歌词;节奏优先展示播放状态与节奏条。") .font(.caption) .foregroundStyle(.white.opacity(0.58)) } } - private var connectionSection: some View { - PreferencesCard(title: "连接", systemImage: "antenna.radiowaves.left.and.right") { + private var connectionAndSystemSection: some View { + PreferencesCard(title: "连接与系统", systemImage: "antenna.radiowaves.left.and.right") { Toggle("自动重连", isOn: autoReconnectBinding) - .tint(.green) + .tint(PlayerDesignTokens.stableAccent) preferencesRow("当前连接状态", displayConnectionState) - preferencesRow("Health 状态", bleManager.connectionHealthState) - preferencesRow( - "MTU", - bleManager.currentMtuBytesForPreferences > 0 - ? "\(bleManager.currentMtuBytesForPreferences)" - : "-" - ) - preferencesRow("最近重连原因", bleManager.connectionHealthLastHardReconnectReason) Text("关闭自动重连后,手动扫描 / 重连仍然可用。") .font(.caption) @@ -108,13 +147,20 @@ struct PreferencesView: View { private var lyricSection: some View { PreferencesCard(title: "歌词", systemImage: "text.quote") { VStack(alignment: .leading, spacing: 10) { + Toggle("自动同步歌词时间", isOn: automaticLyricSyncBinding) + .tint(PlayerDesignTokens.stableAccent) + + Text("自动补偿 Sony 与 iPhone 时钟差及蓝牙传输延迟") + .font(.caption) + .foregroundStyle(.white.opacity(0.58)) + HStack { - Text("歌词偏移校准") + Text("人工微调") .font(.subheadline.weight(.semibold)) Spacer() Text(offsetLabel(Int64(preferences.lyricOffsetMs))) .font(.caption.monospacedDigit().weight(.bold)) - .foregroundStyle(.green) + .foregroundStyle(PlayerDesignTokens.stableAccent) } Slider( @@ -122,7 +168,7 @@ struct PreferencesView: View { in: -2_000...2_000, step: 100 ) - .tint(.green) + .tint(PlayerDesignTokens.stableAccent) } Picker("歌词显示模式", selection: lyricDisplayModeBinding) { @@ -138,7 +184,7 @@ struct PreferencesView: View { let enhancement = bleManager.artworkEnhancementStatus return PreferencesCard(title: "封面", systemImage: "photo.on.rectangle") { Toggle("封面增强", isOn: artworkEnhancementBinding) - .tint(.green) + .tint(PlayerDesignTokens.stableAccent) VStack(alignment: .leading, spacing: 8) { Text("封面显示尺寸") @@ -157,16 +203,40 @@ struct PreferencesView: View { } } - private var cacheAndLogSection: some View { - PreferencesCard(title: "缓存与日志", systemImage: "externaldrive") { + private var playbackHistorySection: some View { + PreferencesCard(title: "播放历史", systemImage: "clock.arrow.circlepath") { + preferencesRow( + "本地记录", + "\(bleManager.playbackHistorySessions.count) 条" + ) + actionButton("同步播放历史", "arrow.clockwise") { + guard !bleManager.isPlaybackHistorySyncing else { return } + bleManager.syncPlaybackHistory() + actionStatus = AppLocalization.string("已请求同步播放历史") + } + actionButton("清理增强封面缓存", "photo.badge.minus") { + bleManager.clearEnhancedArtworkCache() + actionStatus = AppLocalization.string("已请求清理增强封面缓存") + } + } + } + + private var advancedSection: some View { + PreferencesCard(title: "高级与诊断", systemImage: "waveform.path.ecg.rectangle") { + Toggle("强制使用 V2 协议", isOn: forceProtocolV2Binding) + .tint(PlayerDesignTokens.warning) + + Text("仅用于跨端 A/B 与紧急回退;切换后请重新连接。") + .font(.caption) + .foregroundStyle(.white.opacity(0.58)) + + preferencesRow("Health 状态", bleManager.connectionHealthState) + preferencesRow("最近重连原因", bleManager.connectionHealthLastHardReconnectReason) + VStack(spacing: 10) { - actionButton("清理增强封面缓存", "trash") { - bleManager.clearEnhancedArtworkCache() - actionStatus = "已请求清理增强封面缓存" - } actionButton("复制最近日志路径", "doc.on.clipboard") { UIPasteboard.general.string = AppLogStore.shared.currentLogURL.path - actionStatus = "已复制日志路径" + actionStatus = AppLocalization.string("已复制日志路径") } if AppLogStore.shared.currentLogFileExists() { ShareLink(item: AppLogStore.shared.currentLogURL) { @@ -181,7 +251,7 @@ struct PreferencesView: View { nowPlaying: bleManager.makeNowPlayingDiagnosticSnapshot() ) UIPasteboard.general.string = snapshot.copyText - actionStatus = "已复制诊断摘要" + actionStatus = AppLocalization.string("已复制诊断摘要") } } @@ -194,19 +264,19 @@ struct PreferencesView: View { if !actionStatus.isEmpty { Text(actionStatus) .font(.caption.weight(.medium)) - .foregroundStyle(.green.opacity(0.9)) + .foregroundStyle(PlayerDesignTokens.stableAccent.opacity(0.9)) } } } private var aboutSection: some View { PreferencesCard(title: "关于", systemImage: "info.circle") { - preferencesRow("App", "Sony Music BLE Controller") + preferencesRow("应用", "Sony 音乐控制器") preferencesRow("版本", appVersion) - preferencesRow("Build", buildVersion) + preferencesRow("构建版本", buildVersion) preferencesRow("签名有效期", signingProfileExpireText, valueColor: signingProfileStatusColor) preferencesRow("剩余时间", signingProfileRemainingText, valueColor: signingProfileStatusColor) - preferencesRow("签名 Team", signingProfile?.teamIdentifier ?? "-") + preferencesRow("签名团队", signingProfile?.teamIdentifier ?? "-") preferencesRow("当前模式", preferences.appExperienceMode.title) preferencesRow("连接设备", bleManager.connectedDeviceName == "-" ? "Sony" : bleManager.connectedDeviceName) } @@ -219,6 +289,13 @@ struct PreferencesView: View { ) } + private var appLanguageBinding: Binding { + Binding( + get: { preferences.appLanguage }, + set: { preferences.appLanguage = $0 } + ) + } + private var autoReconnectBinding: Binding { Binding( get: { preferences.autoReconnectEnabled }, @@ -226,6 +303,20 @@ struct PreferencesView: View { ) } + private var performanceModeBinding: Binding { + Binding( + get: { preferences.playbackPerformanceMode }, + set: { preferences.playbackPerformanceMode = $0 } + ) + } + + private var forceProtocolV2Binding: Binding { + Binding( + get: { preferences.forceProtocolV2 }, + set: { preferences.forceProtocolV2 = $0 } + ) + } + private var karaokeOffsetBinding: Binding { Binding( get: { Double(preferences.lyricOffsetMs) }, @@ -233,6 +324,13 @@ struct PreferencesView: View { ) } + private var automaticLyricSyncBinding: Binding { + Binding( + get: { preferences.automaticLyricSyncEnabled }, + set: { bleManager.setAutomaticLyricSyncEnabled($0) } + ) + } + private var lyricDisplayModeBinding: Binding { Binding( get: { preferences.lyricDisplayMode }, @@ -266,13 +364,17 @@ struct PreferencesView: View { private var displayConnectionState: String { switch bleManager.connectionDisplayState { - case "connected": return "已连接" - case "reconnecting": return "正在重连" - case "disconnected": return "未连接" + case "connected": return AppLocalization.string("已连接") + case "reconnecting": return AppLocalization.string("正在重连") + case "disconnected": return AppLocalization.string("未连接") default: return bleManager.connectionDisplayState } } + private var connectionStatusColor: Color { + ConnectionStatusPresentation.resolve(bleManager.connectionStore.presentation).color + } + private var appVersion: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-" } @@ -331,7 +433,7 @@ struct PreferencesView: View { valueColor: Color = .white.opacity(0.82) ) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(title) + Text(AppLocalization.string(title)) .font(.caption.weight(.medium)) .foregroundStyle(.white.opacity(0.52)) .frame(width: 96, alignment: .leading) @@ -355,7 +457,7 @@ struct PreferencesView: View { } private func settingsActionLabel(_ title: String, _ systemImage: String) -> some View { - Label(title, systemImage: systemImage) + Label(AppLocalization.string(title), systemImage: systemImage) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) @@ -417,7 +519,7 @@ private struct PreferencesCard: View { var body: some View { VStack(alignment: .leading, spacing: 13) { - Label(title, systemImage: systemImage) + Label(AppLocalization.string(title), systemImage: systemImage) .font(.headline.weight(.bold)) content diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreviousLiveActivityIntent.swift b/IOSBleFeasibility/IOSBleFeasibility/PreviousLiveActivityIntent.swift index 4155d78..63f1830 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreviousLiveActivityIntent.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreviousLiveActivityIntent.swift @@ -8,7 +8,7 @@ struct PreviousLiveActivityIntent: LiveActivityIntent { @MainActor func perform() async throws -> some IntentResult { - LiveActivityCommandBridge.shared.performIntent(command: .previous) + _ = LiveActivityCommandBridge.shared.performIntent(command: .previous) return .result() } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/SelfHealingEngine.swift b/IOSBleFeasibility/IOSBleFeasibility/SelfHealingEngine.swift index a4c6043..97b93a6 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/SelfHealingEngine.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/SelfHealingEngine.swift @@ -8,10 +8,10 @@ enum RecoveryDomain: String, CaseIterable { var title: String { switch self { - case .albumArt: return "AlbumArt" - case .lyrics: return "Lyrics" - case .ble: return "BLE" - case .health: return "Health" + case .albumArt: return "封面" + case .lyrics: return "歌词" + case .ble: return "蓝牙" + case .health: return "连接健康" } } } @@ -27,13 +27,13 @@ enum RecoveryStage: String { var title: String { switch self { - case .idle: return "Idle" - case .detect: return "Detect" - case .recover: return "Recover" - case .verify: return "Verify" - case .success: return "Success" - case .fail: return "Fail" - case .diagnostics: return "Diagnostics" + case .idle: return "空闲" + case .detect: return "检测" + case .recover: return "恢复" + case .verify: return "验证" + case .success: return "成功" + case .fail: return "失败" + case .diagnostics: return "诊断" } } } @@ -166,7 +166,7 @@ struct SelfHealingSnapshot: Equatable { var summaryText: String { let active = activeReports guard !active.isEmpty else { - return "Self-Healing Engine 已就绪,当前没有需要恢复的异常。" + return "自愈引擎已就绪,当前没有需要恢复的异常。" } return active.map { "\($0.domain.title): \($0.summary)" }.joined(separator: "\n") } diff --git a/IOSBleFeasibility/IOSBleFeasibility/SonyMusicActivityAttributes.swift b/IOSBleFeasibility/IOSBleFeasibility/SonyMusicActivityAttributes.swift index 096f77f..b70573d 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/SonyMusicActivityAttributes.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/SonyMusicActivityAttributes.swift @@ -1,6 +1,39 @@ import ActivityKit import Foundation +enum AppLanguage: String, CaseIterable, Identifiable { + case simplifiedChinese = "zh-Hans" + case english = "en" + + var id: String { rawValue } + var locale: Locale { Locale(identifier: rawValue) } + var title: String { + switch self { + case .simplifiedChinese: return "简体中文" + case .english: return "English" + } + } + + static let userDefaultsKey = "appLanguage" + static let defaultLanguage: AppLanguage = .simplifiedChinese +} + +enum AppLocalization { + static func string(_ key: String) -> String { + let groupDefaults = UserDefaults( + suiteName: "group.com.sqz.IOSBleFeasibility.LiveActivity" + ) + let rawLanguage = UserDefaults.standard.string(forKey: AppLanguage.userDefaultsKey) ?? + groupDefaults?.string(forKey: AppLanguage.userDefaultsKey) + let language = rawLanguage.flatMap(AppLanguage.init(rawValue:)) ?? .defaultLanguage + guard let path = Bundle.main.path(forResource: language.rawValue, ofType: "lproj"), + let localizedBundle = Bundle(path: path) else { + return key + } + return localizedBundle.localizedString(forKey: key, value: key, table: nil) + } +} + enum IslandState: String, Codable, Hashable { case playing case paused @@ -29,11 +62,11 @@ enum DynamicIslandStyle: String, Codable, CaseIterable, Identifiable { var title: String { switch self { case .compactDefault: - return "默认" + return AppLocalization.string("默认") case .lyricFocused: - return "歌词优先" + return AppLocalization.string("歌词优先") case .waveformFocused: - return "节奏优先" + return AppLocalization.string("节奏优先") } } diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift new file mode 100644 index 0000000..47705e6 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -0,0 +1,2199 @@ +import Combine +import SwiftUI +import UIKit +import XCTest +@testable import sonyMusic + +final class PerformanceStabilityTests: XCTestCase { + @MainActor + func testPrimaryIOSSurfacesRenderRepresentativeStates() { + let manager = BLETestManager() + let preferences = PreferencesStore.shared + let originalExperienceMode = preferences.appExperienceMode + let originalLyricMode = preferences.lyricDisplayMode + defer { + preferences.appExperienceMode = originalExperienceMode + preferences.lyricDisplayMode = originalLyricMode + } + + preferences.appExperienceMode = .debug + + assertViewRenders( + AnyView(ContentView()), + named: "content-view-regular", + width: 390, + height: 844 + ) + assertViewRenders( + AnyView(ContentView().environment(\.dynamicTypeSize, .accessibility3)), + named: "content-view-accessibility-compact", + width: 375, + height: 667 + ) + + let lyrics = [ + LyricLine( + index: 0, + timeMs: 0, + durationMs: 2_000, + text: "第一句歌词", + translation: "First lyric line", + romanization: "di yi ju ge ci", + words: [ + LyricWord(id: 0, startMs: 0, durationMs: 1_000, text: "第一句"), + LyricWord(id: 1, startMs: 1_000, durationMs: 1_000, text: "歌词") + ] + ), + LyricLine( + index: 1, + timeMs: 2_000, + durationMs: 2_000, + text: "第二句歌词", + translation: nil, + romanization: nil, + words: [] + ) + ] + let stats = makePlaybackStatsFixture() + + let surfaces: [(String, AnyView)] = [ + ("preferences", AnyView(PreferencesView(bleManager: manager, onDismiss: {}))), + ("debug", AnyView(DebugToolsView(bleManager: manager))), + ("now-playing-diagnostic", AnyView(NowPlayingDiagnosticView(bleManager: manager, onDismiss: {}))), + ("system-health", AnyView(SystemHealthOverviewView(bleManager: manager, onDismiss: {}))), + ("lyric-diagnostic", AnyView(LyricDiagnosticView(bleManager: manager, onDismiss: {}))), + ("history-empty", AnyView(PlaybackHistoryView(bleManager: manager))), + ("history-stats", AnyView(PlaybackStatsView(stats: stats))) + ] + + for (name, view) in surfaces { + assertViewRenders(view, named: name, height: 844) + } + + for mode in LyricDisplayMode.allCases { + preferences.lyricDisplayMode = mode + assertViewRenders( + AnyView( + FullLyricsView( + title: "测试歌曲", + artist: "测试歌手", + albumArtImage: nil, + lyrics: lyrics, + lyricsIdentity: "test|2|0|2000", + currentIndex: 0, + positionMs: 750, + translationState: .ready, + romanizationState: .unavailable, + isPlaying: true, + isConnected: true, + onDismiss: {}, + onPrevious: {}, + onPlayPause: {}, + onNext: {}, + onSeekToLine: { _ in }, + onShowDiagnostic: {} + ) + ), + named: "full-lyrics-\(mode.rawValue)", + height: 844 + ) + } + + assertViewRenders( + AnyView( + FullLyricsView( + title: "测试歌曲", + artist: "测试歌手", + albumArtImage: nil, + lyrics: [], + lyricsIdentity: "empty|0|-1|-1", + currentIndex: -1, + positionMs: 0, + translationState: .failed(reason: "测试失败"), + romanizationState: .loading, + isPlaying: false, + isConnected: false, + onDismiss: {}, + onPrevious: {}, + onPlayPause: {}, + onNext: {}, + onSeekToLine: { _ in }, + onShowDiagnostic: {} + ) + ), + named: "full-lyrics-empty", + height: 667 + ) + } + + func testLyricDiagnosticAndMediaLoadingPresentationBranches() { + let statuses = [ + "loaded", + "loading", + "waiting qqmusic cache", + "retry_pending", + "no_safe_candidate", + "no_lyrics_final", + "maintenance_busy", + "error", + "custom" + ] + let reasons = [ + "waiting qqmusic cache", + "fuzzy index warming", + "cooldown retry pending", + "no safe qrc candidate", + "metadata 演唱 mismatch", + "maintenance busy", + "no parsed lyric", + "loading", + "" + ] + let suggestions = [ + "open_qqmusic_lyrics", + "retry_later", + "refresh_current_lyric", + "no_safe_candidate", + "maintenance_busy", + "loaded", + "custom" + ] + + for status in statuses { + let diagnostic = LyricDiagnostic.lightweight( + trackId: "track-\(status)", + title: "Song", + artist: "Artist", + status: status, + reason: "", + suggestion: "custom" + ) + XCTAssertFalse(diagnostic.statusTitle.isEmpty) + } + for reason in reasons { + let diagnostic = LyricDiagnostic.lightweight( + trackId: "track", + title: "Song", + artist: "Artist", + status: "custom", + reason: reason, + suggestion: "custom" + ) + XCTAssertFalse(diagnostic.humanReadableReason.isEmpty) + } + for suggestion in suggestions { + let diagnostic = LyricDiagnostic.lightweight( + trackId: "track", + title: "Song", + artist: "Artist", + status: "loaded", + reason: "loaded", + suggestion: suggestion + ) + XCTAssertFalse(diagnostic.suggestionText.isEmpty) + } + + let lyricStages: [LyricLoadingStage] = [ + .idle, + .waitingQqQrc, + .windowReady(lineCount: 8), + .fullLyrics(received: 2, expected: 0), + .ready(lineCount: 42), + .failed(reason: ""), + .failed(reason: String(repeating: "x", count: 40)) + ] + let artworkStages: [ArtworkLoadingStage] = [ + .idle, + .preview(received: 0, expected: 0), + .preview(received: 2, expected: 4), + .previewReady, + .hq(received: 0, expected: 0), + .hq(received: 3, expected: 5), + .hqReady, + .failed(reason: ""), + .failed(reason: String(repeating: "y", count: 40)) + ] + XCTAssertEqual(lyricStages.filter(\.isFailure).count, 2) + XCTAssertEqual(artworkStages.filter(\.isFailure).count, 2) + XCTAssertTrue(lyricStages.allSatisfy { !$0.title.isEmpty }) + XCTAssertTrue(artworkStages.allSatisfy { !$0.title.isEmpty }) + XCTAssertEqual(MediaLoadingState(), MediaLoadingState(lyric: .idle, artwork: .idle)) + } + + func testSelfHealingCoversHealthyRecoveringAndDiagnosticStates() { + let engine = SelfHealingEngine.shared + var loaded = LyricDiagnostic.lightweight( + trackId: "healthy", + title: "Song", + artist: "Artist", + status: "loaded", + reason: "", + suggestion: "loaded" + ) + loaded.lines = 12 + loaded.recoveryState = "idle" + + let healthy = engine.evaluate( + trackId: "healthy", + title: "Healthy", + connection: makeConnectionSnapshot(), + artwork: makeAlbumArtSnapshot(quality: .hq, transferState: "idle"), + lyric: loaded, + currentLyric: "line", + fullLyricsLineCount: 12, + isFullLyricsCurrent: true + ) + XCTAssertTrue(healthy.activeReports.isEmpty) + XCTAssertEqual(healthy.overallStatus, "状态正常") + XCTAssertFalse(healthy.summaryText.isEmpty) + + var waiting = LyricDiagnostic.lightweight( + trackId: "recovering", + title: "Song", + artist: "Artist", + status: "waiting_qqmusic_cache", + reason: "waiting qqmusic lyric cache", + suggestion: "open_qqmusic_lyrics" + ) + waiting.waitingQqMusicCache = true + let recovering = engine.evaluate( + trackId: "recovering", + title: "Recovering", + connection: makeConnectionSnapshot( + displayState: "reconnecting", + healthState: "stale", + autoReconnectState: "scanning", + ready: false, + notifyAgeMs: 9_000 + ), + artwork: makeAlbumArtSnapshot( + quality: .placeholder, + transferState: "timeout", + failure: "chunk timeout" + ), + lyric: waiting, + currentLyric: "", + fullLyricsLineCount: 0, + isFullLyricsCurrent: false + ) + XCTAssertEqual(recovering.activeReports.count, 4) + XCTAssertEqual(recovering.overallSeverity, .working) + + var noLyrics = LyricDiagnostic.lightweight( + trackId: "diagnostic", + title: "Song", + artist: "Artist", + status: "no_safe_candidate", + reason: "no safe qrc candidate", + suggestion: "no_safe_candidate" + ) + noLyrics.recoveryState = "idle" + let diagnostic = engine.evaluate( + trackId: "diagnostic", + title: "Diagnostic", + connection: makeConnectionSnapshot( + displayState: "disconnected", + healthState: "disconnected", + autoReconnectState: "idle", + ready: false + ), + artwork: makeAlbumArtSnapshot( + quality: .placeholder, + transferState: "failed", + failure: "source unavailable" + ), + lyric: noLyrics, + currentLyric: "", + fullLyricsLineCount: 0, + isFullLyricsCurrent: false + ) + XCTAssertEqual(diagnostic.overallSeverity, .warning) + XCTAssertTrue(diagnostic.summaryText.contains("歌词")) + + var loading = noLyrics + loading.status = "loading" + loading.recoveryState = "retrying" + let probing = engine.evaluate( + trackId: "probing", + title: "Probing", + connection: makeConnectionSnapshot( + healthState: "suspect", + autoReconnectState: "idle", + ready: true, + notifyAgeMs: 4_000 + ), + artwork: makeAlbumArtSnapshot( + quality: .preview, + transferState: "receiving" + ), + lyric: loading, + currentLyric: "", + fullLyricsLineCount: 0, + isFullLyricsCurrent: false + ) + XCTAssertTrue(probing.activeReports.count >= 3) + + var metrics = RecoveryMetrics() + for stage in [RecoveryStage.detect, .recover, .verify, .success, .fail, .diagnostics, .idle] { + metrics.record(stage: stage) + } + XCTAssertEqual(metrics.detectCount, 1) + XCTAssertEqual(metrics.recoverCount, 1) + XCTAssertEqual(metrics.verifyCount, 1) + XCTAssertEqual(metrics.successCount, 1) + XCTAssertEqual(metrics.failCount, 1) + XCTAssertEqual(metrics.diagnosticsCount, 1) + XCTAssertEqual(RecoveryDomain.allCases.map(\.title).count, 4) + } + + func testNowPlayingAndSystemHealthSnapshotsCoverOperationalBranches() { + var loaded = LyricDiagnostic.lightweight( + trackId: "track", + title: "Song", + artist: "Artist", + status: "loaded", + reason: "", + suggestion: "loaded" + ) + loaded.lines = 20 + loaded.recoveryState = "idle" + + let healthyHealing = SelfHealingEngine.shared.evaluate( + trackId: "snapshot-healthy", + title: "Healthy", + connection: makeConnectionSnapshot(), + artwork: makeAlbumArtSnapshot(quality: .hq, transferState: "idle"), + lyric: loaded, + currentLyric: "line", + fullLyricsLineCount: 20, + isFullLyricsCurrent: true + ) + let healthy = makeNowPlayingSnapshot( + lyric: loaded, + connection: makeConnectionSnapshot(), + artworkQuality: "hq", + transferState: "idle", + hqUnavailableReason: "-", + selfHealing: healthyHealing + ) + XCTAssertEqual(healthy.recentIssues, ["状态正常"]) + XCTAssertTrue(healthy.canRequestHqArtwork) + XCTAssertTrue(healthy.canForceReconnect) + XCTAssertTrue(healthy.quickSnapshotText.contains("trackId=track")) + XCTAssertTrue(healthy.diagnosticText.contains("Now Playing Diagnostics")) + XCTAssertEqual(NowPlayingDiagnosticSnapshot.formatDuration(0), "00:00") + XCTAssertEqual(NowPlayingDiagnosticSnapshot.formatDuration(125_000), "02:05") + XCTAssertEqual(NowPlayingDiagnosticSnapshot.optionalDate(nil), "-") + + let healthySystem = SystemHealthSnapshot(nowPlaying: healthy) + XCTAssertEqual(healthySystem.overallStatus, "系统正常") + XCTAssertEqual(healthySystem.recommendation, "当前状态正常。") + XCTAssertTrue(healthySystem.copyText.contains("系统健康总览")) + + var waiting = loaded + waiting.status = "waiting_qqmusic_cache" + waiting.waitingQqMusicCache = true + waiting.suggestion = "open_qqmusic_lyrics" + waiting.recoveryState = "recovering" + let recoveringConnection = makeConnectionSnapshot( + displayState: "reconnecting", + healthState: "stale", + autoReconnectState: "connecting", + ready: false, + notifyAgeMs: 12_000 + ) + let recoveringHealing = SelfHealingEngine.shared.evaluate( + trackId: "snapshot-recovering", + title: "Recovering", + connection: recoveringConnection, + artwork: makeAlbumArtSnapshot( + quality: .placeholder, + transferState: "timeout", + failure: "timeout" + ), + lyric: waiting, + currentLyric: "", + fullLyricsLineCount: 0, + isFullLyricsCurrent: false + ) + let recovering = makeNowPlayingSnapshot( + lyric: waiting, + connection: recoveringConnection, + artworkQuality: "placeholder", + transferState: "timeout", + transferFailure: "timeout", + hqUnavailableReason: "too large", + selfHealing: recoveringHealing + ) + XCTAssertFalse(recovering.recentIssues.isEmpty) + XCTAssertFalse(recovering.canRequestHqArtwork) + XCTAssertTrue(recovering.canForceReconnect) + let recoveringSystem = SystemHealthSnapshot(nowPlaying: recovering) + XCTAssertEqual(recoveringSystem.recommendation, "先恢复 Sony 连接。") + XCTAssertEqual(recoveringSystem.overallLevel, .warning) + + var maintenance = waiting + maintenance.status = "maintenance_busy" + maintenance.maintenanceBusy = true + maintenance.waitingQqMusicCache = false + let preview = makeNowPlayingSnapshot( + lyric: maintenance, + connection: makeConnectionSnapshot(healthState: "suspect", notifyAgeMs: 6_000), + artworkQuality: "preview", + transferState: "idle", + hqUnavailableReason: "source unavailable", + selfHealing: recoveringHealing + ) + XCTAssertTrue(preview.recentIssues.contains { $0.contains("封面") }) + let previewSystem = SystemHealthSnapshot(nowPlaying: preview) + XCTAssertFalse(previewSystem.recommendation.isEmpty) + + for level in [SystemHealthLevel.ok, .working, .warning, .critical, .unknown] { + XCTAssertFalse(level.badgeTitle.isEmpty) + } + } + + func testFullLyricsCacheStoreSaveLoadRemoveAndPrune() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FullLyricsCacheStore( + directoryURL: directory, + maximumEntryCount: 2, + maximumDiskBytes: 64 * 1024 + ) + let now = Date() + for index in 1...3 { + store.save(makeFullLyricsCacheEntry(trackId: "track-\(index)", savedAt: now)) + } + store.save(makeFullLyricsCacheEntry(trackId: "", savedAt: now)) + + _ = await loadLyricsCache( + store, + trackId: "drain", + title: "Song", + artist: "Artist", + now: now + ) + let files = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + XCTAssertLessThanOrEqual(files.count, 2) + + let diskStore = FullLyricsCacheStore(directoryURL: directory) + let loaded = await loadLyricsCache( + diskStore, + trackId: "track-3", + title: " song ", + artist: "ARTIST", + now: now + ) + XCTAssertEqual(loaded?.trackId, "track-3") + let emptyTrack = await loadLyricsCache( + diskStore, + trackId: "", + title: "Song", + artist: "Artist", + now: now + ) + XCTAssertNil(emptyTrack) + diskStore.remove(trackId: "") + diskStore.remove(trackId: "track-3") + _ = await loadLyricsCache( + diskStore, + trackId: "drain-2", + title: "Song", + artist: "Artist", + now: now + ) + let removed = await loadLyricsCache( + FullLyricsCacheStore(directoryURL: directory), + trackId: "track-3", + title: "Song", + artist: "Artist", + now: now + ) + XCTAssertNil(removed) + } + + @MainActor + func testLiveActivityArtworkThumbnailPipelineCompletes() async { + let image = UIGraphicsImageRenderer(size: CGSize(width: 160, height: 100)).image { context in + UIColor.systemPurple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 160, height: 100)) + } + let result = await withCheckedContinuation { continuation in + LiveActivityArtworkStore.shared.writeThumbnail( + image: image, + key: "coverage-artwork", + revision: 1 + ) { result in + continuation.resume(returning: result) + } + } + XCTAssertFalse(result.messages.isEmpty) + XCTAssertTrue(result.messages.first?.contains("LiveArtwork") == true) + LiveActivityArtworkStore.shared.removeAll() + } + + func testPlaybackHistoryStoreRoundTripAndDeduplication() async { + let store = PlaybackHistoryStore.shared + await clearPlaybackHistory(store) + defer { Task { await self.clearPlaybackHistory(store) } } + + let first = makePlaybackHistorySession(id: 1, title: "First") + let newer = makePlaybackHistorySession(id: 2, title: "Newer") + let replacement = makePlaybackHistorySession(id: 1, title: "First Updated") + let merged = await mergePlaybackHistory(store, sessions: [first, newer, replacement]) + XCTAssertEqual(merged.map(\.sessionId), [2, 1]) + XCTAssertEqual(merged.last?.title, "First Updated") + + let loadedSessions = await loadPlaybackHistorySessions(store) + XCTAssertEqual(loadedSessions, merged) + + let syncState = PlaybackHistorySyncState(lastSyncedSessionId: 2) + store.saveSyncState(syncState) + let stats = makePlaybackStatsFixture()["WEEK"]! + store.saveStats(stats) + _ = await mergePlaybackHistory(store, sessions: []) + let loadedSyncState = await loadPlaybackSyncState(store) + let loadedStats = await loadPlaybackStats(store) + XCTAssertEqual(loadedSyncState, syncState) + XCTAssertEqual(loadedStats["WEEK"], stats) + + await clearPlaybackHistory(store) + let clearedSessions = await loadPlaybackHistorySessions(store) + let clearedSyncState = await loadPlaybackSyncState(store) + XCTAssertTrue(clearedSessions.isEmpty) + XCTAssertEqual(clearedSyncState.lastSyncedSessionId, 0) + } + + func testArtworkImageCacheMemoryDiskDecodeAndMemoryWarning() async throws { + let cache = ArtworkImageCache.shared + cache.removeAllDecodedImages() + let source = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 180)).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 320, height: 180)) + } + let data = try XCTUnwrap(source.jpegData(compressionQuality: 0.85)) + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("coverage-artwork-\(UUID().uuidString).jpg") + try data.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let diskImage = await cache.load( + artworkId: "disk", + quality: "hq", + fileURL: fileURL, + maximumPixelSize: 128 + ) + XCTAssertNotNil(diskImage) + XCTAssertNotNil( + cache.memoryImage( + artworkId: "disk", + quality: "hq", + maximumPixelSize: 128 + ) + ) + + let decoded = await withCheckedContinuation { continuation in + cache.decode( + data: data, + artworkId: "decoded", + quality: "preview", + maximumPixelSize: 96 + ) { image in + continuation.resume(returning: image) + } + } + XCTAssertNotNil(decoded) + cache.store( + source, + artworkId: "manual", + quality: "preview", + maximumPixelSize: 64 + ) + let memoryHit = await cache.load( + artworkId: "manual", + quality: "preview", + fileURL: fileURL, + maximumPixelSize: 64 + ) + XCTAssertNotNil(memoryHit) + NotificationCenter.default.post( + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + XCTAssertNil( + cache.memoryImage( + artworkId: "manual", + quality: "preview", + maximumPixelSize: 64 + ) + ) + XCTAssertNil(ArtworkImageCache.downsampledImage(data: data, maximumPixelSize: 0)) + + let ioCompleted = expectation(description: "artwork I/O queue") + cache.performIO { ioCompleted.fulfill() } + await fulfillment(of: [ioCompleted], timeout: 1) + } + + func testLastNowPlayingSnapshotStoreSaveLoadAndClear() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileURL = directory.appendingPathComponent("snapshot.json") + defer { try? FileManager.default.removeItem(at: directory) } + let store = LastNowPlayingSnapshotStore(fileURL: fileURL) + let now = Date() + let snapshot = LastNowPlayingSnapshot( + version: LastNowPlayingSnapshot.version, + trackId: "track-store", + title: "Stored Song", + artist: "Artist", + album: "Album", + wasPlaying: true, + positionMs: 12_000, + durationMs: 180_000, + lyricLines: [ + .init( + index: 0, + timeMs: 0, + durationMs: 2_000, + text: "line", + translation: "translation", + romanization: "romanization", + words: [ + .init(id: 0, startMs: 0, durationMs: 2_000, text: "line") + ] + ) + ], + albumArtId: "artwork-store", + savedAt: now + ) + store.save(snapshot) + try await Task.sleep(nanoseconds: 450_000_000) + let loaded = await loadLastNowPlaying(store, now: now) + XCTAssertEqual(loaded, snapshot) + + store.save( + LastNowPlayingSnapshot( + version: LastNowPlayingSnapshot.version, + trackId: "", + title: "", + artist: "", + album: "", + wasPlaying: false, + positionMs: 0, + durationMs: 0, + lyricLines: [], + albumArtId: "", + savedAt: now + ) + ) + store.clear() + let cleared = await loadLastNowPlaying(store, now: now) + XCTAssertNil(cleared) + } + + func testAppLogStoreWritesReadsTimelineAndClears() async { + let store = AppLogStore.shared + await clearAppLogs(store) + store.append("coverage regular message") + store.append("[Coverage] categorized message") + store.appendTimeline("timeline coverage message") + + let text = await readRecentAppLogs(store) + XCTAssertTrue(text.contains("[App] coverage regular message")) + XCTAssertTrue(text.contains("[Coverage] categorized message")) + XCTAssertTrue(store.currentLogFileExists()) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.timelineLogURL.path)) + + await clearTimelineLogs(store) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.timelineLogURL.path)) + await clearAppLogs(store) + XCTAssertFalse(store.currentLogFileExists()) + let clearedText = await readRecentAppLogs(store) + XCTAssertTrue(clearedText.isEmpty) + } + + func testAppLanguageDefaultsToSimplifiedChinese() { + XCTAssertEqual(AppLanguage.defaultLanguage, .simplifiedChinese) + XCTAssertEqual(AppLanguage.simplifiedChinese.locale.identifier, "zh-Hans") + XCTAssertEqual(AppLanguage.english.locale.identifier, "en") + } + + func testLyricSecondaryRetryPolicySeparatesUnavailableAndTransientFailures() { + XCTAssertEqual( + LyricSecondaryRetryPolicy.action( + explicitlyUnavailable: true, + retryCount: 0 + ), + .markUnavailable + ) + XCTAssertEqual( + LyricSecondaryRetryPolicy.action( + explicitlyUnavailable: false, + retryCount: 0 + ), + .retry + ) + XCTAssertEqual( + LyricSecondaryRetryPolicy.action( + explicitlyUnavailable: false, + retryCount: 1 + ), + .markFailed + ) + } + + func testSystemReconnectPolicyDoesNotRaceCoreBluetoothReconnect() { + XCTAssertFalse( + SystemReconnectPolicy.shouldScheduleManualReconnect( + autoReconnectEnabled: true, + systemIsReconnecting: true + ) + ) + XCTAssertTrue( + SystemReconnectPolicy.shouldScheduleManualReconnect( + autoReconnectEnabled: true, + systemIsReconnecting: false + ) + ) + XCTAssertFalse( + SystemReconnectPolicy.shouldScheduleManualReconnect( + autoReconnectEnabled: false, + systemIsReconnecting: false + ) + ) + } + + func testKaraokeHighlightUsesWordDurationWithinWord() { + let words = [ + LyricWord(id: 0, startMs: 1_000, durationMs: 500, text: "Hello"), + LyricWord(id: 1, startMs: 2_000, durationMs: 500, text: "world") + ] + let firstHalf = KaraokeHighlightResolver.resolve( + text: "Hello world", + words: words, + positionMs: 1_250, + fallbackProgress: 0 + ) + XCTAssertEqual(firstHalf.highlightedCharacterCount, 2) + XCTAssertEqual(firstHalf.highlightedText, "He") + XCTAssertEqual(firstHalf.highlightedCharacterProgress, 2.5, accuracy: 0.001) + XCTAssertEqual(firstHalf.normalizedProgress, 2.5 / 11.0, accuracy: 0.001) + + let betweenWords = KaraokeHighlightResolver.resolve( + text: "Hello world", + words: words, + positionMs: 1_750, + fallbackProgress: 0 + ) + XCTAssertEqual(betweenWords.highlightedText, "Hello") + + let secondHalf = KaraokeHighlightResolver.resolve( + text: "Hello world", + words: words, + positionMs: 2_250, + fallbackProgress: 0 + ) + XCTAssertEqual(secondHalf.highlightedText, "Hello wo") + + let chineseHalf = KaraokeHighlightResolver.resolve( + text: "你", + words: [LyricWord(id: 0, startMs: 1_000, durationMs: 500, text: "你")], + positionMs: 1_250, + fallbackProgress: 0 + ) + XCTAssertEqual(chineseHalf.highlightedCharacterCount, 0) + XCTAssertEqual(chineseHalf.highlightedCharacterProgress, 0.5, accuracy: 0.001) + XCTAssertEqual(chineseHalf.normalizedProgress, 0.5, accuracy: 0.001) + + let projected = KaraokeHighlightResolver.resolve( + text: "你好", + words: [ + LyricWord(id: 0, startMs: 1_000, durationMs: 500, text: "你"), + LyricWord(id: 1, startMs: 1_500, durationMs: 500, text: "好") + ], + positionMs: 1_250, + fallbackProgress: 0, + lookAheadMs: 250 + ) + XCTAssertEqual(projected.highlightedCharacterProgress, 1, accuracy: 0.001) + } + + func testKaraokeProgressAnimationPolicySnapsCorrectionsAndReduceMotion() { + XCTAssertEqual( + KaraokeProgressAnimationPolicy.duration( + for: .automatic, + isLowPowerModeEnabled: false + ), + 0.25, + accuracy: 0.001 + ) + XCTAssertTrue( + KaraokeProgressAnimationPolicy.shouldAnimate( + from: 0.2, + to: 0.3, + isPlaying: true, + reduceMotion: false + ) + ) + XCTAssertFalse( + KaraokeProgressAnimationPolicy.shouldAnimate( + from: 0.6, + to: 0.2, + isPlaying: true, + reduceMotion: false + ) + ) + XCTAssertFalse( + KaraokeProgressAnimationPolicy.shouldAnimate( + from: 0.2, + to: 0.3, + isPlaying: true, + reduceMotion: true + ) + ) + XCTAssertFalse( + KaraokeProgressAnimationPolicy.shouldAnimate( + from: 0.2, + to: 0.7, + isPlaying: true, + reduceMotion: false + ) + ) + } + + func testFullLyricsCacheRejectsExpiredMismatchedAndCorruptEntries() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("entry.json") + let now = Date() + let entry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: "track-1", + title: "Song", + artist: "Artist", + album: "Album", + lines: [ + FullLyricsCacheEntry.Line( + index: 0, + timeMs: 0, + durationMs: 1_000, + text: "line", + translation: "翻译", + romanization: nil, + words: [ + FullLyricsCacheEntry.Word( + id: 0, + startMs: 0, + durationMs: 1_000, + text: "line" + ) + ] + ) + ], + savedAt: now + ) + try JSONEncoder().encode(entry).write(to: fileURL, options: .atomic) + XCTAssertTrue(entry.matches(title: " song ", artist: "ARTIST")) + XCTAssertFalse(entry.matches(title: "Other Song", artist: "Artist")) + XCTAssertEqual( + FullLyricsCacheStore.readEntry( + fileURL: fileURL, + expectedTrackId: "track-1", + now: now + )?.lines.first?.translation, + "翻译" + ) + XCTAssertNil( + FullLyricsCacheStore.readEntry( + fileURL: fileURL, + expectedTrackId: "track-2", + now: now + ) + ) + XCTAssertNil( + FullLyricsCacheStore.readEntry( + fileURL: fileURL, + expectedTrackId: "track-1", + now: now.addingTimeInterval(FullLyricsCacheEntry.maximumAge + 1) + ) + ) + + try Data([0x00, 0x01, 0x02]).write(to: fileURL, options: .atomic) + XCTAssertNil( + FullLyricsCacheStore.readEntry( + fileURL: fileURL, + expectedTrackId: "track-1", + now: now + ) + ) + } + + func testWriteTimeoutDoesNotAdvanceQueueAndSuspendsInBackground() { + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: false, + transportReady: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .suspendUntilForeground + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .extendWithoutAdvancingQueue + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + timeoutCountAfterIncrement: 2, + reconnectThreshold: 2 + ), + .reconnect + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: false, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .reconnect + ) + } + + func testCurrentWordOrderingFenceRejectsDuplicatesAndSmallRegression() { + var fence = CurrentWordOrderingFence() + XCTAssertTrue(fence.shouldAccept(generation: 7, sequence: 1, positionMs: 1_000)) + XCTAssertFalse(fence.shouldAccept(generation: 7, sequence: 1, positionMs: 1_050)) + XCTAssertFalse(fence.shouldAccept(generation: 7, sequence: 2, positionMs: 900)) + XCTAssertTrue(fence.shouldAccept(generation: 7, sequence: 3, positionMs: 1_700)) + XCTAssertTrue(fence.shouldAccept(generation: 7, sequence: 4, positionMs: 100)) + XCTAssertEqual(fence.sequence, 4) + XCTAssertEqual(fence.positionMs, 100) + + fence.reset() + XCTAssertTrue(fence.shouldAccept(generation: 1, sequence: 1, positionMs: 20)) + } + + func testMonotonicClockSyncAndAutomaticPlaybackCompensation() { + var synchronizer = MonotonicClockSynchronizer() + XCTAssertEqual( + synchronizer.record( + clientSendElapsedMs: 1_000, + serverReceiveElapsedMs: 920, + serverSendElapsedMs: 922, + clientReceiveElapsedMs: 1_042 + )?.isConfident, + false + ) + _ = synchronizer.record( + clientSendElapsedMs: 2_000, + serverReceiveElapsedMs: 1_922, + serverSendElapsedMs: 1_924, + clientReceiveElapsedMs: 2_046 + ) + let snapshot = synchronizer.record( + clientSendElapsedMs: 3_000, + serverReceiveElapsedMs: 2_919, + serverSendElapsedMs: 2_921, + clientReceiveElapsedMs: 3_041 + ) + XCTAssertEqual(snapshot?.isConfident, true) + XCTAssertEqual(snapshot?.bestRoundTripMs, 39) + XCTAssertEqual(Int64(snapshot?.localMinusServerMs.rounded() ?? 0), 100) + + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + serverSampleElapsedMs: 4_000, + localReceiveElapsedMs: 4_220, + playbackSpeed: 1.0, + isPlaying: true, + durationMs: 60_000, + synchronizer: synchronizer + ), + .resolved(positionMs: 10_120, transportAgeMs: 120) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + measuredTransportAgeMs: 2_000, + playbackSpeed: 1.0, + isPlaying: true, + durationMs: 60_000 + ), + .stale(transportAgeMs: 2_000) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + measuredTransportAgeMs: 200, + playbackSpeed: 1.0, + isPlaying: false, + durationMs: 60_000 + ), + .resolved(positionMs: 10_000, transportAgeMs: 200) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.smoothedPosition( + currentPositionMs: 10_000, + targetPositionMs: 10_200 + ), + 10_100 + ) + } + + func testAutomaticLyricSyncMigratesOnlyLegacyDefaultOffset() { + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 600, + migrationCompleted: false + ), + 0 + ) + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 300, + migrationCompleted: false + ), + 300 + ) + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 600, + migrationCompleted: true + ), + 600 + ) + } + + func testCompactConnectionAndVolumePresentation() { + XCTAssertEqual( + DarkControlConnectionState.connected.compactTitle, + "Sony" + ) + XCTAssertEqual( + DarkControlConnectionState.disconnected.compactTitle, + "连接" + ) + XCTAssertTrue(DarkControlConnectionState.connecting.showsProgressIndicator) + XCTAssertTrue(DarkControlConnectionState.reconnecting.showsProgressIndicator) + XCTAssertFalse(DarkControlConnectionState.connected.showsProgressIndicator) + + XCTAssertEqual( + CompactVolumePresentation.normalizedProgress(current: 8, maximum: 16), + 0.5, + accuracy: 0.001 + ) + XCTAssertEqual( + CompactVolumePresentation.normalizedProgress(current: 20, maximum: 15), + 1, + accuracy: 0.001 + ) + } + + func testA1AndA2DispatchAndOutOfOrderAssembly() { + let a1 = packet(magic: 0xA1, kind: 3, index: 0, total: 1, payload: Data([9])) + let decodedA1 = BLEBinaryChunkCodec.decode(a1, expectedMagic: 0xA1) + XCTAssertEqual(decodedA1?.kindCode, 3) + XCTAssertEqual(decodedA1?.payload, Data([9])) + XCTAssertNil(BLEBinaryChunkCodec.decode(a1, expectedMagic: 0xA2)) + + let payloads = [Data("one".utf8), Data("two".utf8), Data("three".utf8)] + let packets = payloads.enumerated().map { + packet( + magic: 0xA2, + kind: 1, + index: $0.offset, + total: payloads.count, + payload: $0.element + ) + } + var chunks: [Int: Data] = [:] + for packetData in packets.reversed() { + let chunk = try! XCTUnwrap( + BLEBinaryChunkCodec.decode(packetData, expectedMagic: 0xA2) + ) + chunks[chunk.index] = chunk.payload + } + let duplicate = try! XCTUnwrap( + BLEBinaryChunkCodec.decode(packets[1], expectedMagic: 0xA2) + ) + chunks[duplicate.index] = duplicate.payload + XCTAssertEqual( + BLEBinaryChunkCodec.reassemble(chunks: chunks, expectedCount: 3), + Data("onetwothree".utf8) + ) + chunks.removeValue(forKey: 1) + XCTAssertEqual( + BLEBinaryChunkCodec.missingIndexes(chunks: chunks, expectedCount: 3), + [1] + ) + XCTAssertNil(BLEBinaryChunkCodec.reassemble(chunks: chunks, expectedCount: 3)) + } + + func testCRCAndZlibFixedVector() { + XCTAssertEqual( + BLEBinaryChunkCodec.crc32(Data("123456789".utf8)), + 0xcbf4_3926 + ) + let raw = Data( + "{\"trackId\":\"fixed\",\"generation\":7,\"lines\":[{\"index\":0,\"timeMs\":0,\"durationMs\":1000,\"text\":\"hello\"}]}" + .utf8 + ) + let compressed = dataFromHex( + "7801ab562a294a4ccef64c51b2524acbac484d51d2514a4fcd4b2d4a2cc9cccf53b232d751cac9cc4b2d56b28aae56cacc4b49ad50b232d0512ac9cc4df5050a029929a510b520aea181014832b5a204685c466a4e4ebe526d6c2d0075732076" + ) + XCTAssertEqual( + BLETestManager.zlibDecompress(compressed, expectedSize: raw.count), + raw + ) + XCTAssertEqual(BLETestManager.crc32(compressed), 0x74f3_85b3) + } + + func testSnapshotValidExpiredAndCorrupted() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("LastNowPlayingSnapshot-v1.json") + let now = Date() + let snapshot = LastNowPlayingSnapshot( + version: LastNowPlayingSnapshot.version, + trackId: "track-1", + title: "Title", + artist: "Artist", + album: "Album", + wasPlaying: true, + positionMs: 2_000, + durationMs: 20_000, + lyricLines: [], + albumArtId: "art-1", + savedAt: now + ) + try JSONEncoder().encode(snapshot).write(to: fileURL, options: .atomic) + XCTAssertEqual( + LastNowPlayingSnapshotStore.readSnapshot(fileURL: fileURL, now: now), + snapshot + ) + XCTAssertNil( + LastNowPlayingSnapshotStore.readSnapshot( + fileURL: fileURL, + now: now.addingTimeInterval(LastNowPlayingSnapshot.maximumAge + 1) + ) + ) + try Data("{broken".utf8).write(to: fileURL, options: .atomic) + XCTAssertNil(LastNowPlayingSnapshotStore.readSnapshot(fileURL: fileURL)) + } + + func testArtworkDownsamplingAndCorruptData() throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1_024, height: 768)) + let image = renderer.image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 1_024, height: 768)) + } + let data = try XCTUnwrap(image.jpegData(compressionQuality: 0.8)) + let downsampled = try XCTUnwrap( + ArtworkImageCache.downsampledImage(data: data, maximumPixelSize: 128) + ) + XCTAssertLessThanOrEqual(max(downsampled.pixelWidth, downsampled.pixelHeight), 128) + XCTAssertNil( + ArtworkImageCache.downsampledImage( + data: Data("not-an-image".utf8), + maximumPixelSize: 128 + ) + ) + } + + func testQQFallbackIsRejectedAndForceRefreshRetainsRealArtwork() async throws { + let delegate = AlbumArtReceiverTestDelegate() + let receiver = AlbumArtReceiver(delegate: delegate) + let artworkID = "low-information-\(UUID().uuidString)" + delegate.trackID = artworkID + receiver.handleIdentity(id: artworkID) + defer { receiver.clearCurrentIdentity(reason: "test cleanup") } + + let rendererFormat = UIGraphicsImageRendererFormat() + rendererFormat.scale = 1 + let fallbackRenderer = UIGraphicsImageRenderer( + size: CGSize(width: 228, height: 228), + format: rendererFormat + ) + let fallbackImage = fallbackRenderer.image { context in + UIColor(white: 0.42, alpha: 1).setFill() + context.fill(CGRect(x: 0, y: 0, width: 228, height: 228)) + } + let fallbackData = try XCTUnwrap(fallbackImage.jpegData(compressionQuality: 0.8)) + XCTAssertEqual( + AlbumArtPlaceholderPolicy.disposition( + image: fallbackImage, + dataSize: fallbackData.count + ), + .provisional + ) + + let rejected = expectation(description: "QQ fallback rejected") + var didFulfillRejection = false + receiver.onStateChanged = { state in + guard !didFulfillRejection, + state.artworkEnhancementStatus.lastMessage == "waiting for real artwork" else { + return + } + didFulfillRejection = true + rejected.fulfill() + } + receiver.handleLegacyStart( + id: artworkID, + quality: "hq", + size: fallbackData.count, + chunks: 1 + ) + receiver.handleLegacyChunk( + id: artworkID, + quality: "hq", + index: 0, + base64: fallbackData.base64EncodedString() + ) + receiver.handleLegacyEnd(id: artworkID, quality: "hq") + await fulfillment(of: [rejected], timeout: 5) + XCTAssertNil(receiver.albumArtImage) + XCTAssertEqual(receiver.artworkDisplayQuality, .placeholder) + + let realRenderer = UIGraphicsImageRenderer( + size: CGSize(width: 280, height: 280), + format: rendererFormat + ) + let realImage = realRenderer.image { context in + UIColor.systemRed.setFill() + context.fill(CGRect(x: 0, y: 0, width: 140, height: 280)) + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 140, y: 0, width: 140, height: 280)) + UIColor.systemGreen.setFill() + context.fill(CGRect(x: 70, y: 70, width: 140, height: 140)) + } + let realData = try XCTUnwrap(realImage.jpegData(compressionQuality: 0.8)) + XCTAssertEqual( + AlbumArtPlaceholderPolicy.disposition( + image: realImage, + dataSize: realData.count + ), + .normal + ) + + let displayed = expectation(description: "real artwork displayed") + var didFulfillDisplay = false + receiver.onStateChanged = { state in + guard !didFulfillDisplay, state.albumArtImage != nil else { return } + didFulfillDisplay = true + displayed.fulfill() + } + receiver.handleLegacyStart( + id: artworkID, + quality: "preview", + size: realData.count, + chunks: 1 + ) + receiver.handleLegacyChunk( + id: artworkID, + quality: "preview", + index: 0, + base64: realData.base64EncodedString() + ) + receiver.handleLegacyEnd(id: artworkID, quality: "preview") + await fulfillment(of: [displayed], timeout: 5) + + let displayedImage = try XCTUnwrap(receiver.albumArtImage) + delegate.commands.removeAll() + XCTAssertTrue(receiver.forceRefreshCurrentAlbumArt()) + XCTAssertTrue(receiver.albumArtImage === displayedImage) + + let previewRefresh = try XCTUnwrap( + delegate.commands.first { + $0.command == "ALBUM_ART_REQUEST" && + ($0.extra["quality"] as? String) == "preview" + } + ) + XCTAssertEqual(previewRefresh.extra["forceRefresh"] as? Bool, true) + } + + func testSourceRefreshSecondOfferDoesNotBecomeFalseCacheHit() { + XCTAssertTrue( + AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: true, + refreshAttempted: false, + refreshInFlight: false + ) + ) + XCTAssertTrue( + AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: true, + refreshAttempted: true, + refreshInFlight: true + ) + ) + XCTAssertFalse( + AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: true, + refreshAttempted: true, + refreshInFlight: false + ) + ) + XCTAssertFalse( + AlbumArtSourceRefreshPolicy.needsRefresh( + cacheRequiresRefresh: false, + refreshAttempted: false, + refreshInFlight: false + ) + ) + } + + func testRepeatedOfferDuringSourceRefreshDoesNotSkipHQ() async throws { + let delegate = AlbumArtReceiverTestDelegate() + let receiver = AlbumArtReceiver(delegate: delegate) + let artworkID = "source-refresh-\(UUID().uuidString)" + delegate.trackID = artworkID + receiver.handleIdentity(id: artworkID) + defer { receiver.clearCurrentIdentity(reason: "test cleanup") } + + let rendererFormat = UIGraphicsImageRendererFormat() + rendererFormat.scale = 1 + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: 228, height: 228), + format: rendererFormat + ) + let image = renderer.image { context in + UIColor.systemPurple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 114, height: 228)) + UIColor.systemOrange.setFill() + context.fill(CGRect(x: 114, y: 0, width: 114, height: 228)) + } + let data = try XCTUnwrap(image.jpegData(compressionQuality: 0.8)) + + let cacheSaved = expectation(description: "transient HQ cache saved") + delegate.onLog = { message in + if message.contains("[AlbumArtCache] saved id=\(artworkID) quality=hq") { + cacheSaved.fulfill() + } + } + receiver.handleLegacyStart( + id: artworkID, + quality: "hq", + size: data.count, + chunks: 1 + ) + receiver.handleLegacyChunk( + id: artworkID, + quality: "hq", + index: 0, + base64: data.base64EncodedString() + ) + receiver.handleLegacyEnd(id: artworkID, quality: "hq") + await fulfillment(of: [cacheSaved], timeout: 5) + + delegate.commands.removeAll() + let forcedPreview = expectation(description: "source refresh preview requested") + delegate.onCommand = { command, extra in + guard command == "ALBUM_ART_REQUEST", + (extra["quality"] as? String) == "preview", + (extra["forceRefresh"] as? Bool) == true else { + return + } + forcedPreview.fulfill() + } + receiver.handleOffer(id: artworkID) + await fulfillment(of: [forcedPreview], timeout: 5) + + let secondOfferHeld = expectation(description: "second offer remains in refresh") + delegate.onLog = { message in + if message.contains("refresh_already_requested id=\(artworkID)") { + secondOfferHeld.fulfill() + } + } + receiver.handleOffer(id: artworkID) + await fulfillment(of: [secondOfferHeld], timeout: 5) + XCTAssertFalse( + delegate.commands.contains { + $0.command == "ALBUM_ART_SKIP" && + ($0.extra["quality"] as? String) == "hq" + } + ) + } + + func testObservableSliceDeduplicatesAndPlaybackClockPauses() { + let slice = ObservableStateSlice(BLEConnectionViewState()) + var emissions = 0 + let cancellable = slice.$value.dropFirst().sink { _ in emissions += 1 } + slice.update(BLEConnectionViewState()) + XCTAssertEqual(emissions, 0) + var connected = BLEConnectionViewState() + connected.status = "已连接" + slice.update(connected) + XCTAssertEqual(emissions, 1) + withExtendedLifetime(cancellable) {} + + XCTAssertTrue( + PlaybackClockPolicy.shouldRun( + isPlaying: true, + durationMs: 10_000, + appLifecycleState: "active" + ) + ) + XCTAssertFalse( + PlaybackClockPolicy.shouldRun( + isPlaying: false, + durationMs: 10_000, + appLifecycleState: "active" + ) + ) + XCTAssertFalse( + PlaybackClockPolicy.shouldRun( + isPlaying: true, + durationMs: 10_000, + appLifecycleState: "background" + ) + ) + } + + func testProtocolCapabilitiesV1V2AndV3Negotiation() { + let v1 = BLEProtocolV3Parser.capabilitiesAck(from: [:]) + XCTAssertEqual(v1.protocolVersion, 1) + XCTAssertTrue(v1.v2Features.isEmpty) + XCTAssertTrue(v1.v3Features.isEmpty) + + let v2 = BLEProtocolV3Parser.capabilitiesAck(from: [ + "protocolVersion": 2, + "albumArtBinary": true, + "fullLyricsZlib": true, + "lyricWindow": true, + "ping": true, + "clockSyncV1": true, + "transferRetry": true, + "unknownCapability": true + ]) + XCTAssertEqual(v2.protocolVersion, 2) + XCTAssertEqual(v2.v2Features.rawValue, 63) + XCTAssertTrue(v2.v3Features.isEmpty) + + let v3 = BLEProtocolV3Parser.capabilitiesAck(from: [ + "protocolVersion": 3, + "f2": 63 | (1 << 12), + "f3": 7 | (1 << 10), + "sid": "sony-session-1" + ]) + XCTAssertEqual(v3.protocolVersion, 3) + XCTAssertTrue(v3.v2Features.contains(.albumArtBinary)) + XCTAssertTrue(v3.v2Features.contains(.transferRetry)) + XCTAssertTrue(v3.v3Features.contains(.statusMetaV1)) + XCTAssertTrue(v3.v3Features.contains(.structuredErrorV1)) + XCTAssertTrue(v3.v3Features.contains(.mediaLoadStateV1)) + XCTAssertEqual(v3.sessionId, "sony-session-1") + XCTAssertEqual(v3.v3Features.intersection(.all).rawValue, 7) + } + + func testProtocolRejectsMalformedAndOversizedJSON() { + XCTAssertNil(BLEProtocolV3Parser.jsonObject(from: Data("{".utf8))) + XCTAssertNil(BLEProtocolV3Parser.jsonObject(from: Data())) + XCTAssertNil( + BLEProtocolV3Parser.jsonObject( + from: Data(repeating: 0x61, count: BLEProtocolV3Parser.maximumJSONNotifyBytes + 1) + ) + ) + let unknown = BLEProtocolV3Parser.jsonObject( + from: Data("{\"type\":\"futureNotify\",\"future\":true}".utf8) + ) + XCTAssertEqual(unknown?["type"] as? String, "futureNotify") + } + + func testStructuredCommandErrorAndMediaLoadStateParsing() throws { + let commandError = try XCTUnwrap(BLEProtocolV3Parser.commandError(from: [ + "type": "commandError", + "seq": 42, + "cmd": "GET_FULL_LYRICS", + "domain": "lyrics", + "code": "qrc_not_ready", + "retryable": true, + "retryAfterMs": 800, + "trackId": "track-1", + "generation": 7, + "sid": "session-1", + "es": 10 + ])) + XCTAssertEqual(commandError.sequence, 42) + XCTAssertEqual(commandError.domain, .lyrics) + XCTAssertTrue(commandError.retryable) + XCTAssertEqual(commandError.retryAfterMs, 800) + XCTAssertEqual(commandError.metadata?.eventSequence, 10) + XCTAssertNil(BLEProtocolV3Parser.commandError(from: ["cmd": "GET_FULL_LYRICS"])) + + let media = try XCTUnwrap(BLEProtocolV3Parser.mediaLoadState(from: [ + "resource": "artwork", + "stage": "transferring", + "reason": "hq", + "retryable": false, + "trackId": "track-1", + "generation": 7 + ])) + XCTAssertEqual(media.resource, .artwork) + XCTAssertEqual(media.stage, .transferring) + XCTAssertEqual(media.deduplicationKey, "track-1|7|artwork|transferring|hq") + XCTAssertEqual(media.deduplicationKey, media.deduplicationKey) + XCTAssertNil(BLEProtocolV3Parser.mediaLoadState(from: [ + "resource": "future-resource", + "stage": "ready", + "trackId": "track-1" + ])) + } + + func testEventSequenceDiagnosticsNeverActsAsGlobalDropFence() { + var diagnostics = BLEEventSequenceDiagnostics() + XCTAssertEqual( + diagnostics.observe(BLEStatusMetadata(sessionId: "s1", eventSequence: 1)), + .first + ) + XCTAssertEqual( + diagnostics.observe(BLEStatusMetadata(sessionId: "s1", eventSequence: 1)), + .duplicate + ) + XCTAssertEqual( + diagnostics.observe(BLEStatusMetadata(sessionId: "s1", eventSequence: 4)), + .gap(missing: 2) + ) + XCTAssertEqual( + diagnostics.observe(BLEStatusMetadata(sessionId: "s1", eventSequence: 2)), + .outOfOrder + ) + XCTAssertEqual(diagnostics.highestSequence, 4) + XCTAssertEqual( + diagnostics.observe(BLEStatusMetadata(sessionId: "s2", eventSequence: 1)), + .newSession + ) + } + + func testFocusedStoresAndResponsiveLayoutModes() { + let connection = ConnectionStore() + connection.update( + state: BLEConnectionViewState(status: "已连接"), + presentation: .connected(deviceName: "Sony", health: "healthy") + ) + XCTAssertTrue(connection.presentation.isConnected) + + let playback = PlaybackStore() + playback.updateMetadata(BLEPlaybackMetadataState(title: "Song", artist: "Artist", album: "Album")) + playback.updateTimeline(BLEPlaybackTimelineState(isPlaying: true, durationMs: 60_000)) + playback.updateVolume(BLEVolumeViewState(current: 8, maximum: 15, seekValue: 8)) + XCTAssertEqual(playback.metadata.title, "Song") + XCTAssertTrue(playback.timeline.isPlaying) + XCTAssertEqual(playback.volume.current, 8) + + XCTAssertEqual( + PlayerLayoutMode.resolve(availableHeight: 667, dynamicTypeSize: .large), + .compact + ) + XCTAssertEqual( + PlayerLayoutMode.resolve(availableHeight: 852, dynamicTypeSize: .large), + .regular + ) + XCTAssertEqual( + PlayerLayoutMode.resolve( + availableHeight: 852, + dynamicTypeSize: .accessibility3 + ), + .accessibility + ) + + let seMetrics = PlayerLayoutMetrics.resolve( + availableSize: CGSize(width: 375, height: 667), + safeAreaInsets: EdgeInsets(top: 20, leading: 0, bottom: 0, trailing: 0), + dynamicTypeSize: .large, + artworkPreference: .large + ) + XCTAssertEqual(seMetrics.mode, .compact) + XCTAssertTrue((112...144).contains(seMetrics.artworkSize)) + + let standardMetrics = PlayerLayoutMetrics.resolve( + availableSize: CGSize(width: 393, height: 852), + safeAreaInsets: EdgeInsets(top: 59, leading: 0, bottom: 34, trailing: 0), + dynamicTypeSize: .large, + artworkPreference: .medium + ) + XCTAssertEqual(standardMetrics.mode, .regular) + XCTAssertEqual(standardMetrics.artworkSize, 224) + + let largeMetrics = PlayerLayoutMetrics.resolve( + availableSize: CGSize(width: 430, height: 932), + safeAreaInsets: EdgeInsets(top: 59, leading: 0, bottom: 34, trailing: 0), + dynamicTypeSize: .large, + artworkPreference: .large + ) + XCTAssertEqual(largeMetrics.mode, .regular) + XCTAssertEqual(largeMetrics.artworkSize, 272) + } + + func testProductUiStateMappingsAndLegacyArtworkMigration() { + XCTAssertEqual(ArtworkDisplaySizeOption.small.pointSize, 184) + XCTAssertEqual(ArtworkDisplaySizeOption.medium.pointSize, 224) + XCTAssertEqual(ArtworkDisplaySizeOption.large.pointSize, 272) + XCTAssertEqual(PreferencesStore.migratedArtworkDisplaySize(storedValue: 200), .small) + XCTAssertEqual(PreferencesStore.migratedArtworkDisplaySize(storedValue: 220), .medium) + XCTAssertEqual(PreferencesStore.migratedArtworkDisplaySize(storedValue: 260), .large) + + XCTAssertEqual( + ConnectionStatusPresentation.resolve(.connected(deviceName: "Sony", health: "healthy")).title, + "已连接" + ) + XCTAssertEqual( + ConnectionStatusPresentation.resolve(.reconnecting(deviceName: "Sony", attempt: 2)).title, + "正在重连" + ) + let banner = ReconnectBannerPresentation.resolve( + connection: .disconnected(lastDeviceName: "Sony"), + hasSnapshot: true + ) + XCTAssertEqual(banner?.showsRetry, true) + XCTAssertNil(ReconnectBannerPresentation.resolve( + connection: .disconnected(lastDeviceName: "Sony"), + hasSnapshot: false + )) + XCTAssertTrue(NowPlayingSnapshotPolicy.hasDisplayableSnapshot( + title: "Song", + artist: "Artist", + hasArtwork: false, + isRestoredSnapshot: false + )) + XCTAssertFalse(NowPlayingSnapshotPolicy.hasDisplayableSnapshot( + title: "-", + artist: "等待同步", + hasArtwork: false, + isRestoredSnapshot: false + )) + } + + func testFullLyricsFollowStateOnlyResumesExplicitlyOrOnTrackChange() { + var state = FullLyricsFollowState() + XCTAssertFalse(state.showsReturnToCurrent) + state.userDidBrowse() + XCTAssertTrue(state.showsReturnToCurrent) + state.returnToCurrent() + XCTAssertFalse(state.showsReturnToCurrent) + state.userDidBrowse() + state.trackDidChange() + XCTAssertFalse(state.showsReturnToCurrent) + } + + func testLiveArtworkRevisionFenceRejectsLateCompletion() { + var fence = LiveActivityArtworkRevisionFence() + let first = fence.begin() + let second = fence.begin() + XCTAssertFalse(fence.accepts(first)) + XCTAssertTrue(fence.accepts(second)) + fence.invalidate() + XCTAssertFalse(fence.accepts(second)) + } + + func testDecodeOneThousandV3StatusMessagesPerformance() { + let payload = Data( + "{\"type\":\"mediaLoadState\",\"resource\":\"lyrics\",\"stage\":\"transferring\",\"reason\":\"qrc\",\"trackId\":\"t1\",\"generation\":7,\"sid\":\"s1\",\"es\":99}" + .utf8 + ) + measure { + for _ in 0..<1_000 { + let object = BLEProtocolV3Parser.jsonObject(from: payload) + XCTAssertNotNil(object.flatMap(BLEProtocolV3Parser.mediaLoadState(from:))) + } + } + } + + @MainActor + private func assertViewRenders( + _ view: AnyView, + named name: String, + width: CGFloat = 390, + height: CGFloat + ) { + let size = CGSize(width: width, height: height) + let host = UIHostingController( + rootView: view + .environment(\.locale, PreferencesStore.shared.appLanguage.locale) + .frame(width: width, height: height) + ) + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first + let previousKeyWindow = windowScene?.windows.first(where: \.isKeyWindow) + let window = windowScene.map(UIWindow.init(windowScene:)) ?? UIWindow(frame: CGRect(origin: .zero, size: size)) + window.frame = CGRect(origin: .zero, size: size) + window.rootViewController = host + window.makeKeyAndVisible() + host.loadViewIfNeeded() + host.view.frame = CGRect(origin: .zero, size: size) + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.08)) + host.view.layoutIfNeeded() + + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + host.view.layer.render(in: context.cgContext) + } + XCTAssertEqual(image.size.width, width, accuracy: 0.1, name) + XCTAssertEqual(image.size.height, height, accuracy: 0.1, name) + + window.isHidden = true + window.rootViewController = nil + previousKeyWindow?.makeKeyAndVisible() + } + + private func makePlaybackStatsFixture() -> [String: PlaybackStatsSnapshot] { + let track = PlaybackTopTrack( + trackKey: "track-1", + title: "测试歌曲", + artist: "测试歌手", + album: "测试专辑", + artworkId: nil, + listenedMs: 180_000, + playCount: 3, + completedCount: 2, + skippedCount: 1 + ) + let artist = PlaybackTopArtist( + artist: "测试歌手", + listenedMs: 360_000, + playCount: 6, + trackCount: 2 + ) + let dailyTrend = [ + DailyListenStat(dateKey: "2026-08-12", listenedMs: 120_000, playCount: 2), + DailyListenStat(dateKey: "2026-08-13", listenedMs: 240_000, playCount: 4) + ] + func snapshot(_ range: String, multiplier: Int64) -> PlaybackStatsSnapshot { + PlaybackStatsSnapshot( + range: range, + rangeStart: 1_723_430_400_000, + rangeEnd: 1_723_516_800_000, + totalListenMs: 360_000 * multiplier, + playCount: Int(6 * multiplier), + uniqueTrackCount: 2, + completedCount: Int(4 * multiplier), + skippedCount: Int(multiplier), + completionRate: 0.67, + skipRate: 0.17, + topTracks: [track], + topArtists: [artist], + dailyTrend: dailyTrend + ) + } + return [ + "TODAY": snapshot("TODAY", multiplier: 1), + "WEEK": snapshot("WEEK", multiplier: 3), + "MONTH": snapshot("MONTH", multiplier: 8) + ] + } + + private func makeConnectionSnapshot( + displayState: String = "connected", + healthState: String = "healthy", + autoReconnectState: String = "idle", + ready: Bool = true, + notifyAgeMs: Int64 = 100 + ) -> ConnectionDiagnosticSnapshot { + ConnectionDiagnosticSnapshot( + connectionStatus: displayState == "connected" ? "已连接" : "未连接", + displayState: displayState, + healthState: healthState, + autoReconnectState: autoReconnectState, + autoReconnectAttempt: 1, + mtuBytes: ready ? 185 : 0, + lastNotifyAgeMs: notifyAgeMs, + peripheralState: displayState, + characteristicReady: ready, + probeInFlight: healthState == "suspect", + lastHardReconnectReason: "coverage", + reconnectWorkItemExists: displayState == "reconnecting" + ) + } + + private func makeAlbumArtSnapshot( + quality: ArtworkDisplayQuality, + transferState: String, + failure: String = "-" + ) -> AlbumArtSnapshot { + AlbumArtSnapshot( + id: "artwork-id", + image: nil, + displayQuality: quality, + displayPixelWidth: quality == .placeholder ? 0 : 600, + displayPixelHeight: quality == .placeholder ? 0 : 600, + enhancementStatus: ArtworkEnhancementDebugStatus( + displayQuality: quality, + lastMessage: "coverage" + ), + caches: [ + .missing(quality: "preview"), + .missing(quality: "hq"), + .missing(quality: "enhanced") + ], + hqUnavailableReason: "-", + hqUnavailableBestBytes: 0, + hqUnavailableBestChunks: 0, + hqUnavailableMinCandidateScale: 0, + transfer: AlbumArtTransferDiagnosticSnapshot( + state: transferState, + quality: quality.label, + receivedChunks: transferState == "receiving" ? 2 : 0, + totalChunks: transferState == "receiving" ? 4 : 0, + lastFailureReason: failure, + previewRetryCount: 0, + hqRetryCount: 0 + ), + predictive: PredictiveAlbumArtSnapshot( + lastAlbumArtId: "artwork-id", + pendingHq: false, + pendingHqId: "", + lastSkipReason: "-", + offerCount: 1, + hqPrefetchScheduled: 0, + hqPrefetchSent: 0, + hqPrefetchSkippedCacheHit: 0, + hqPrefetchSkippedInFlight: 0, + hqPrefetchSkippedNotConnected: 0, + hqPrefetchCancelledTrackChanged: 0, + hqArrivedBeforeDisplayCount: 0, + avgOfferToHqRequestMs: 0, + avgOfferToHqReadyMs: 0, + lastOfferToHqRequestMs: 0, + lastOfferToHqReadyMs: 0 + ) + ) + } + + private func makeNowPlayingSnapshot( + lyric: LyricDiagnostic?, + connection: ConnectionDiagnosticSnapshot, + artworkQuality: String, + transferState: String, + transferFailure: String = "-", + hqUnavailableReason: String, + selfHealing: SelfHealingSnapshot + ) -> NowPlayingDiagnosticSnapshot { + NowPlayingDiagnosticSnapshot( + generatedAt: Date(timeIntervalSince1970: 1_723_500_000), + title: "Song", + artist: "Artist", + album: "Album", + trackId: "track", + albumArtId: "artwork-id", + albumArtDisplayQuality: artworkQuality, + displayArtworkPixelWidth: artworkQuality == "placeholder" ? 0 : 600, + displayArtworkPixelHeight: artworkQuality == "placeholder" ? 0 : 600, + artworkEnhancementStatus: ArtworkEnhancementDebugStatus( + enabled: false, + displayQuality: artworkQuality == "hq" ? .hq : + (artworkQuality == "preview" ? .preview : .placeholder), + lastMessage: "coverage" + ), + artworkCaches: [ + ArtworkCacheDiagnostic( + quality: "preview", + exists: artworkQuality != "placeholder", + bytes: 1_024, + pixelWidth: 300, + pixelHeight: 300, + isPlaceholder: false, + modifiedAt: Date(timeIntervalSince1970: 1_723_500_000), + path: "/tmp/preview" + ), + artworkQuality == "hq" + ? ArtworkCacheDiagnostic( + quality: "hq", + exists: true, + bytes: 8_192, + pixelWidth: 800, + pixelHeight: 800, + isPlaceholder: false, + modifiedAt: Date(timeIntervalSince1970: 1_723_500_000), + path: "/tmp/hq" + ) + : .missing(quality: "hq"), + .missing(quality: "enhanced") + ], + hqUnavailableReason: hqUnavailableReason, + hqUnavailableBestBytes: 10_000, + hqUnavailableBestChunks: 4, + hqUnavailableMinCandidateScale: 2, + albumArtTransfer: AlbumArtTransferDiagnosticSnapshot( + state: transferState, + quality: artworkQuality, + receivedChunks: 1, + totalChunks: 3, + lastFailureReason: transferFailure, + previewRetryCount: 1, + hqRetryCount: 1 + ), + predictiveAlbumArt: PredictiveAlbumArtDiagnosticSnapshot( + lastAlbumArtId: "artwork-id", + pendingHq: artworkQuality == "preview", + pendingHqId: artworkQuality == "preview" ? "artwork-id" : "", + lastSkipReason: "-", + offerCount: 2, + hqPrefetchScheduled: 1, + hqPrefetchSent: 1, + hqPrefetchSkippedCacheHit: 0, + hqPrefetchSkippedInFlight: 0, + hqPrefetchSkippedNotConnected: 0, + hqPrefetchCancelledTrackChanged: 0, + hqArrivedBeforeDisplayCount: 0, + avgOfferToHqRequestMs: 120, + avgOfferToHqReadyMs: 700, + lastOfferToHqRequestMs: 110, + lastOfferToHqReadyMs: 650 + ), + isPlaying: true, + positionMs: 65_000, + durationMs: 240_000, + currentLyric: (lyric?.lines ?? 0) > 0 ? "line" : "", + lyricDiagnostic: lyric, + fullLyricsLineCount: lyric?.lines ?? 0, + isFullLyricsCurrent: (lyric?.lines ?? 0) > 0, + isFullLyricsReceiving: transferState == "receiving", + currentWord: CurrentWordDiagnosticSnapshot( + lineIndex: 1, + wordIndex: 2, + pushCount: 10, + dropCount: 1, + averageUpdateIntervalMs: 120, + lastLatencyMs: 30, + automaticSyncEnabled: true, + automaticCompensationMs: 20, + manualFineTuneMs: 0, + legacyFallbackMs: 0, + clockBestRoundTripMs: 25, + clockOffsetJitterMs: 3, + clockSampleCount: 5, + clockSyncConfident: true + ), + connection: connection, + selfHealing: selfHealing + ) + } + + private func makeFullLyricsCacheEntry( + trackId: String, + savedAt: Date + ) -> FullLyricsCacheEntry { + FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: trackId, + title: "Song", + artist: "Artist", + album: "Album", + lines: [ + .init( + index: 0, + timeMs: 0, + durationMs: 1_000, + text: "line", + translation: "translation", + romanization: "romanization", + words: [ + .init(id: 0, startMs: 0, durationMs: 1_000, text: "line") + ] + ) + ], + savedAt: savedAt + ) + } + + private func loadLyricsCache( + _ store: FullLyricsCacheStore, + trackId: String, + title: String, + artist: String, + now: Date + ) async -> FullLyricsCacheEntry? { + await withCheckedContinuation { continuation in + store.load( + trackId: trackId, + title: title, + artist: artist, + now: now + ) { entry in + continuation.resume(returning: entry) + } + } + } + + private func makePlaybackHistorySession( + id: Int64, + title: String + ) -> PlaybackHistorySession { + PlaybackHistorySession( + sessionId: id, + trackKey: "track-\(id)", + title: title, + artist: "Artist", + album: "Album", + artworkId: nil, + startedAt: 1_723_500_000_000 + id, + endedAt: 1_723_500_060_000 + id, + listenedMs: 60_000, + durationMs: 180_000, + completed: false, + skipped: false, + countedPlay: true + ) + } + + private func mergePlaybackHistory( + _ store: PlaybackHistoryStore, + sessions: [PlaybackHistorySession] + ) async -> [PlaybackHistorySession] { + await withCheckedContinuation { continuation in + store.mergeSessions(sessions) { result in + continuation.resume(returning: result) + } + } + } + + private func loadPlaybackHistorySessions( + _ store: PlaybackHistoryStore + ) async -> [PlaybackHistorySession] { + await withCheckedContinuation { continuation in + store.loadSessions { continuation.resume(returning: $0) } + } + } + + private func loadPlaybackSyncState( + _ store: PlaybackHistoryStore + ) async -> PlaybackHistorySyncState { + await withCheckedContinuation { continuation in + store.loadSyncState { continuation.resume(returning: $0) } + } + } + + private func loadPlaybackStats( + _ store: PlaybackHistoryStore + ) async -> [String: PlaybackStatsSnapshot] { + await withCheckedContinuation { continuation in + store.loadStats { continuation.resume(returning: $0) } + } + } + + private func clearPlaybackHistory(_ store: PlaybackHistoryStore) async { + await withCheckedContinuation { continuation in + store.clear { continuation.resume() } + } + } + + private func loadLastNowPlaying( + _ store: LastNowPlayingSnapshotStore, + now: Date + ) async -> LastNowPlayingSnapshot? { + await withCheckedContinuation { continuation in + store.load(now: now) { continuation.resume(returning: $0) } + } + } + + private func readRecentAppLogs(_ store: AppLogStore) async -> String { + await withCheckedContinuation { continuation in + store.readRecentText { continuation.resume(returning: $0) } + } + } + + private func clearAppLogs(_ store: AppLogStore) async { + await withCheckedContinuation { continuation in + store.clear { continuation.resume() } + } + } + + private func clearTimelineLogs(_ store: AppLogStore) async { + await withCheckedContinuation { continuation in + store.clearTimeline { continuation.resume() } + } + } + + private func packet( + magic: UInt8, + kind: UInt8, + index: Int, + total: Int, + payload: Data + ) -> Data { + var data = Data([ + magic, + kind, + UInt8((index >> 8) & 0xff), + UInt8(index & 0xff), + UInt8((total >> 8) & 0xff), + UInt8(total & 0xff) + ]) + data.append(payload) + return data + } + + private func dataFromHex(_ text: String) -> Data { + var result = Data() + var index = text.startIndex + while index < text.endIndex { + let next = text.index(index, offsetBy: 2) + result.append(UInt8(text[index.. Void)? + var onLog: ((String) -> Void)? + + var albumArtCurrentTrackID: String { trackID } + var albumArtCurrentTitle: String { "Test Track" } + var albumArtConnectionStatus: String { "已连接" } + var albumArtConnectionDisplayState: String { "connected" } + var albumArtConnectionHealthState: String { "healthy" } + var albumArtCharacteristicReady: Bool { true } + var albumArtIsBusyForHqRequest: Bool { false } + + func albumArtLog(_ message: String) { + onLog?(message) + } + + func albumArtConsoleLog(_ message: String) {} + + func albumArtSendCommand(cmd: String, extra: [String: Any]) { + commands.append((cmd, extra)) + onCommand?(cmd, extra) + } + + func albumArtEffectiveHqDelay( + _ delay: TimeInterval + ) -> (delay: TimeInterval, deferred: Bool) { + (delay, false) + } + + func albumArtPublishLiveArtwork(image: UIImage, key: String, reason: String) {} + func albumArtClearLiveArtwork(reason: String, shouldUpdate: Bool) {} + func albumArtUpdateLiveActivity(force: Bool, reason: String) {} +} diff --git a/IOSBleFeasibility/SonyMusicLiveActivityExtension/SonyMusicLiveActivityWidget.swift b/IOSBleFeasibility/SonyMusicLiveActivityExtension/SonyMusicLiveActivityWidget.swift index 6b5cace..988586d 100644 --- a/IOSBleFeasibility/SonyMusicLiveActivityExtension/SonyMusicLiveActivityWidget.swift +++ b/IOSBleFeasibility/SonyMusicLiveActivityExtension/SonyMusicLiveActivityWidget.swift @@ -15,10 +15,8 @@ struct SonyMusicLiveActivityWidget: Widget { let visualState = DynamicIslandPlaybackVisualState.resolve(state: context.state) return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - LiveActivityArtworkView( - artworkKey: context.state.artworkKey, - artworkRevision: context.state.artworkRevision, - size: 44 + ExpandedStyleLeadingView( + state: context.state ) } DynamicIslandExpandedRegion(.trailing) { @@ -34,14 +32,20 @@ struct SonyMusicLiveActivityWidget: Widget { ExpandedBottomView(state: context.state) } } compactLeading: { - CompactArtworkProgressView(state: context.state, size: 26) + CompactStyleLeadingView( + state: context.state + ) } compactTrailing: { CompactPlaybackGlyphView( visualState: visualState, size: 18 ) } minimal: { - MinimalPlaybackGlyphView(visualState: visualState) + LiveActivityArtworkView( + artworkKey: context.state.artworkKey, + artworkRevision: context.state.artworkRevision, + size: 18 + ) } .keylineTint(visualState.accentColor) .widgetURL(URL(string: "sonymusic://nowplaying")) @@ -49,6 +53,26 @@ struct SonyMusicLiveActivityWidget: Widget { } } +private struct ExpandedStyleLeadingView: View { + let state: SonyMusicActivityAttributes.ContentState + + var body: some View { + LiveActivityArtworkView( + artworkKey: state.artworkKey, + artworkRevision: state.artworkRevision, + size: 44 + ) + } +} + +private struct CompactStyleLeadingView: View { + let state: SonyMusicActivityAttributes.ContentState + + var body: some View { + CompactArtworkProgressView(state: state, size: 26) + } +} + private enum DynamicIslandPlaybackVisualState: String { case playing case paused @@ -142,15 +166,15 @@ private extension DynamicIslandPlaybackVisualState { var accentColor: Color { switch self { case .playing: - return Color(red: 0.29, green: 0.95, blue: 0.42) + return Color(red: 0.47, green: 0.86, blue: 0.76) case .paused: - return Color(red: 0.25, green: 0.57, blue: 1.0) + return Color(red: 0.47, green: 0.86, blue: 0.76) case .loading: return Color(red: 0.55, green: 0.60, blue: 0.68) case .reconnecting: - return Color(red: 0.55, green: 0.60, blue: 0.68) + return Color(red: 0.95, green: 0.71, blue: 0.43) case .disconnected: - return Color(red: 0.72, green: 0.36, blue: 0.34) + return Color(red: 0.93, green: 0.47, blue: 0.45) case .unavailable: return Color(red: 0.70, green: 0.43, blue: 1.0) } @@ -457,19 +481,6 @@ private struct CompactPlaybackGlyphView: View { } } -private struct MinimalPlaybackGlyphView: View { - let visualState: DynamicIslandPlaybackVisualState - - var body: some View { - PlaybackGlyphContent( - visualState: visualState, - size: 13, - iconScale: 0.62, - xOffset: 0 - ) - } -} - private struct PlaybackGlyphContent: View { let visualState: DynamicIslandPlaybackVisualState let size: CGFloat @@ -649,21 +660,24 @@ private struct TrackSummaryView: View { .minimumScaleFactor(0.75) .truncationMode(.tail) - Text(state.artist) - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.68)) - .lineLimit(1) - .minimumScaleFactor(0.75) - .truncationMode(.tail) - - Text(lyricState.text) - .font(.caption2.weight(.semibold)) - .foregroundStyle(lyricForegroundStyle) - .lineLimit(1) - .minimumScaleFactor(0.76) - .truncationMode(.tail) - - if dynamicIslandStyle == .waveformFocused { + if dynamicIslandStyle == .compactDefault { + Text(state.artist) + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.68)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .truncationMode(.tail) + } else if dynamicIslandStyle == .lyricFocused { + Text(lyricState.text) + .font(.caption.weight(.semibold)) + .foregroundStyle(lyricForegroundStyle) + .lineLimit(2) + .minimumScaleFactor(0.76) + .truncationMode(.tail) + } else { + Text(visualState.statusText) + .font(.caption2.weight(.semibold)) + .foregroundStyle(visualState.accentColor.opacity(0.90)) DynamicIslandStatusStrip( visualState: visualState, width: 96, diff --git a/PlayerAgentApp/build.gradle b/PlayerAgentApp/build.gradle index 1dde356..8221927 100644 --- a/PlayerAgentApp/build.gradle +++ b/PlayerAgentApp/build.gradle @@ -26,6 +26,7 @@ android { dependencies { implementation "androidx.room:room-runtime:2.6.1" + implementation "androidx.recyclerview:recyclerview:1.3.2" kapt "androidx.room:room-compiler:2.6.1" testImplementation "junit:junit:4.13.2" testImplementation "org.json:json:20240303" diff --git a/PlayerAgentApp/src/debug/AndroidManifest.xml b/PlayerAgentApp/src/debug/AndroidManifest.xml index d51f8c2..6e5239e 100644 --- a/PlayerAgentApp/src/debug/AndroidManifest.xml +++ b/PlayerAgentApp/src/debug/AndroidManifest.xml @@ -10,6 +10,7 @@ + { + forwardToService( + context = context, + serviceAction = PlayerAgentForegroundService.ACTION_REFRESH_CURRENT_LYRIC, + logAction = "REFRESH_CURRENT_LYRIC" + ) + } else -> Log.w(TAG, "[DebugControl] ignored action=$action") } } @@ -74,6 +81,8 @@ class PlayerAgentDebugControlReceiver : BroadcastReceiver() { "com.example.playeragent.debug.RECOVER_BLE_STACK" const val ACTION_DUMP_BLE_DIAGNOSTICS = "com.example.playeragent.debug.DUMP_BLE_DIAGNOSTICS" + const val ACTION_REFRESH_CURRENT_LYRIC = + "com.example.playeragent.debug.REFRESH_CURRENT_LYRIC" private const val TAG = "PlayerAgent" } } diff --git a/PlayerAgentApp/src/main/AndroidManifest.xml b/PlayerAgentApp/src/main/AndroidManifest.xml index 655e5d3..b97ce35 100644 --- a/PlayerAgentApp/src/main/AndroidManifest.xml +++ b/PlayerAgentApp/src/main/AndroidManifest.xml @@ -41,7 +41,8 @@ + android:exported="true" + android:launchMode="singleTop"> diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt index c368925..c824485 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt @@ -6,6 +6,7 @@ import android.app.AlertDialog import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -25,7 +26,6 @@ import android.widget.Button import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout -import android.widget.ScrollView import android.widget.TextView import com.example.playeragent.ble.ControllerScannerManager import com.example.playeragent.ble.BleHealthState @@ -58,10 +58,21 @@ import com.example.playeragent.media.QrcV2RebuildStatus import com.example.playeragent.media.QrcWatcherStatus import com.example.playeragent.service.PlayerAgentForegroundService import com.example.playeragent.service.QQMusicLyricAccessibilityService +import com.example.playeragent.ui.SonyPlayerUiState +import com.example.playeragent.ui.PlayerLogAdapter +import com.example.playeragent.ui.PlayerAgentNavigationView +import com.example.playeragent.ui.PlayerAgentProductUiState +import com.example.playeragent.ui.PlayerAgentUiInputs +import com.example.playeragent.ui.PlayerAgentUiStateMapper +import com.example.playeragent.ui.SafeRepairAction +import com.example.playeragent.ui.SetupAction +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import java.text.SimpleDateFormat import java.security.MessageDigest import java.util.Date import java.util.Locale +import java.util.concurrent.Executors class MainActivity : Activity() { @@ -69,9 +80,11 @@ class MainActivity : Activity() { private lateinit var bleStatusTextView: TextView private lateinit var accessibilityStatusTextView: TextView private lateinit var currentSongTextView: TextView + private lateinit var currentArtistAlbumTextView: TextView private lateinit var currentLyricTextView: TextView private lateinit var currentAlbumArtImageView: ImageView private lateinit var currentAlbumArtTextView: TextView + private lateinit var suggestedActionButton: Button private lateinit var artworkDiscoveryStatusTextView: TextView private lateinit var qrcCacheBuildTextView: TextView private lateinit var qrcStaleCacheRebuildTextView: TextView @@ -80,8 +93,8 @@ class MainActivity : Activity() { private lateinit var qrcCacheOverviewTextView: TextView private lateinit var maintenanceStatusTextView: TextView private lateinit var historyStatusTextView: TextView - private lateinit var logTextView: TextView - private lateinit var logScrollView: ScrollView + private lateinit var logRecyclerView: RecyclerView + private lateinit var logAdapter: PlayerLogAdapter private lateinit var logSectionBody: LinearLayout private lateinit var buildTaskStatusTextView: TextView private lateinit var buildTaskBadgeTextView: TextView @@ -99,6 +112,8 @@ class MainActivity : Activity() { private lateinit var currentTrackDebugStatusTextView: TextView private lateinit var currentTrackBadgeTextView: TextView private lateinit var maintenanceSummaryTextView: TextView + private lateinit var productNavigationView: PlayerAgentNavigationView + private var lastProductUiState: PlayerAgentProductUiState? = null private var bluetoothAdapter: BluetoothAdapter? = null private var controllerScannerManager: ControllerScannerManager? = null private var rfcommClientManager: RfcommClientManager? = null @@ -110,6 +125,9 @@ class MainActivity : Activity() { private var qrcStaleCacheRebuildManager: QrcStaleCacheRebuildManager? = null private var lyricWarmupManager: LyricWarmupManager? = null private val mainHandler = Handler(Looper.getMainLooper()) + private val uiMediaExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "PlayerAgent-UiMediaIO") + } @Volatile private var debugStatsLoading = false @Volatile private var lastDebugStatsLoadAt = 0L private var debugStatsLoadRunnable: Runnable? = null @@ -117,6 +135,7 @@ class MainActivity : Activity() { private var lastPlayerUiLyric: String = "" private var lastPlayerUiLyricStatus: String = "" private var lastPlayerUiAlbumArtStatus: String = "未检测" + private var lastSonyPlayerUiState = SonyPlayerUiState() private var albumArtRefreshGeneration = 0L private var controlServiceStatusRefreshRunnable: Runnable? = null private var lastQrcV2BuildProgress = QrcV2RebuildProgress() @@ -202,7 +221,6 @@ class MainActivity : Activity() { refreshControlServiceStatus() appendLog("[PlayerAgent] UI ready") appendLog("[UI] first lightweight render ready costMs=${System.currentTimeMillis() - startedAt}") - scheduleDebugStatsLoad() refreshCurrentMedia() } @@ -244,6 +262,7 @@ class MainActivity : Activity() { artworkDiscoveryManager?.shutdown() controllerScannerManager?.stopScan() rfcommClientManager?.close() + uiMediaExecutor.shutdownNow() super.onDestroy() } @@ -270,122 +289,147 @@ class MainActivity : Activity() { } private fun setupUi() { - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(Color.rgb(18, 18, 20)) - setPadding(dp(20), dp(24), dp(20), dp(32)) - } - - content.addView(TextView(this).apply { - text = "PlayerAgent" - textSize = 26f - setTextColor(Color.WHITE) - setPadding(0, 0, 0, dp(2)) - }) - content.addView(TextView(this).apply { - text = "索尼音乐播放器控制服务" - textSize = 14f - setTextColor(Color.rgb(165, 170, 178)) - setPadding(0, 0, 0, dp(14)) - }) - - val serviceCard = collapsibleCard(content, "服务状态", expanded = true) - statusTextView = statusValue("Service:未启动") - serviceCard.addView(statusTextView) - bleStatusTextView = statusValue("BLE:未启动") - serviceCard.addView(bleStatusTextView) - accessibilityStatusTextView = statusValue("通知读取权限:未知") - serviceCard.addView(accessibilityStatusTextView) - serviceCard.addView(buttonGrid(listOf( - "启动控制服务" to ::startPlayerAgentService, - "停止控制服务" to ::stopPlayerAgentService, - "恢复蓝牙服务" to ::recoverBleStack, - "打开通知读取权限" to ::openNotificationAccessSettings, - "打开无障碍设置" to ::openAccessibilitySettings - ))) - - val playerCard = collapsibleCard(content, "当前播放", expanded = true) - currentAlbumArtImageView = ImageView(this).apply { - scaleType = ImageView.ScaleType.CENTER_CROP - background = roundedBackground(Color.rgb(42, 44, 50), dp(16)) - setImageResource(android.R.drawable.ic_media_play) - } - playerCard.addView( - currentAlbumArtImageView, - LinearLayout.LayoutParams(dp(150), dp(150)).apply { - gravity = Gravity.CENTER_HORIZONTAL - bottomMargin = dp(10) - } + productNavigationView = PlayerAgentNavigationView( + context = this, + onSetupAction = ::handleSetupAction, + onSafeRepair = ::performSafeRepair, + onRelinkLyrics = ::refreshCurrentLyric ) - currentAlbumArtTextView = statusValue("封面:未检测").also(playerCard::addView) - currentSongTextView = statusValue("歌曲:未检测").also(playerCard::addView) - currentLyricTextView = statusValue("当前歌词:暂无歌词").also(playerCard::addView) - - val historyCard = collapsibleCard(content, "播放历史与收听统计", expanded = false) - historyStatusTextView = statusValue("播放历史:未刷新").also(historyCard::addView) - historyCard.addView(buttonGrid(listOf( - "刷新历史状态" to ::refreshHistoryStatus, - "显示最近 10 条" to ::showRecentHistory, - "显示今日统计" to ::showTodayHistoryStats, - "清空播放历史" to { confirmAction("清空 Sony 本地播放历史?", ::clearPlayHistory) } + val views = productNavigationView.build() + currentAlbumArtImageView = views.currentAlbumArt + currentAlbumArtTextView = views.currentAlbumArtStatus + currentSongTextView = views.currentSong + currentArtistAlbumTextView = views.currentArtistAlbum + currentLyricTextView = views.currentLyric + suggestedActionButton = views.suggestedAction + accessibilityStatusTextView = views.accessibilityStatus + + statusTextView = statusValue("Service:未启动").also(views.linkHost::addView) + bleStatusTextView = statusValue("BLE:未启动").also(views.linkHost::addView) + views.linkHost.addView(buttonGrid(listOf( + "启动服务" to ::startPlayerAgentService, + "恢复 BLE" to ::recoverBleStack, + "扫描控制端" to ::startControllerScan, + "经典蓝牙兼容" to ::connectRfcommServer, + "通知读取权限" to ::openNotificationAccessSettings ))) - val lyricCard = collapsibleCard(content, "歌词缓存与逐字时间", expanded = false) - setupMaintenancePanel(lyricCard) - - val connectionCard = collapsibleCard(content, "连接与兼容", expanded = false) - connectionCard.addView(buttonGrid(listOf( - "扫描控制端" to ::startControllerScan, - "连接经典蓝牙控制端" to ::connectRfcommServer, - "恢复蓝牙服务" to ::recoverBleStack, - "启动 QRC 监听器" to ::startQrcWatcher, - "停止 QRC 监听器" to ::stopQrcWatcher + views.lyricsHost.addView(buttonGrid(listOf( + "扫描歌词文件" to ::scanLrcFiles, + "检测歌词来源" to ::testLyricSource, + "测试当前歌词" to ::testCurrentLyric, + "歌词解析诊断" to ::testLrcDebug, + "解析首个 QRC" to ::dumpFirstQrc, + "启动 QRC 监听" to ::startQrcWatcher, + "停止 QRC 监听" to ::stopQrcWatcher, + "无障碍诊断" to ::dumpAccessibilityTree, + "打开无障碍设置" to ::openAccessibilitySettings ))) + setupMaintenancePanel(views.lyricsHost) + scheduleDebugStatsLoad() - val mediaCard = collapsibleCard(content, "媒体诊断", expanded = false) artworkDiscoveryStatusTextView = statusValue( ArtworkDiscoveryStatus().displayText() - ).also(mediaCard::addView) - mediaCard.addView(buttonGrid(listOf( - "扫描歌词文件" to ::scanLrcFiles, + ).also(views.artworkHost::addView) + views.artworkHost.addView(buttonGrid(listOf( "测试当前封面" to ::testAlbumArt, "发现高清封面" to ::discoverHqArtwork, - "停止封面发现" to ::stopArtworkDiscovery, - "检测歌词来源" to ::testLyricSource, - "测试当前歌词" to ::testCurrentLyric, - "歌词解析诊断" to ::testLrcDebug, - "解析首个 QRC 文件" to ::dumpFirstQrc, - "无障碍节点诊断" to ::dumpAccessibilityTree, - "刷新歌词统计" to ::refreshLyricStats, - "重置歌词统计" to ::resetLyricStats + "停止封面发现" to ::stopArtworkDiscovery ))) - logSectionBody = collapsibleCard(content, "日志", expanded = false) - logSectionBody.addView(buttonGrid(listOf( + historyStatusTextView = statusValue("播放历史:进入后按需加载") + .also(views.logsHost::addView) + views.logsHost.addView(buttonGrid(listOf( + "刷新历史" to ::refreshHistoryStatus, + "最近 10 条" to ::showRecentHistory, + "今日统计" to ::showTodayHistoryStats, + "清空历史" to { confirmAction("清空 Sony 本地播放历史?", ::clearPlayHistory) } + ))) + logSectionBody = views.logsHost + views.logsHost.addView(buttonGrid(listOf( "显示/隐藏日志" to ::toggleLogs, "导出日志" to ::exportLog, "清空日志" to ::clearLog ))) - logTextView = TextView(this).apply { - textSize = 12f - setTextColor(Color.rgb(215, 218, 224)) - setTextIsSelectable(true) - setPadding(dp(10), dp(10), dp(10), dp(10)) + logAdapter = PlayerLogAdapter().apply { + replace(LogBuffer.getRecentLogs(PlayerLogAdapter.MAX_ROWS)) } - logScrollView = ScrollView(this).apply { + logRecyclerView = RecyclerView(this).apply { visibility = View.GONE - background = roundedBackground(Color.rgb(24, 25, 29), dp(12)) - addView(logTextView) - } - logSectionBody.addView( - logScrollView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - dp(260) + background = roundedBackground(Color.rgb(24, 34, 48), dp(12)) + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = logAdapter + setHasFixedSize(true) + } + views.logsHost.addView(logRecyclerView, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, dp(260) + )) + + setContentView(views.root) + refreshProductUi() + } + + private fun refreshProductUi() { + if (!::productNavigationView.isInitialized) return + val snapshot = PlayerAgentForegroundService.bleHealthSnapshot() + val state = PlayerAgentUiStateMapper.map( + PlayerAgentUiInputs( + healthState = snapshot.healthState, + serviceRunning = PlayerAgentForegroundService.isRunning(), + notificationAccess = hasNotificationAccess(), + fileAccess = hasLyricStorageAccess(), + accessibilityEnabled = QQMusicLyricAccessibilityService.isEnabled(this), + runtimePermissions = hasRequiredPermissions(), + hasPlayback = lastPlayerUiSongKey.isNotBlank(), + lyricStatus = lastPlayerUiLyricStatus, + artworkStatus = lastPlayerUiAlbumArtStatus ) ) + lastProductUiState = state + productNavigationView.render(state) + } + + private fun handleSetupAction(action: SetupAction) { + when (action) { + SetupAction.NONE -> Unit + SetupAction.OPEN_NOTIFICATION_ACCESS -> openNotificationAccessSettings() + SetupAction.OPEN_FILE_ACCESS -> ensureLyricStorageAccess() + SetupAction.OPEN_ACCESSIBILITY -> openAccessibilitySettings() + SetupAction.REQUEST_RUNTIME_PERMISSIONS -> ensureRequiredPermissions() + SetupAction.START_SERVICE -> startPlayerAgentService() + } + } + + private fun performSafeRepair() { + refreshProductUi() + when (lastProductUiState?.safeRepairActions?.firstOrNull()) { + SafeRepairAction.REQUEST_RUNTIME_PERMISSIONS -> ensureRequiredPermissions() + SafeRepairAction.OPEN_NOTIFICATION_ACCESS -> openNotificationAccessSettings() + SafeRepairAction.OPEN_FILE_ACCESS -> ensureLyricStorageAccess() + SafeRepairAction.OPEN_ACCESSIBILITY -> openAccessibilitySettings() + SafeRepairAction.START_SERVICE -> startPlayerAgentService() + SafeRepairAction.RECOVER_BLE -> recoverBleStack() + null -> appendLog("[ProductUI] safe repair check complete; no action needed") + } + } - setContentView(ScrollView(this).apply { addView(content) }) + private fun hasNotificationAccess(): Boolean { + val listeners = Settings.Secure.getString( + contentResolver, + "enabled_notification_listeners" + ).orEmpty() + return listeners.split(':') + .mapNotNull(ComponentName::unflattenFromString) + .any { it.packageName == packageName } + } + + private fun hasLyricStorageAccess(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED + } } private fun setupMaintenancePanel(parent: LinearLayout) { @@ -858,45 +902,6 @@ class MainActivity : Activity() { } } - private fun collapsibleCard( - parent: LinearLayout, - title: String, - expanded: Boolean - ): LinearLayout { - val card = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = roundedBackground(Color.rgb(30, 31, 36), dp(18)) - setPadding(dp(14), dp(12), dp(14), dp(14)) - } - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - visibility = if (expanded) View.VISIBLE else View.GONE - } - val header = TextView(this).apply { - text = if (expanded) "▼ $title" else "▶ $title" - textSize = 17f - setTextColor(Color.WHITE) - setPadding(0, 0, 0, dp(10)) - setOnClickListener { - val nowExpanded = body.visibility != View.VISIBLE - body.visibility = if (nowExpanded) View.VISIBLE else View.GONE - text = if (nowExpanded) "▼ $title" else "▶ $title" - } - } - card.addView(header) - card.addView(body) - parent.addView( - card, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - bottomMargin = dp(12) - } - ) - return body - } - private fun buttonGrid(items: List Unit>>): GridLayout { return GridLayout(this).apply { columnCount = 2 @@ -1017,6 +1022,7 @@ class MainActivity : Activity() { val serviceRunning = PlayerAgentForegroundService.isRunning() setControlServiceStatus(if (serviceRunning) "运行中" else "未启动") setBleStatus(PlayerAgentForegroundService.bleHealthSnapshot()) + refreshProductUi() } private fun setControlServiceStatus(status: String) { @@ -1066,7 +1072,7 @@ class MainActivity : Activity() { } private fun refreshCurrentMedia() { - Thread { + uiMediaExecutor.execute { val state = try { PlaybackStateReader( context = this, @@ -1116,9 +1122,6 @@ class MainActivity : Activity() { } refreshMaintenanceUiState() } - }.apply { - name = "MainStatusRefreshThread" - start() } } @@ -1162,6 +1165,19 @@ class MainActivity : Activity() { val safeAlbum = album.ifBlank { "-" } val songKey = "$safeTitle|$safeArtist|$safeAlbum" val songChanged = songKey != lastPlayerUiSongKey + val lyricText = lyric.ifBlank { "暂无歌词" } + val rawStatus = lyricStatus.ifBlank { "unknown" } + val nextUiState = lastSonyPlayerUiState.copy( + playback = SonyPlayerUiState.PlaybackState( + title = safeTitle, + artist = safeArtist, + album = safeAlbum + ), + lyrics = SonyPlayerUiState.LyricsState( + text = lyricText, + rawStatus = rawStatus + ) + ) if (songChanged) { lastPlayerUiSongKey = songKey appendLog( @@ -1176,27 +1192,41 @@ class MainActivity : Activity() { } } - currentSongTextView.text = "歌曲名:$safeTitle\n歌手:$safeArtist\n专辑:$safeAlbum" - .replace("歌曲名:-\n歌手:-\n专辑:-", "歌曲:未检测") - val lyricText = lyric.ifBlank { "暂无歌词" } - val statusText = lyricStatus.ifBlank { "unknown" } - lastPlayerUiLyricStatus = statusText - val hint = if (statusText == "waiting QQMusic lyric cache") { - "\n提示:QQ音乐可能尚未生成歌词缓存,可在 QQ音乐中打开歌词/桌面歌词后稍等。" - } else { - "" + if (nextUiState.playback != lastSonyPlayerUiState.playback) { + val hasPlayback = safeTitle != "-" || safeArtist != "-" || safeAlbum != "-" + currentSongTextView.text = if (hasPlayback) { + safeTitle + } else { + getString(R.string.home_no_playback) + } + currentArtistAlbumTextView.text = if (hasPlayback) { + getString(R.string.home_artist_album, safeArtist, safeAlbum) + } else { + "" + } } - currentLyricTextView.text = "当前歌词:$lyricText\nLyric status:$statusText$hint" - if (lyricText != lastPlayerUiLyric) { + if (nextUiState.lyrics != lastSonyPlayerUiState.lyrics) { + lastPlayerUiLyricStatus = rawStatus + currentLyricTextView.text = + "当前歌词:$lyricText\n${nextUiState.lyrics.displayStatus}" + suggestedActionButton.visibility = if (nextUiState.lyrics.needsRelink) { + View.VISIBLE + } else { + View.GONE + } + } + if (lyricText != lastPlayerUiLyric && lyricText != "暂无歌词") { lastPlayerUiLyric = lyricText appendLog("[PlayerUI] lyric changed text=$lyricText") } - refreshMaintenanceUiState() + lastSonyPlayerUiState = nextUiState + refreshProductUi() + if (songChanged) refreshMaintenanceUiState() } private fun refreshCurrentAlbumArtForSong(songKey: String) { val generation = ++albumArtRefreshGeneration - Thread { + uiMediaExecutor.execute { val albumArt = try { AlbumArtTestManager( context = this, @@ -1233,10 +1263,8 @@ class MainActivity : Activity() { appendLog("[PlayerUI] album art changed exists=false") } refreshMaintenanceUiState() + refreshProductUi() } - }.apply { - name = "MainAlbumArtRefreshThread" - start() } } @@ -2032,10 +2060,14 @@ class MainActivity : Activity() { } private fun toggleLogs() { - logScrollView.visibility = if (logScrollView.visibility == View.VISIBLE) { - View.GONE - } else { + val show = logRecyclerView.visibility != View.VISIBLE + logRecyclerView.visibility = if (show) { + val logs = LogBuffer.getRecentLogs(PlayerLogAdapter.MAX_ROWS) + logAdapter.replace(logs) + if (logs.isNotEmpty()) logRecyclerView.scrollToPosition(logs.lastIndex) View.VISIBLE + } else { + View.GONE } } @@ -2087,6 +2119,7 @@ class MainActivity : Activity() { enabled -> "通知读取权限:无障碍已授权,等待连接" else -> "通知读取权限:未授权" } + refreshProductUi() } private fun ensureLyricStorageAccess(): Boolean { @@ -2136,7 +2169,7 @@ class MainActivity : Activity() { private fun clearLog() { LogBuffer.clear() - logTextView.text = "" + if (::logAdapter.isInitialized) logAdapter.replace(emptyList()) appendLog("[PlayerAgent] Log cleared") } @@ -2175,6 +2208,7 @@ class MainActivity : Activity() { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == REQUEST_PERMISSIONS) { maybeAutoStartControlService("permissions_result") + refreshProductUi() } } @@ -2201,13 +2235,17 @@ class MainActivity : Activity() { if (storeInBuffer) { LogBuffer.append(message) } - if (!::logTextView.isInitialized || !::logScrollView.isInitialized) { + if (!::logRecyclerView.isInitialized || !::logAdapter.isInitialized) { android.util.Log.i("PlayerAgent", message) return } - logTextView.append("$message\n") - logScrollView.post { - logScrollView.fullScroll(View.FOCUS_DOWN) + if (logRecyclerView.visibility != View.VISIBLE) { + return + } + val logs = LogBuffer.getRecentLogs(PlayerLogAdapter.MAX_ROWS) + logAdapter.replace(logs) + logRecyclerView.post { + if (logs.isNotEmpty()) logRecyclerView.scrollToPosition(logs.lastIndex) } } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/AlbumArtRequestPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/AlbumArtRequestPolicy.kt new file mode 100644 index 0000000..24499ec --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/AlbumArtRequestPolicy.kt @@ -0,0 +1,30 @@ +package com.example.playeragent.ble + +/** + * Completed artwork transfers may be requested again when the client lost its + * local cache or explicitly asks for a refresh. Only very recent duplicate + * commands are suppressed; in-flight requests are guarded separately by + * [BleGattServerManager]. + */ +internal object AlbumArtRequestPolicy { + const val COMPLETED_REQUEST_COOLDOWN_MS = 750L + + fun shouldAllowCompletedRequest( + lastCompletedAtMs: Long?, + nowMs: Long, + forceRefresh: Boolean + ): Boolean { + if (forceRefresh || lastCompletedAtMs == null) { + return true + } + val elapsedMs = nowMs - lastCompletedAtMs + return elapsedMs < 0L || elapsedMs >= COMPLETED_REQUEST_COOLDOWN_MS + } + + fun shouldResendOfferAfterRecovery( + recoveredFromUnavailable: Boolean, + hadWaitingClientRequest: Boolean + ): Boolean { + return recoveredFromUnavailable && !hadWaitingClientRequest + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleAdvertiserManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleAdvertiserManager.kt index 9ddfa0f..0cef73a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleAdvertiserManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleAdvertiserManager.kt @@ -98,6 +98,10 @@ class BleAdvertiserManager( val advertiseData = AdvertiseData.Builder() .setIncludeDeviceName(true) + // CoreBluetooth service-filtered scans may discard a legacy + // advertisement before its scan response is merged. Keep A001 in + // the primary packet so a cold iOS scan can discover this server. + .addServiceUuid(ParcelUuid(PlayerAgentUuids.SERVICE_UUID)) .build() val scanResponse = AdvertiseData.Builder() @@ -107,7 +111,10 @@ class BleAdvertiserManager( try { updateState(AdvertisingState.STARTING) advertiser.startAdvertising(settings, advertiseData, scanResponse, advertiseCallback) - logger("BLE advertising start requested") + logger( + "BLE advertising start requested " + + "primaryService=${PlayerAgentUuids.SERVICE_UUID} scanResponseService=true" + ) } catch (securityException: SecurityException) { updateState(AdvertisingState.STOPPED) restartPending = false diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattPayloadPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattPayloadPolicy.kt new file mode 100644 index 0000000..f5e482e --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattPayloadPolicy.kt @@ -0,0 +1,16 @@ +package com.example.playeragent.ble + +/** + * GATT attribute values are limited to 512 bytes even when Android reports the + * maximum ATT MTU of 517 (517 - 3 would otherwise incorrectly yield 514). + */ +internal object BleGattPayloadPolicy { + private const val ATT_HEADER_BYTES = 3 + const val MAX_ATTRIBUTE_VALUE_BYTES = 512 + + fun maximumNotificationPayload(mtu: Int): Int { + return (mtu - ATT_HEADER_BYTES) + .coerceAtLeast(0) + .coerceAtMost(MAX_ATTRIBUTE_VALUE_BYTES) + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt index b0e5dfd..2962fcc 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -11,8 +11,10 @@ import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.content.Context +import android.content.ComponentCallbacks2 import android.graphics.Bitmap import android.os.Handler +import android.os.HandlerThread import android.os.Looper import android.os.SystemClock import android.util.Base64 @@ -20,6 +22,7 @@ import com.example.playeragent.history.HistorySessionRow import com.example.playeragent.history.PlaybackHistoryRepository import com.example.playeragent.history.PlaybackStatsSummary import com.example.playeragent.history.StatsRange +import com.example.playeragent.media.AlbumArtPlaceholderPolicy import com.example.playeragent.media.AlbumArtTestManager import com.example.playeragent.logging.LogConfig import com.example.playeragent.logging.LogBuffer @@ -30,6 +33,7 @@ import com.example.playeragent.media.CurrentTrackSnapshot import com.example.playeragent.media.IncrementalLyricsReady import com.example.playeragent.media.LyricTraceLogger import com.example.playeragent.media.LyricsReadyGateSnapshot +import com.example.playeragent.media.PlayerAgentExecutionHub import com.example.playeragent.media.PlaybackStateReader import com.example.playeragent.media.PlaybackStateDiffType import com.example.playeragent.media.ReactiveMediaController @@ -40,14 +44,16 @@ import org.json.JSONObject import java.io.ByteArrayOutputStream import java.io.File import java.security.MessageDigest +import java.util.Collections +import java.util.IdentityHashMap import java.util.LinkedHashMap import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference class BleGattServerManager( context: Context, @@ -57,11 +63,15 @@ class BleGattServerManager( private val verboseLogger: (String) -> Unit = logger, private val advertisingStateProvider: () -> String = { "unknown" }, private val onAllClientsDisconnected: (reason: String) -> Unit = {}, - private val onPlaybackUiState: (JSONObject) -> Unit = {} + private val onControllerConnectionCountChanged: (connectedCount: Int) -> Unit = {}, + private val onPlaybackUiState: (JSONObject) -> Unit = {}, + private val executionHub: PlayerAgentExecutionHub ) { private val appContext = context.applicationContext - private val connectedDeviceAddresses = ConcurrentHashMap.newKeySet() + private val connectedDeviceAddresses = Collections.newSetFromMap( + ConcurrentHashMap() + ) private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = ConcurrentHashMap() private val mtuByAddress = ConcurrentHashMap() @@ -72,6 +82,7 @@ class BleGattServerManager( context = appContext, logger = logger, reactiveMediaController = reactiveMediaController, + executionHub = executionHub, onLyricsReady = ::handleLyricsReady ) private val albumArtTestManager = AlbumArtTestManager( @@ -84,37 +95,33 @@ class BleGattServerManager( sendLine = { message -> sendStatusMessage(message) } ) private val mediaFieldDumpManager = MediaFieldDumpManager(appContext) - private val mediaFieldDumpExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "MediaFieldDumpThread") - } + private val mediaFieldDumpExecutor = executionHub.maintenance private val mediaFieldDumpPreparing = AtomicBoolean(false) - private val historyExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "PlaybackHistoryBleThread") - } + private val historyExecutor = executionHub.maintenance private val historyQueryPreparing = AtomicBoolean(false) - private val reconnectSyncExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "ReconnectStateSyncThread") - } - private val commandExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "BleCommandDispatchThread") - } - private val lyricCommandExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "BleLyricCommandThread") - } - private val albumArtFastPathExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "AlbumArtFastPathThread") - } - private val albumArtHandler = Handler(Looper.getMainLooper()) + private val reconnectSyncExecutor = executionHub.realtime + private val commandExecutor = executionHub.realtime + private val lyricCommandExecutor = executionHub.foregroundIO + private val albumArtFastPathExecutor = executionHub.foregroundIO + private val mediaCallbackThread = + HandlerThread("BleMedia-Callbacks").apply { start() } + // Artwork notifications, transfer completion, and delayed lyric retries + // must never contend with MainActivity's input loop on low-end Sony + // hardware. State transitions remain protected by the manager monitor. + private val albumArtHandler = Handler(mediaCallbackThread.looper) private val gattLifecycleHandler = Handler(Looper.getMainLooper()) private val qqMusicArtworkListener: (String) -> Unit = { event -> - albumArtHandler.post { retryCurrentAlbumArtAfterNotification(event) } + // Start the latency-sensitive state/lyrics path before artwork recovery. + // The latter may need notification bitmap I/O on slower Sony hardware. + schedulePlaybackUiRefresh("qq_notification") + albumArtHandler.post { + retryCurrentAlbumArtAfterNotification(event) + wakeAutoPushFromQqNotification() + } } + private val playbackUiRefreshInFlight = AtomicBoolean(false) + private val playbackUiRefreshPending = AtomicBoolean(false) + private val playbackUiRefreshReason = AtomicReference("initial") private var gattServer: BluetoothGattServer? = null private var statusCharacteristic: BluetoothGattCharacteristic? = null @@ -122,16 +129,24 @@ class BleGattServerManager( private var gattServiceGeneration = 0L private val forcedRediscoveryGenerationByAddress = ConcurrentHashMap() private var autoPushExecutor: ScheduledExecutorService? = null + private var autoPushTask: Future<*>? = null private var currentWordExecutor: ScheduledExecutorService? = null @Volatile private var currentWordPushTask: Future<*>? = null private val currentWordPushEngine = CurrentWordPushEngine( logger = logger, sendStatusMessage = { message -> sendStatusMessage(message) }, - normalizeTrackId = { trackId -> normalizeCurrentWordTrackId(trackId) } + normalizeTrackId = { trackId -> normalizeCurrentWordTrackId(trackId) }, + includeClockSyncFields = { + val addresses = subscribedDevices.keys.toList() + addresses.isNotEmpty() && addresses.all { address -> + connectionCommandCoordinator.capabilities(address).clockSyncV1 + } + } ) private val playbackStateBuffer = PlaybackStateBuffer( logger = logger, + scheduledExecutor = executionHub.scheduled, flush = { source, snapshot, diff, reason, coalesceCount -> flushBufferedPlaybackState(source, snapshot, diff, reason, coalesceCount) } @@ -149,13 +164,13 @@ class BleGattServerManager( private var lastAlbumArtKey: String? = null @Volatile private var currentAlbumArtId: String? = null - private val albumArtRequestsInFlight = ConcurrentHashMap.newKeySet() - private val albumArtRequestsCompleted = ConcurrentHashMap.newKeySet() + private val albumArtRequestsInFlight = Collections.newSetFromMap( + ConcurrentHashMap() + ) + private val albumArtRequestCompletedAtMs = ConcurrentHashMap() @Volatile private var pendingAlbumArt: PendingAlbumArt? = null @Volatile - private var albumArtScheduleGeneration = 0L - @Volatile private var albumArtTaskGeneration = 0L @Volatile private var albumArtFastPathTask: Future<*>? = null @@ -166,52 +181,33 @@ class BleGattServerManager( private val albumArtPendingRequests = ConcurrentHashMap() private val albumArtSourceRetryCounts = ConcurrentHashMap() - private val albumArtCache = object : - LinkedHashMap(ALBUM_ART_CACHE_CAPACITY, 0.75f, true) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry? - ): Boolean { - val shouldEvict = size > ALBUM_ART_CACHE_CAPACITY - if (shouldEvict && eldest != null) { - logger("[AlbumArtCache] evict key=${eldest.key} id=${eldest.value.protocolId}") - } - return shouldEvict - } - } + private val albumArtUnavailableProtocolIds = Collections.newSetFromMap( + ConcurrentHashMap() + ) + private val albumArtCache = + LinkedHashMap(ALBUM_ART_CACHE_CAPACITY * 2, 0.75f, true) private val encodedAlbumArtCache = LinkedHashMap(ENCODED_ART_CACHE_CAPACITY, 0.75f, true) + private val albumArtPreparationLocks = ConcurrentHashMap() private var encodedAlbumArtCacheBytes = 0 @Volatile private var currentAlbumArtPlaybackState: JSONObject? = null - @Volatile - private var pendingFullLyricsRequest: PendingFullLyricsRequest? = null - @Volatile - private var pendingLyricWindowRequest: PendingLyricWindowRequest? = null - @Volatile - private var recentFullLyricsBinaryTransfer: FullLyricsBinaryTransfer? = null - private val recentAlbumArtBinaryTransfers = - ConcurrentHashMap() + private val pendingFullLyricsRequests = + ConcurrentHashMap() + private val pendingLyricWindowRequests = + ConcurrentHashMap() + private val lyricsTransferCoordinator = LyricsTransferCoordinator() + private val albumArtTransferCoordinator = AlbumArtTransferCoordinator() + private val connectionCommandCoordinator = ConnectionCommandCoordinator() + private val v3SessionCoordinator = BleV3SessionCoordinator() + private val multiControllerCommandGate = MultiControllerCommandGate() private var lastAutoPushSongKey: String? = null private var lastAutoPushPlaying: Boolean? = null + @Volatile + private var lastAutoPushReadAtMs: Long = 0L private var lastPlaybackDiffSkipLogAtMs: Long = 0L private val reconnectSyncLastAtByAddress = ConcurrentHashMap() @Volatile - private var clientSupportsBinaryAlbumArt = false - @Volatile - private var clientProtocolVersion = 1 - @Volatile - private var clientSupportsFullLyricsZlib = false - @Volatile - private var clientSupportsLyricWindow = false - @Volatile - private var clientSupportsPing = false - @Volatile - private var clientSupportsTransferRetry = false - @Volatile - private var clientCapabilitiesNegotiated = false - @Volatile - private var capabilityNegotiationGeneration = 0L - @Volatile private var started = false @Volatile private var serverState = ServerState.STOPPED @@ -221,8 +217,12 @@ class BleGattServerManager( private var lastNotifySuccessAtMs = 0L @Volatile private var lastNotifyFailureAtMs = 0L - @Volatile - private var notifyFailureCount = 0 + private val notifyFailureCountByAddress = ConcurrentHashMap() + private val subscribedAtByAddress = ConcurrentHashMap() + private val lastCommandSuccessAtByAddress = ConcurrentHashMap() + private val lastNotifySuccessAtByAddress = ConcurrentHashMap() + private val lastNotifyFailureAtByAddress = ConcurrentHashMap() + private val lastLinkProbeAtByAddress = ConcurrentHashMap() @Volatile private var lastSubscribedAtMs = 0L @Volatile @@ -261,23 +261,46 @@ class BleGattServerManager( } if (newState == BluetoothProfile.STATE_CONNECTED && device != null) { - connectedDeviceAddresses.add(address) + val added = connectedDeviceAddresses.add(address) connectedDevices[address] = device mtuByAddress[address] = DEFAULT_MTU + notifyQueue.resetLinkProfile(address, DEFAULT_MTU) logger("[ReconnectSync] central connected device=$address") scheduleGattRediscoveryIfUnsubscribed( device = device, reason = "central_connected" ) + if (added) { + onControllerConnectionCountChanged(connectedDeviceAddresses.size) + } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { val hasUsableAddress = address.isNotBlank() && address != "unknown" + var connectionRemoved = false if (hasUsableAddress) { - connectedDeviceAddresses.remove(address) + connectionRemoved = connectedDeviceAddresses.remove(address) connectedDevices.remove(address) subscribedDevices.remove(address) forcedRediscoveryGenerationByAddress.remove(address) mtuByAddress.remove(address) notifyQueue.removeDevice(address) + notifyFailureCountByAddress.remove(address) + subscribedAtByAddress.remove(address) + lastCommandSuccessAtByAddress.remove(address) + lastNotifySuccessAtByAddress.remove(address) + lastNotifyFailureAtByAddress.remove(address) + lastLinkProbeAtByAddress.remove(address) + connectionCommandCoordinator.remove(address) + lyricsTransferCoordinator.resetAddress(address) + albumArtTransferCoordinator.resetAddress(address) + albumArtRequestsInFlight + .filter { it.startsWith("$address|") } + .forEach(albumArtRequestsInFlight::remove) + albumArtRequestCompletedAtMs.keys + .filter { it.startsWith("$address|") } + .forEach(albumArtRequestCompletedAtMs::remove) + albumArtPendingRequests.entries.removeAll { + it.value.device.address == address + } } else { logger("[BLE-A] disconnect address unavailable, clearing all notify state") connectedDeviceAddresses.clear() @@ -286,19 +309,24 @@ class BleGattServerManager( forcedRediscoveryGenerationByAddress.clear() mtuByAddress.clear() notifyQueue.clearAllForDisconnect("unknown disconnect address") + notifyFailureCountByAddress.clear() + subscribedAtByAddress.clear() + lastCommandSuccessAtByAddress.clear() + lastNotifySuccessAtByAddress.clear() + lastNotifyFailureAtByAddress.clear() + lastLinkProbeAtByAddress.clear() + connectionCommandCoordinator.clear() + lyricsTransferCoordinator.clearRetryState() + albumArtTransferCoordinator.reset() + } + if (subscribedDevices.isEmpty()) { + clearSharedClientRuntimeState() } - lastAlbumArtKey = null - currentAlbumArtId = null - albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() - albumArtPendingRequests.clear() - pendingAlbumArt = null - albumArtScheduleGeneration += 1 - resetClientCapabilities() - lastAutoPushSongKey = null - lastAutoPushPlaying = null stopAutoPushIfUnused() logDisconnectDiagnostics(address) + if (connectionRemoved) { + onControllerConnectionCountChanged(connectedDeviceAddresses.size) + } if (connectedDeviceAddresses.isEmpty()) { onAllClientsDisconnected("gatt disconnected status=$status") } @@ -308,6 +336,7 @@ class BleGattServerManager( override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) { val address = device?.address ?: return mtuByAddress[address] = mtu + notifyQueue.updateLinkMtu(address, mtu) val callbackKey = "$address|$mtu" if (shouldLogCallback(recentMtuCallbacks, callbackKey)) { logger("[BLE-A] MTU changed: device=$address mtu=$mtu") @@ -315,7 +344,7 @@ class BleGattServerManager( } override fun onNotificationSent(device: BluetoothDevice?, status: Int) { - notifyQueue.onNotificationSent(status) + notifyQueue.onNotificationSent(device?.address, status) } override fun onCharacteristicWriteRequest( @@ -373,6 +402,15 @@ class BleGattServerManager( "costMs=${SystemClock.elapsedRealtime() - responseStartMs} ok=$ok" ) } + device?.let { + sendCommandError( + device = it, + request = JSONObject().put("cmd", "").put("seq", ""), + domain = "protocol", + code = "invalid_json", + retryable = false + ) + } return } @@ -383,6 +421,16 @@ class BleGattServerManager( "parseCostMs=${SystemClock.elapsedRealtime() - parseStartedAtMs}" ) logger("[BLE-A] command received: $command") + // Capability fallback is timed from CCCD subscription, while command + // handling runs on a worker that can be busy with reconnect state sync. + // Record a valid capability frame at receipt time so the 250 ms timer + // cannot incorrectly downgrade this controller before its ACK is queued. + if (command == "CLIENT_CAPABILITIES" && device != null) { + connectionCommandCoordinator.accept( + device.address, + parseClientCapabilities(request, maximumPayloadFor(device)) + ) + } if (responseNeeded) { val responseStartMs = SystemClock.elapsedRealtime() logger( @@ -402,7 +450,7 @@ class BleGattServerManager( ) if (ok) { notifyQueue.onCommandResponseSent() - recordCommandSuccess(command) + recordCommandSuccess(command, device?.address) } } val dispatchExecutor = when (command) { @@ -420,7 +468,12 @@ class BleGattServerManager( "[CTRL-Sony] handle async begin seq=$seq cmd=$command " + "queueSnapshot=${controlQueueSnapshot()}" ) - runCatching { handleCommand(command, request, seq) } + val sourceDevice = device + if (sourceDevice == null) { + logger("[CTRL-Sony] command dropped reason=unknown source seq=$seq cmd=$command") + return@execute + } + runCatching { handleCommand(sourceDevice, command, request, seq) } .onFailure { exception -> logger( "[CTRL-Sony] handle async failed seq=$seq cmd=$command " + @@ -465,24 +518,60 @@ class BleGattServerManager( return } + val enabling = value?.contentEquals( + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + ) == true + if (enabling && + !subscribedDevices.containsKey(device.address) && + !MultiControllerPolicy.hasConnectionCapacity(subscribedDevices.size) + ) { + logger( + "[BLE-A] subscription rejected device=${device.address} " + + "reason=max_controllers limit=${MultiControllerPolicy.MAX_CONTROLLERS}" + ) + if (responseNeeded) { + sendWriteResponse( + device, + requestId, + BluetoothGatt.GATT_FAILURE, + offset, + value + ) + } + runCatching { gattServer?.cancelConnection(device) } + return + } + when { value?.contentEquals( BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE ) == true -> { subscribedDevices[device.address] = device + notifyFailureCountByAddress[device.address] = 0 lastSubscribedAtMs = SystemClock.elapsedRealtime() - resetClientCapabilities() - val capabilityGeneration = ++capabilityNegotiationGeneration + subscribedAtByAddress[device.address] = lastSubscribedAtMs + lastCommandSuccessAtByAddress.remove(device.address) + lastNotifySuccessAtByAddress.remove(device.address) + lastLinkProbeAtByAddress.remove(device.address) + resetClientCapabilities(device.address) + val capabilityGeneration = connectionCommandCoordinator.beginNegotiation( + device.address, + lastSubscribedAtMs + ) logger("[BLE-A] status notify subscribed: device=${device.address}") logger("[ReconnectSync] notify subscribed device=${device.address}") startAutoPush() scheduleReconnectStateSync(device, "notify_subscribed") albumArtHandler.postDelayed({ - if (capabilityGeneration == capabilityNegotiationGeneration && - !clientCapabilitiesNegotiated + if (connectionCommandCoordinator.useLegacyIfCurrent( + device.address, + capabilityGeneration + ) ) { - clientCapabilitiesNegotiated = true - logger("[BLE-A] client capability timeout, legacy fallback") + logger( + "[BLE-A] client capability timeout, legacy fallback " + + "device=${device.address}" + ) sendPendingAlbumArtIfAny() } }, CLIENT_CAPABILITY_WAIT_MS) @@ -493,16 +582,26 @@ class BleGattServerManager( ) == true -> { subscribedDevices.remove(device.address) notifyQueue.removeDevice(device.address) - lastAlbumArtKey = null - currentAlbumArtId = null - albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() - pendingAlbumArt = null - albumArtScheduleGeneration += 1 - resetClientCapabilities() - capabilityNegotiationGeneration += 1 - lastAutoPushSongKey = null - lastAutoPushPlaying = null + notifyFailureCountByAddress.remove(device.address) + subscribedAtByAddress.remove(device.address) + lastCommandSuccessAtByAddress.remove(device.address) + lastNotifySuccessAtByAddress.remove(device.address) + lastNotifyFailureAtByAddress.remove(device.address) + lastLinkProbeAtByAddress.remove(device.address) + resetClientCapabilities(device.address) + connectionCommandCoordinator.remove(device.address) + albumArtRequestsInFlight + .filter { it.startsWith("${device.address}|") } + .forEach(albumArtRequestsInFlight::remove) + albumArtRequestCompletedAtMs.keys + .filter { it.startsWith("${device.address}|") } + .forEach(albumArtRequestCompletedAtMs::remove) + albumArtPendingRequests.entries.removeAll { + it.value.device.address == device.address + } + if (subscribedDevices.isEmpty()) { + clearSharedClientRuntimeState() + } logger("[BLE-A] status notify unsubscribed: device=${device.address}") stopAutoPushIfUnused() } @@ -642,9 +741,44 @@ class BleGattServerManager( } catch (exception: Exception) { logger("[BLE-A] GATT addService failed: ${exception.message}") } + schedulePlaybackUiRefresh("gatt_start") return true } + private fun schedulePlaybackUiRefresh(reason: String) { + if (!started) return + playbackUiRefreshReason.set(reason) + playbackUiRefreshPending.set(true) + if (!playbackUiRefreshInFlight.compareAndSet(false, true)) return + executionHub.foregroundIO.execute { + try { + do { + playbackUiRefreshPending.set(false) + if (!started) break + val latestReason = playbackUiRefreshReason.get() + val source = playbackStateReader.readPlaybackState() + onPlaybackUiState(source) + logger( + "[PlayerUI] service refresh reason=$latestReason " + + "title=${source.optString("title").take(48)}" + ) + } while (playbackUiRefreshPending.get()) + } catch (exception: Exception) { + logger( + "[PlayerUI] service refresh failed " + + "reason=${playbackUiRefreshReason.get()} error=${exception.message}" + ) + } finally { + playbackUiRefreshInFlight.set(false) + // Close the race where a request arrives after the loop's final + // pending check but before inFlight is cleared. + if (started && playbackUiRefreshPending.get()) { + schedulePlaybackUiRefresh(playbackUiRefreshReason.get()) + } + } + } + } + @SuppressLint("MissingPermission") @Synchronized fun close() { @@ -660,20 +794,28 @@ class BleGattServerManager( } logger("[BLE-GATT] close reason=$reason") stopAutoPush() - notifyQueue.clearAllForDisconnect(reason) + notifyQueue.shutdown(reason) lastAlbumArtKey = null currentAlbumArtId = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() pendingAlbumArt = null - albumArtScheduleGeneration += 1 + pendingFullLyricsRequests.clear() + pendingLyricWindowRequests.clear() + lyricsTransferCoordinator.reset() + albumArtTransferCoordinator.reset() + connectionCommandCoordinator.clear() + multiControllerCommandGate.reset() albumArtTaskGeneration += 1 albumArtFastPathTask?.cancel(true) albumArtFastPathTask = null albumArtFastPathProtocolId = null albumArtFastPathCompletionPending = false albumArtPendingRequests.clear() + albumArtUnavailableProtocolIds.clear() + albumArtPreparationLocks.clear() albumArtHandler.removeCallbacksAndMessages(null) + mediaCallbackThread.quitSafely() gattLifecycleHandler.removeCallbacksAndMessages(null) PlayerNotificationListenerService.removeQqMusicArtworkListener(qqMusicArtworkListener) lastAutoPushSongKey = null @@ -685,13 +827,13 @@ class BleGattServerManager( mtuByAddress.clear() recentConnectionCallbacks.clear() recentMtuCallbacks.clear() + notifyFailureCountByAddress.clear() + subscribedAtByAddress.clear() + lastCommandSuccessAtByAddress.clear() + lastNotifySuccessAtByAddress.clear() + lastNotifyFailureAtByAddress.clear() + lastLinkProbeAtByAddress.clear() statusCharacteristic = null - mediaFieldDumpExecutor.shutdownNow() - historyExecutor.shutdownNow() - reconnectSyncExecutor.shutdownNow() - commandExecutor.shutdownNow() - lyricCommandExecutor.shutdownNow() - albumArtFastPathExecutor.shutdownNow() playbackStateBuffer.shutdown() playbackStateReader.close() @@ -714,6 +856,34 @@ class BleGattServerManager( } } + fun onTrimMemory(level: Int) { + if (level < ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) return + val currentId = currentAlbumArtId.orEmpty() + synchronized(albumArtCache) { + val staleEntries = albumArtCache.values + .filter { it.protocolId != currentId } + .distinctBy(AlbumArtCacheEntry::protocolId) + staleEntries.forEach(::removeAlbumArtCacheEntryLocked) + } + synchronized(encodedAlbumArtCache) { + val iterator = encodedAlbumArtCache.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + val keepCurrentPreview = currentId.isNotBlank() && + entry.key.startsWith("$currentId|preview|") + if (!keepCurrentPreview) { + encodedAlbumArtCacheBytes -= entry.value.bytes.size + iterator.remove() + } + } + encodedAlbumArtCacheBytes = encodedAlbumArtCacheBytes.coerceAtLeast(0) + } + albumArtPreparationLocks.keys.removeAll { key -> + currentId.isBlank() || !key.startsWith("$currentId|") + } + logger("[AlbumArtCache] trim level=$level keepCurrent=${currentId.isNotBlank()}") + } + fun isStarted(): Boolean = started && gattServer != null fun isServerReady(): Boolean = isStarted() && serverState == ServerState.READY @@ -743,19 +913,16 @@ class BleGattServerManager( val queueSnapshot = notifyQueue.snapshot() val connectedCount = connectedDeviceAddresses.size val subscribedCount = subscribedDevices.size + val currentNotifyFailureCount = notifyFailureCount() val healthState = when { !serviceRunning -> BleHealthState.SERVICE_STOPPED recovering -> BleHealthState.RECOVERING serverState == ServerState.FAILED -> BleHealthState.ERROR !started || serverState == ServerState.STARTING -> BleHealthState.STARTING serverState != ServerState.READY -> BleHealthState.STARTING - notifyFailureCount >= NOTIFY_FAILURE_SUSPECT_THRESHOLD -> BleHealthState.SUSPECT - subscribedWithoutSuccessOlderThan(SUSPECT_NO_SUCCESS_HEARTBEAT_MS) -> + currentNotifyFailureCount >= NOTIFY_FAILURE_SUSPECT_THRESHOLD -> BleHealthState.SUSPECT + hasAnyStaleSubscriber(SUSPECT_NO_SUCCESS_HEARTBEAT_MS) -> BleHealthState.SUSPECT - subscribedCount > 0 && isSuccessHeartbeatStale( - SystemClock.elapsedRealtime(), - SUSPECT_NO_SUCCESS_HEARTBEAT_MS - ) -> BleHealthState.SUSPECT subscribedCount > 0 && lastSuccessHeartbeatAtMs() > 0L -> BleHealthState.CONTROLLABLE subscribedCount > 0 -> BleHealthState.SUBSCRIBED connectedCount > 0 -> BleHealthState.CONNECTED @@ -775,7 +942,7 @@ class BleGattServerManager( lastCommandSuccessAt = lastCommandSuccessAtMs, lastNotifySuccessAt = lastNotifySuccessAtMs, lastNotifyFailureAt = lastNotifyFailureAtMs, - notifyFailureCount = notifyFailureCount, + notifyFailureCount = currentNotifyFailureCount, lastRecoveryAt = lastRecoveryAt, recoveryCount = recoveryCount, reason = reason @@ -791,31 +958,121 @@ class BleGattServerManager( staleAddresses.forEach { address -> subscribedDevices.remove(address) notifyQueue.removeDevice(address) + connectionCommandCoordinator.remove(address) + lyricsTransferCoordinator.resetAddress(address) + albumArtTransferCoordinator.resetAddress(address) + pendingFullLyricsRequests.remove(address) + pendingLyricWindowRequests.remove(address) + notifyFailureCountByAddress.remove(address) + subscribedAtByAddress.remove(address) + lastCommandSuccessAtByAddress.remove(address) + lastNotifySuccessAtByAddress.remove(address) + lastNotifyFailureAtByAddress.remove(address) + lastLinkProbeAtByAddress.remove(address) + albumArtRequestsInFlight + .filter { it.startsWith("$address|") } + .forEach(albumArtRequestsInFlight::remove) + albumArtRequestCompletedAtMs.keys + .filter { it.startsWith("$address|") } + .forEach(albumArtRequestCompletedAtMs::remove) + albumArtPendingRequests.entries.removeAll { + it.value.device.address == address + } } logger( "[BleHealth] watchdog action=clear_stale_subscribers " + "reason=$reason addresses=$staleAddresses" ) if (subscribedDevices.isEmpty()) { + clearSharedClientRuntimeState() stopAutoPushIfUnused() } return true } - fun hasSuccessHeartbeatOlderThan(ageMs: Long): Boolean { - return isSuccessHeartbeatStale(SystemClock.elapsedRealtime(), ageMs) + fun hasAnyStaleSubscriber(ageMs: Long): Boolean { + val nowMs = SystemClock.elapsedRealtime() + return subscribedDevices.keys.any { address -> + BleSubscribedLinkPolicy.isActivityStale( + subscribed = true, + subscribedAtMs = subscribedAtByAddress[address] ?: lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtByAddress[address] ?: 0L, + lastNotifySuccessAtMs = lastNotifySuccessAtByAddress[address] ?: 0L, + nowMs = nowMs, + maxAgeMs = ageMs + ) + } + } + + fun notifyFailureCount(): Int = notifyFailureCountByAddress.values.maxOrNull() ?: 0 + + fun failingSubscriberAddresses(threshold: Int): List { + if (threshold <= 0) return emptyList() + return subscribedDevices.keys.filter { address -> + (notifyFailureCountByAddress[address] ?: 0) >= threshold + } } - fun subscribedWithoutSuccessOlderThan(ageMs: Long): Boolean { - val subscribedAt = lastSubscribedAtMs - return subscribedDevices.isNotEmpty() && - hasOutboundWork() && - lastSuccessHeartbeatAtMs() == 0L && - subscribedAt > 0L && - SystemClock.elapsedRealtime() - subscribedAt > ageMs + /** + * Queues one tiny notification for each silent subscriber. A successful + * notification refreshes that device's heartbeat through recordNotifySuccess; + * a real callback failure is handled by the existing per-device failure + * counter. Merely being quiet never tears down the shared GATT server. + */ + fun probeStaleSubscribers( + staleAfterMs: Long, + minimumProbeIntervalMs: Long + ): Int { + val nowMs = SystemClock.elapsedRealtime() + val candidates = subscribedDevices.entries.filter { (address, _) -> + BleSubscribedLinkPolicy.shouldSendProbe( + subscribedAtMs = subscribedAtByAddress[address] ?: lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtByAddress[address] ?: 0L, + lastNotifySuccessAtMs = lastNotifySuccessAtByAddress[address] ?: 0L, + lastProbeAtMs = lastLinkProbeAtByAddress[address] ?: 0L, + nowMs = nowMs, + staleAfterMs = staleAfterMs, + minimumProbeIntervalMs = minimumProbeIntervalMs + ) + } + candidates.forEach { (address, device) -> + lastLinkProbeAtByAddress[address] = nowMs + // Keep the payload below the default 20-byte ATT payload so the + // probe works even before an MTU upgrade. Unknown status types are + // intentionally ignored by old controllers after refreshing health. + sendShortJsonIfFits( + device = device, + type = "linkProbe", + value = JSONObject().put("type", "link") + ) + } + return candidates.size } - fun notifyFailureCount(): Int = notifyFailureCount + @SuppressLint("MissingPermission") + fun disconnectSubscribers(addresses: Collection, reason: String): Int { + var requested = 0 + addresses.distinct().forEach { address -> + val device = subscribedDevices[address] ?: return@forEach + logger( + "[BleHealth] isolate unhealthy controller device=$address " + + "reason=$reason failures=${notifyFailureCountByAddress[address] ?: 0}" + ) + notifyFailureCountByAddress[address] = 0 + try { + gattServer?.cancelConnection(device) + requested += 1 + } catch (securityException: SecurityException) { + logger("[BleHealth] controller isolation failed device=$address permission") + } catch (exception: Exception) { + logger( + "[BleHealth] controller isolation failed device=$address " + + "reason=${exception.message}" + ) + } + } + return requested + } private fun updateServerState(newState: ServerState) { val oldState = serverState @@ -826,27 +1083,46 @@ class BleGattServerManager( logger("[BLE-GATT] state $oldState -> $newState") } - private fun recordCommandSuccess(command: String) { + private fun recordCommandSuccess(command: String, deviceAddress: String?) { lastCommandSuccessAtMs = SystemClock.elapsedRealtime() + if (!deviceAddress.isNullOrBlank()) { + lastCommandSuccessAtByAddress[deviceAddress] = lastCommandSuccessAtMs + } logger("[BleHealth] state=CONTROLLABLE reason=command_success cmd=$command") } - private fun recordNotifySuccess(type: String) { + private fun recordNotifySuccess(deviceAddress: String, type: String) { lastNotifySuccessAtMs = SystemClock.elapsedRealtime() - notifyFailureCount = 0 + if (deviceAddress.isNotBlank()) { + lastNotifySuccessAtByAddress[deviceAddress] = lastNotifySuccessAtMs + notifyFailureCountByAddress[deviceAddress] = 0 + } val now = lastNotifySuccessAtMs if (now - lastHealthSuccessLogAtMs >= HEALTH_SUCCESS_LOG_INTERVAL_MS) { lastHealthSuccessLogAtMs = now - logger("[BleHealth] state=CONTROLLABLE reason=notify_success type=$type") + logger( + "[BleHealth] state=CONTROLLABLE reason=notify_success " + + "device=$deviceAddress type=$type" + ) } } - private fun recordNotifyFailure(type: String, status: Int, reason: String) { + private fun recordNotifyFailure( + deviceAddress: String, + type: String, + status: Int, + reason: String + ) { lastNotifyFailureAtMs = SystemClock.elapsedRealtime() - notifyFailureCount += 1 + if (deviceAddress.isNotBlank()) { + lastNotifyFailureAtByAddress[deviceAddress] = lastNotifyFailureAtMs + notifyFailureCountByAddress[deviceAddress] = + (notifyFailureCountByAddress[deviceAddress] ?: 0) + 1 + } + val currentFailureCount = notifyFailureCount() logger( - "[BleHealth] notify failure type=$type status=$status " + - "reason=$reason failureCount=$notifyFailureCount " + + "[BleHealth] notify failure device=$deviceAddress type=$type status=$status " + + "reason=$reason failureCount=$currentFailureCount " + "connected=${connectedDeviceAddresses.size} " + "subscribed=${subscribedDevices.size}" ) @@ -856,19 +1132,6 @@ class BleGattServerManager( return lastNotifySuccessAtMs } - private fun isSuccessHeartbeatStale(nowMs: Long, maxAgeMs: Long): Boolean { - val lastSuccess = lastSuccessHeartbeatAtMs() - return hasOutboundWork() && lastSuccess > 0L && nowMs - lastSuccess > maxAgeMs - } - - private fun hasOutboundWork(): Boolean { - val queueSnapshot = notifyQueue.snapshot() - return queueSnapshot.notificationInFlight || - queueSnapshot.activeJobType != null || - queueSnapshot.pendingJobCount > 0 || - queueSnapshot.pendingShortMessageCount > 0 - } - private fun logDisconnectDiagnostics(address: String) { val snapshot = notifyQueue.snapshot() logger( @@ -901,30 +1164,68 @@ class BleGattServerManager( now - previous >= CALLBACK_LOG_DEDUP_WINDOW_MS } - private fun handleCommand(command: String, request: JSONObject, seq: String? = null) { + private fun handleCommand( + sourceDevice: BluetoothDevice, + command: String, + request: JSONObject, + seq: String? = null + ) { + val sourceAddress = sourceDevice.address + val commandReceivedElapsedMs = SystemClock.elapsedRealtime() when (command) { "PLAY_PAUSE", "NEXT", "PREVIOUS", "VOLUME_UP", "VOLUME_DOWN" -> { - cancelHistoryTransfersForControl(command) + cancelHistoryTransfersForControl(sourceAddress, command) + if (command in MULTI_CONTROLLER_DEDUP_COMMANDS && + !multiControllerCommandGate.shouldExecute( + command, + sourceAddress, + SystemClock.elapsedRealtime() + ) + ) { + logger( + "[MultiController] duplicate control suppressed " + + "cmd=$command device=$sourceAddress" + ) + sendPlaybackState() + return + } if (command == "NEXT") { playbackStateReader.notifyManualNextHint(seq) } mediaCommandExecutor.execute(command, seq) + if (command in MULTI_CONTROLLER_DEDUP_COMMANDS) { + scheduleAuthoritativeStateBroadcast(command) + } } "SEEK_TO" -> { - cancelHistoryTransfersForControl(command) + if (request.opt("position") !is Number) { + sendCommandError( + sourceDevice, request, "protocol", "invalid_position", false + ) + return + } + cancelHistoryTransfersForControl(sourceAddress, command) val position = request.optLong("position").coerceAtLeast(0L) + currentWordPushEngine.resetTimeline() logger("[BLE-A][Seek] position=$position") mediaCommandExecutor.seekTo(position, seq) logger("[BLE-A][Seek] seekTo called") sendPlaybackState() + scheduleAuthoritativeStateBroadcast(command) } "SET_VOLUME" -> { - cancelHistoryTransfersForControl(command) + if (request.opt("volume") !is Number) { + sendCommandError( + sourceDevice, request, "protocol", "invalid_volume", false + ) + return + } + cancelHistoryTransfersForControl(sourceAddress, command) val requestedVolume = request.optInt("volume") val volumeStartedAtMs = SystemClock.elapsedRealtime() logger("[CTRL-Sony] volume begin seq=${seq ?: "unknown"} cmd=SET_VOLUME") @@ -946,35 +1247,47 @@ class BleGattServerManager( ) } "GET_PLAYBACK_STATE" -> { - sendPlaybackState(includeAlbumArt = true) + sendPlaybackStateTo(sourceDevice, includeAlbumArt = true) } - "PING" -> sendPong(request) - "GET_VOLUME" -> mediaCommandExecutor.sendVolumeState() + "PING" -> sendPong(sourceDevice, request, commandReceivedElapsedMs) + "GET_VOLUME" -> sendStatusMessageTo( + sourceDevice, + mediaCommandExecutor.createVolumeState().toString() + ) "GET_LOGS" -> { val limit = request.optInt("limit", DEFAULT_LOG_LIMIT) .coerceIn(0, MAX_LOG_LIMIT) - sendRemoteLogs(limit) + sendRemoteLogs(sourceDevice, limit) } "ALBUM_ART_SKIP" -> handleAlbumArtSkip(request) - "ALBUM_ART_REQUEST" -> handleAlbumArtRequest(request) - "CLIENT_CAPABILITIES" -> handleClientCapabilities(request) - "DUMP_MEDIA_FIELDS" -> sendMediaFieldDump() - "GET_FULL_LYRICS" -> sendFullLyrics(request) - "GET_LYRIC_WINDOW" -> sendLyricWindow(request) - "RETRY_TRANSFER" -> retryTransfer(request) - "GET_LYRIC_SECONDARY" -> sendLyricSecondary(request) - "GET_LYRIC_DIAGNOSTIC" -> sendLyricDiagnostic(request) - "GET_PLAY_HISTORY_PAGE" -> sendPlayHistoryPage(request) - "GET_PLAY_HISTORY_SINCE" -> sendPlayHistorySince(request) - "GET_PLAY_STATS" -> sendPlayStats(request) - else -> logger("[BLE-A] unknown command: $command") - } - } - - private fun cancelHistoryTransfersForControl(command: String) { - if (notifyQueue.hasJobTypeActiveOrQueued(PLAY_HISTORY_JOB_TYPE) || - notifyQueue.hasJobTypeActiveOrQueued(PLAY_STATS_JOB_TYPE) || - notifyQueue.hasJobTypeActiveOrQueued(LYRIC_SECONDARY_JOB_TYPE) + "ALBUM_ART_REQUEST" -> handleAlbumArtRequest(sourceDevice, request) + "CLIENT_CAPABILITIES" -> handleClientCapabilities(sourceDevice, request) + "DUMP_MEDIA_FIELDS" -> sendMediaFieldDump(sourceDevice) + "GET_FULL_LYRICS" -> sendFullLyrics(sourceDevice, request) + "GET_LYRIC_WINDOW" -> sendLyricWindow(sourceDevice, request) + "RETRY_TRANSFER" -> retryTransfer(sourceDevice, request) + "GET_LYRIC_SECONDARY" -> sendLyricSecondary(sourceDevice, request) + "GET_LYRIC_DIAGNOSTIC" -> sendLyricDiagnostic(sourceDevice, request) + "GET_PLAY_HISTORY_PAGE" -> sendPlayHistoryPage(sourceDevice, request) + "GET_PLAY_HISTORY_SINCE" -> sendPlayHistorySince(sourceDevice, request) + "GET_PLAY_STATS" -> sendPlayStats(sourceDevice, request) + else -> { + logger("[BLE-A] unknown command: $command") + sendCommandError( + device = sourceDevice, + request = request, + domain = "protocol", + code = "unknown_command", + retryable = false + ) + } + } + } + + private fun cancelHistoryTransfersForControl(sourceAddress: String, command: String) { + if (notifyQueue.hasJobTypeActiveOrQueued(PLAY_HISTORY_JOB_TYPE, sourceAddress) || + notifyQueue.hasJobTypeActiveOrQueued(PLAY_STATS_JOB_TYPE, sourceAddress) || + notifyQueue.hasJobTypeActiveOrQueued(LYRIC_SECONDARY_JOB_TYPE, sourceAddress) ) { logger("[HistoryBLE] cancelled reason=control command cmd=$command") logger("[LyricSecondary] cancelled reason=control command cmd=$command") @@ -984,64 +1297,137 @@ class BleGattServerManager( PLAY_STATS_JOB_TYPE, LYRIC_SECONDARY_JOB_TYPE ), - "control command" + "control command", + sourceAddress ) } } - private fun handleClientCapabilities(request: JSONObject) { - clientProtocolVersion = request.optInt("protocolVersion", 1).coerceAtLeast(1) - clientSupportsBinaryAlbumArt = request.optBoolean("albumArtBinary", false) - clientSupportsFullLyricsZlib = request.optBoolean("fullLyricsZlib", false) - clientSupportsLyricWindow = request.optBoolean("lyricWindow", false) - clientSupportsPing = request.optBoolean("ping", false) - clientSupportsTransferRetry = request.optBoolean("transferRetry", false) - clientCapabilitiesNegotiated = true + private fun scheduleAuthoritativeStateBroadcast(command: String) { + val delayMs = when (command) { + "PLAY_PAUSE" -> 100L + "SEEK_TO" -> 150L + "NEXT", "PREVIOUS" -> 220L + else -> 120L + } + albumArtHandler.postDelayed({ + if (!started || subscribedDevices.isEmpty()) return@postDelayed + sendPlaybackState(includeAlbumArt = command == "NEXT" || command == "PREVIOUS") + }, delayMs) + } + + private fun handleClientCapabilities(device: BluetoothDevice, request: JSONObject) { + val notifyPayload = maximumPayloadFor(device) + val capabilities = parseClientCapabilities(request, notifyPayload) + connectionCommandCoordinator.accept(device.address, capabilities) logger( - "[BLE-A] client capability protocolVersion=$clientProtocolVersion " + - "albumArtBinary=$clientSupportsBinaryAlbumArt " + - "fullLyricsZlib=$clientSupportsFullLyricsZlib " + - "lyricWindow=$clientSupportsLyricWindow ping=$clientSupportsPing " + - "transferRetry=$clientSupportsTransferRetry" + "[BLE-A] client capability protocolVersion=${capabilities.protocolVersion} " + + "albumArtBinary=${capabilities.binaryAlbumArt} " + + "fullLyricsZlib=${capabilities.fullLyricsZlib} " + + "lyricWindow=${capabilities.lyricWindow} ping=${capabilities.ping} " + + "clockSyncV1=${capabilities.clockSyncV1} " + + "transferRetry=${capabilities.transferRetry} " + + "requestedF3=${request.optInt("f3").takeIf { request.has("f3") }} " + + "notifyPayload=$notifyPayload negotiatedF2=${capabilities.f2} " + + "negotiatedF3=${capabilities.f3}" ) - subscribedDevices.values.firstOrNull()?.let { device -> - sendShortJsonIfFits( - device = device, - type = "clientCapabilitiesAck", - value = JSONObject() - .put("type", "clientCapabilitiesAck") - .put("protocolVersion", SERVER_PROTOCOL_VERSION) - .put("albumArtBinary", true) - .put("fullLyricsZlib", true) - .put("lyricWindow", true) - .put("ping", true) - .put("transferRetry", true) - ) - } + val ack = BleCapabilitiesAckPolicy.build( + capabilities = capabilities, + requestedF3Present = request.has("f3"), + sessionId = v3SessionCoordinator.sessionId + ) + logger("[BLE-V3] capabilities ack payload=$ack") + sendShortJsonIfFits(device, "clientCapabilitiesAck", ack) sendPendingAlbumArtIfAny() } - private fun resetClientCapabilities() { - clientProtocolVersion = 1 - clientSupportsBinaryAlbumArt = false - clientSupportsFullLyricsZlib = false - clientSupportsLyricWindow = false - clientSupportsPing = false - clientSupportsTransferRetry = false - clientCapabilitiesNegotiated = false - recentFullLyricsBinaryTransfer = null - recentAlbumArtBinaryTransfers.clear() + private fun parseClientCapabilities( + request: JSONObject, + notifyPayload: Int + ): ConnectionCommandCoordinator.Capabilities { + val protocolVersion = request.optInt("protocolVersion", 1).coerceAtLeast(1) + val binaryAlbumArt = request.optBoolean("albumArtBinary", false) + val fullLyricsZlib = request.optBoolean("fullLyricsZlib", false) + val lyricWindow = request.optBoolean("lyricWindow", false) + val ping = request.optBoolean("ping", false) + val clockSyncV1 = request.optBoolean("clockSyncV1", false) + val transferRetry = request.optBoolean("transferRetry", false) + return ConnectionCommandCoordinator.Capabilities( + protocolVersion = protocolVersion, + binaryAlbumArt = binaryAlbumArt, + fullLyricsZlib = fullLyricsZlib, + lyricWindow = lyricWindow, + ping = ping, + clockSyncV1 = clockSyncV1, + transferRetry = transferRetry, + f2 = BleV3CapabilityPolicy.f2( + binaryAlbumArt, + fullLyricsZlib, + lyricWindow, + ping, + clockSyncV1, + transferRetry + ), + f3 = BleV3CapabilityPolicy.negotiateF3( + protocolVersion = protocolVersion, + requestedF3 = request.optInt("f3").takeIf { request.has("f3") }, + notifyPayload = notifyPayload + ), + negotiated = true + ) + } + + private fun resetClientCapabilities(address: String) { + connectionCommandCoordinator.invalidate(address) + lyricsTransferCoordinator.resetAddress(address) + albumArtTransferCoordinator.resetAddress(address) + pendingFullLyricsRequests.remove(address) + pendingLyricWindowRequests.remove(address) + v3SessionCoordinator.resetAddress(address) + } + + private fun clearSharedClientRuntimeState() { + lastAlbumArtKey = null + currentAlbumArtId = null + currentAlbumArtPlaybackState = null + albumArtRequestsInFlight.clear() + albumArtRequestCompletedAtMs.clear() + albumArtPendingRequests.clear() + albumArtUnavailableProtocolIds.clear() + pendingAlbumArt = null + pendingFullLyricsRequests.clear() + pendingLyricWindowRequests.clear() + connectionCommandCoordinator.clear() + lyricsTransferCoordinator.clearRetryState() + albumArtTransferCoordinator.reset() + multiControllerCommandGate.reset() + v3SessionCoordinator.clear() + lastAutoPushSongKey = null + lastAutoPushPlaying = null } - private fun sendPong(request: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: return + private fun sendPong( + device: BluetoothDevice, + request: JSONObject, + serverReceiveElapsedMs: Long + ) { + val serverSendElapsedMs = SystemClock.elapsedRealtime() + val value = JSONObject() + .put("type", "pong") + .put("seq", request.optString("seq")) + .put("time", System.currentTimeMillis()) + if (request.optBoolean("clockSyncV1", false) && + request.has("clientSendElapsedMs") + ) { + value + .put("clientSendElapsedMs", request.optLong("clientSendElapsedMs")) + .put("serverReceiveElapsedMs", serverReceiveElapsedMs) + .put("serverSendElapsedMs", serverSendElapsedMs) + } sendShortJsonIfFits( device = device, type = "pong", - value = JSONObject() - .put("type", "pong") - .put("seq", request.optString("seq")) - .put("time", System.currentTimeMillis()) + value = value ) } @@ -1057,6 +1443,32 @@ class BleGattServerManager( } } + private fun sendPlaybackStateTo( + device: BluetoothDevice, + includeAlbumArt: Boolean = false + ) { + if (!subscribedDevices.containsKey(device.address)) return + val source = playbackStateReader.readPlaybackState() + onPlaybackUiState(source) + if (includeAlbumArt) { + sendTrackInfoTo(device, source) + } + sendCompactPlaybackStateTo(device, source) + if (includeAlbumArt) { + val protocolId = buildAlbumArtProtocolId(source) + if (protocolId.isNotBlank()) { + enqueueAlbumArtOfferOrPending( + PendingAlbumArt( + cacheKey = buildAlbumArtCacheKey(source), + protocolId = protocolId, + playbackState = JSONObject(source.toString()) + ), + targetDevice = device + ) + } + } + } + private fun scheduleReconnectStateSync(device: BluetoothDevice, reason: String) { val address = device.address ?: "unknown" val now = SystemClock.elapsedRealtime() @@ -1078,10 +1490,14 @@ class BleGattServerManager( val startedAt = SystemClock.elapsedRealtime() logger("[ReconnectSync] start reason=$reason device=$address") try { + val device = subscribedDevices[address] ?: run { + logger("[ReconnectSync] skipped device=$address reason=not_subscribed") + return + } val source = playbackStateReader.readPlaybackState() onPlaybackUiState(source) - sendTrackInfo(source) - val sentPlayback = sendCompactPlaybackState(source) + sendTrackInfoTo(device, source) + val sentPlayback = sendCompactPlaybackStateTo(device, source) logger( "[ReconnectSync] send playbackState reason=reconnect_sync " + "sent=$sentPlayback positionMs=${source.optLong("position")} " + @@ -1117,7 +1533,7 @@ class BleGattServerManager( protocolId = protocolId, playbackState = JSONObject(source.toString()) ) - enqueueAlbumArtOfferOrPending(pending) + enqueueAlbumArtOfferOrPending(pending, targetDevice = device) logger( "[ReconnectSync] send albumArtOffer reason=reconnect_sync " + "id=${pending.protocolId}" @@ -1135,9 +1551,9 @@ class BleGattServerManager( ?: playbackStateReader.currentTrackSnapshot() } - private fun sendLyricDiagnostic(request: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[LyricDiag] send skipped: no iPhone subscriber") + private fun sendLyricDiagnostic(device: BluetoothDevice, request: JSONObject) { + if (!subscribedDevices.containsKey(device.address)) { + logger("[LyricDiag] send skipped: controller unsubscribed") return } val startedAtMs = SystemClock.elapsedRealtime() @@ -1269,13 +1685,38 @@ class BleGattServerManager( return playbackStateReader.manualRefreshCurrentLyric() } + /** + * Debug/UI refresh entry point that does not require an active BLE subscriber. + * Refresh the QQ Music MediaSession snapshot first so LyricManager has the + * current song identity before clearing only that song's retry state. + */ + fun refreshCurrentPlaybackAndLyric(): Boolean { + val playbackState = playbackStateReader.readPlaybackState() + val manualRefreshStarted = playbackStateReader.manualRefreshCurrentLyric() + return manualRefreshStarted || playbackState.optString("title").isNotBlank() + } + private fun sendShortJsonIfFits( device: BluetoothDevice, type: String, value: JSONObject ) { - val bytes = value.toString().toByteArray(Charsets.UTF_8) - if (bytes.size > maximumPayloadFor(device)) { + val maximumPayload = maximumPayloadFor(device) + val capabilities = connectionCommandCoordinator.capabilities(device.address) + val outgoing = if ( + type != "clientCapabilitiesAck" && + capabilities.statusMetaV1 && + !value.has("sid") + ) { + v3SessionCoordinator.decorate(device.address, value) + } else { + value + } + var bytes = outgoing.toString().toByteArray(Charsets.UTF_8) + if (bytes.size > maximumPayload && outgoing !== value) { + bytes = value.toString().toByteArray(Charsets.UTF_8) + } + if (bytes.size > maximumPayload) { logger("[BLE-A] short json skipped type=$type reason=payload too large bytes=${bytes.size}") return } @@ -1293,15 +1734,36 @@ class BleGattServerManager( logger("[BLE-A] status notify skipped: GATT server unavailable") return false } - - val device = subscribedDevices.values.firstOrNull() - if (device == null) { - logger("[BLE-A] status notify skipped: no iPhone subscriber") + val devices = subscribedDevices.values.toList() + if (devices.isEmpty()) { + logger("[BLE-A] status notify skipped: no controller subscriber") return false } + var sent = false + devices.forEach { device -> + sent = sendStatusMessageTo(device, message) || sent + } + return sent + } - val value = message.toByteArray(Charsets.UTF_8) + private fun sendStatusMessageTo(device: BluetoothDevice, message: String): Boolean { + if (!subscribedDevices.containsKey(device.address)) return false val maximumPayload = maximumPayloadFor(device) + val capabilities = connectionCommandCoordinator.capabilities(device.address) + val originalObject = runCatching { JSONObject(message) }.getOrNull() + val decorated = if ( + capabilities.statusMetaV1 && + originalObject != null && + !originalObject.has("sid") + ) { + v3SessionCoordinator.decorate(device.address, originalObject) + } else { + null + } + var value = (decorated?.toString() ?: message).toByteArray(Charsets.UTF_8) + if (value.size > maximumPayload && decorated != null) { + value = message.toByteArray(Charsets.UTF_8) + } if (value.size > maximumPayload) { logger( "[BLE-A] status notify skipped: payload=${value.size} " + @@ -1316,7 +1778,7 @@ class BleGattServerManager( messageType == "trackInfo" || messageType == "volumeState" || messageType == "currentWord") && - notifyQueue.hasLongJobActiveOrQueued() + notifyQueue.hasLongJobActiveOrQueued(device.address) ) { notifyQueue.setLatestInterleavedShort( device = device, @@ -1336,6 +1798,85 @@ class BleGattServerManager( return true } + private fun sendCommandError( + device: BluetoothDevice, + request: JSONObject, + domain: String, + code: String, + retryable: Boolean, + retryAfterMs: Long? = null, + trackId: String? = null, + generation: Long? = null + ) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) + if (!capabilities.structuredErrorV1) return + val value = BleV3PayloadFactory.commandError( + seq = request.optString("seq"), + command = request.optString("cmd"), + domain = domain, + code = code, + retryable = retryable + ) + retryAfterMs?.let { value.put("retryAfterMs", it) } + trackId?.takeIf(String::isNotBlank)?.let { value.put("trackId", it) } + generation?.let { value.put("generation", it) } + enqueueV3Json(device, "commandError", value, BleNotifyQueue.Priority.P0_REALTIME) + } + + private fun sendMediaLoadState( + device: BluetoothDevice, + resource: String, + stage: String, + reason: String, + retryable: Boolean, + trackId: String, + generation: Long, + retryAfterMs: Long? = null + ) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) + if (!capabilities.mediaLoadStateV1) return + if (!v3SessionCoordinator.shouldSendMediaLoadState( + address = device.address, + resource = resource, + trackId = trackId, + generation = generation, + stage = stage, + reason = reason + ) + ) return + val value = JSONObject() + .put("type", "mediaLoadState") + .put("resource", resource) + .put("stage", stage) + .put("reason", reason) + .put("retryable", retryable) + .put("trackId", trackId) + .put("generation", generation) + retryAfterMs?.let { value.put("retryAfterMs", it) } + enqueueV3Json(device, "mediaLoadState", value, BleNotifyQueue.Priority.P1_INTERACTIVE) + } + + private fun enqueueV3Json( + device: BluetoothDevice, + type: String, + value: JSONObject, + priority: BleNotifyQueue.Priority + ) { + val decorated = v3SessionCoordinator.decorate(device.address, value) + val bytes = decorated.toString().toByteArray(Charsets.UTF_8) + if (bytes.size > maximumPayloadFor(device)) { + logger("[BLE-V3] skipped type=$type reason=payload too large bytes=${bytes.size}") + return + } + logger("[BLE-V3] enqueue type=$type payload=$decorated") + notifyQueue.enqueueLongJob( + type = type, + device = device, + packets = listOf(BleNotifyQueue.Packet(type, bytes, SHORT_MESSAGE_DELAY_MS)), + priority = priority + ) + } + @Synchronized private fun startAutoPush() { if (autoPushExecutor != null) { @@ -1347,18 +1888,19 @@ class BleGattServerManager( currentWordPushEngine.reset() playbackStateBuffer.reset() startCurrentWordPush() - autoPushExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "BlePlaybackStateAutoPushThread") - }.also { executor -> - executor.scheduleAtFixedRate( - { + autoPushExecutor = executionHub.scheduled + autoPushTask = executionHub.scheduled.scheduleAtFixedRate( + { + val now = SystemClock.elapsedRealtime() + val interval = autoPushPollIntervalMs(lastAutoPushPlaying) + if (now - lastAutoPushReadAtMs >= interval) { pushPlaybackStateAutomatically() - }, - 0L, - AUTO_PUSH_INTERVAL_MS, - TimeUnit.MILLISECONDS - ) - } + } + }, + 0L, + AUTO_PUSH_INTERVAL_MS, + TimeUnit.MILLISECONDS + ) } @Synchronized @@ -1370,16 +1912,30 @@ class BleGattServerManager( @Synchronized private fun stopAutoPush() { - val executor = autoPushExecutor ?: return - executor.shutdownNow() + if (autoPushExecutor == null) return + autoPushTask?.cancel(false) + autoPushTask = null autoPushExecutor = null stopCurrentWordPush() playbackStateBuffer.reset() CurrentTrackRuntimeCache.resetPlaybackDiffState() currentWordPushEngine.logMetrics() + lastAutoPushReadAtMs = 0L logger("[BLE-A][AutoPush] stopped") } + @Synchronized + private fun wakeAutoPushFromQqNotification() { + val executor = autoPushExecutor ?: return + runCatching { + executor.execute { + pushPlaybackStateAutomatically() + } + }.onFailure { + transientLogger("[BLE-A][AutoPush] notification wake skipped") + } + } + private fun pushPlaybackStateAutomatically() { if (subscribedDevices.isEmpty()) { stopAutoPushIfUnused() @@ -1387,6 +1943,7 @@ class BleGattServerManager( } try { + lastAutoPushReadAtMs = SystemClock.elapsedRealtime() val source = playbackStateReader.readPlaybackState() onPlaybackUiState(source) val songChanged = logAutoPushStateChanges(source) @@ -1397,7 +1954,7 @@ class BleGattServerManager( CurrentTrackRuntimeCache.diffFromLastSent(it) } if (diff != null) { - logger( + verboseLogger( "[PlaybackDiff] candidate reason=${diff.reason} " + "type=${diff.type} shouldPush=${diff.shouldPush} " + "positionMs=${diff.positionMs} lastPositionMs=${diff.lastPositionMs} " + @@ -1497,9 +2054,7 @@ class BleGattServerManager( return } logger("[CurrentWordPush] started") - currentWordExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "BleCurrentWordPushThread") - } + currentWordExecutor = executionHub.scheduled scheduleCurrentWordPush(CURRENT_WORD_INITIAL_DELAY_MS) } @@ -1534,10 +2089,9 @@ class BleGattServerManager( @Synchronized private fun stopCurrentWordPush() { - val executor = currentWordExecutor ?: return + if (currentWordExecutor == null) return currentWordPushTask?.cancel(true) currentWordPushTask = null - executor.shutdownNow() currentWordExecutor = null currentWordPushEngine.reset() logger("[CurrentWordPush] stopped") @@ -1581,7 +2135,7 @@ class BleGattServerManager( coalesceCount: Int ): Boolean { val sent = sendCompactPlaybackState(source) - val payloadSize = compactPlaybackStatePayload(source) + val payloadSize = compactPlaybackStatePayload(source, includeClockSync = false) .toString() .toByteArray(Charsets.UTF_8) .size @@ -1599,27 +2153,29 @@ class BleGattServerManager( } private fun sendCompactPlaybackState(source: JSONObject): Boolean { - val compactState = compactPlaybackStatePayload(source) - val payloadSizeBeforeFit = compactState.toString().toByteArray(Charsets.UTF_8).size - val maximumPayload = subscribedDevices.values.firstOrNull()?.let(::maximumPayloadFor) ?: 0 + val devices = subscribedDevices.values.toList() + val maximumPayload = devices.minOfOrNull(::maximumPayloadFor) ?: 0 + val payloadSizeBeforeFit = compactPlaybackStatePayload( + source, + includeClockSync = false + ).toString().toByteArray(Charsets.UTF_8).size logger( "[PlaybackState] compact payloadSize=$payloadSizeBeforeFit " + "maxPayload=$maximumPayload positionMs=${source.optLong("position")} " + "lyricLength=${source.optString("lyric").length}" ) - val device = subscribedDevices.values.firstOrNull() - if (device != null) { - val fitted = fitCompactPlaybackState(compactState, maximumPayloadFor(device)) - val sent = sendStatusMessage(fitted.toString()) - if (sent) { - TrackCapabilityTracker.onPlaybackStateSent( - trackId = buildAlbumArtProtocolId(source), - protocolId = buildAlbumArtProtocolId(source) - ) - } - return sent + val sent = devices.fold(false) { anySent, device -> + val fitted = fitCompactPlaybackState( + compactPlaybackStatePayload( + source, + includeClockSync = connectionCommandCoordinator + .capabilities(device.address) + .clockSyncV1 + ), + maximumPayloadFor(device) + ) + sendStatusMessageTo(device, fitted.toString()) || anySent } - val sent = sendStatusMessage(compactState.toString()) if (sent) { TrackCapabilityTracker.onPlaybackStateSent( trackId = buildAlbumArtProtocolId(source), @@ -1629,13 +2185,38 @@ class BleGattServerManager( return sent } - private fun compactPlaybackStatePayload(source: JSONObject): JSONObject { - return JSONObject() + private fun sendCompactPlaybackStateTo( + device: BluetoothDevice, + source: JSONObject + ): Boolean { + val fitted = fitCompactPlaybackState( + compactPlaybackStatePayload( + source, + includeClockSync = connectionCommandCoordinator + .capabilities(device.address) + .clockSyncV1 + ), + maximumPayloadFor(device) + ) + return sendStatusMessageTo(device, fitted.toString()) + } + + private fun compactPlaybackStatePayload( + source: JSONObject, + includeClockSync: Boolean + ): JSONObject { + val payload = JSONObject() .put("type", "playbackState") .put("playing", source.optBoolean("playing")) .put("position", source.optLong("position")) .put("duration", source.optLong("duration")) .put("lyric", source.optString("lyric").take(MAX_LYRIC_TEXT_LENGTH)) + if (includeClockSync) { + payload + .put("sampleMono", source.optLong("positionSampleElapsedMs")) + .put("speed", source.optDouble("speed", 1.0)) + } + return payload } private fun fitCompactPlaybackState( @@ -1657,10 +2238,15 @@ class BleGattServerManager( } private fun sendTrackInfo(source: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[TrackInfo] send skipped: no iPhone subscriber") + val devices = subscribedDevices.values.toList() + if (devices.isEmpty()) { + logger("[TrackInfo] send skipped: no controller subscriber") return } + devices.forEach { device -> sendTrackInfoTo(device, source) } + } + + private fun sendTrackInfoTo(device: BluetoothDevice, source: JSONObject) { val maximumPayload = maximumPayloadFor(device) val trackInfo = buildFittingTrackInfo(source, maximumPayload) if (trackInfo == null) { @@ -1668,10 +2254,10 @@ class BleGattServerManager( return } logger("[TrackInfo] send") - if (notifyQueue.hasLongJobActiveOrQueued()) { + if (notifyQueue.hasLongJobActiveOrQueued(device.address)) { logger("[TrackInfo] latest updated title=${source.optString("title")}") } - sendStatusMessage(trackInfo.toString()) + sendStatusMessageTo(device, trackInfo.toString()) } private fun buildFittingTrackInfo( @@ -1682,6 +2268,7 @@ class BleGattServerManager( val artist = source.optString("artist") val album = source.optString("album") val trackId = buildAlbumArtProtocolId(source) + val generation = reactiveMediaController.generation() val candidates = listOf( TrackInfoLimit(30, 30, 20, includeAlbum = true), TrackInfoLimit(30, 30, 0, includeAlbum = false), @@ -1693,6 +2280,7 @@ class BleGattServerManager( .put("title", title.take(limit.titleLength)) .put("artist", artist.take(limit.artistLength)) .put("trackId", trackId) + .put("generation", generation) if (limit.includeAlbum) { objectValue.put("album", album.take(limit.albumLength)) } @@ -1802,6 +2390,7 @@ class BleGattServerManager( currentAlbumArtId = protocolId currentAlbumArtPlaybackState = JSONObject(playbackState.toString()) albumArtSourceRetryCounts.clear() + albumArtUnavailableProtocolIds.clear() CurrentTrackRuntimeCache.updateAlbumArt( trackId = protocolId, albumArtId = protocolId, @@ -1848,18 +2437,11 @@ class BleGattServerManager( return } startAlbumArtFastPathLoad(pending, mediaGeneration) - val generation = ++albumArtScheduleGeneration - logger("[AlbumArt] scheduled after cooldown id=$protocolId") - albumArtHandler.postDelayed( - { - synchronized(this) { - if (generation == albumArtScheduleGeneration) { - enqueueAlbumArtOfferOrPending(pending) - } - } - }, - ALBUM_ART_COOLDOWN_AFTER_TRACK_CHANGE_MS - ) + // The offer is intentionally sent before the bitmap is ready. iOS can + // request preview immediately; the pending-request path fulfills it as + // soon as the fast source read completes. Capability negotiation still + // provides the only required (maximum 250 ms) compatibility gate. + enqueueAlbumArtOfferOrPending(pending) } /** @@ -1885,10 +2467,10 @@ class BleGattServerManager( currentAlbumArtPlaybackState = null pendingAlbumArt = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() albumArtPendingRequests.clear() albumArtSourceRetryCounts.clear() - albumArtScheduleGeneration += 1 + albumArtUnavailableProtocolIds.clear() notifyQueue.cancelJobTypes( setOf(ALBUM_ART_JOB_TYPE), "no active QQ track" @@ -1898,7 +2480,10 @@ class BleGattServerManager( } } - private fun enqueueAlbumArtOfferOrPending(pending: PendingAlbumArt) { + private fun enqueueAlbumArtOfferOrPending( + pending: PendingAlbumArt, + targetDevice: BluetoothDevice? = null + ) { if (!ALBUM_ART_ENABLED) { return } @@ -1906,26 +2491,16 @@ class BleGattServerManager( logger("[AlbumArt] offer skipped reason=empty protocol id") return } - if (!clientCapabilitiesNegotiated && - SystemClock.elapsedRealtime() - lastSubscribedAtMs < CLIENT_CAPABILITY_WAIT_MS - ) { - pendingAlbumArt = pending - logger("[AlbumArt] offer deferred reason=capability negotiation id=${pending.protocolId}") - return - } - if (notifyQueue.hasJobTypeActiveOrQueued(ALBUM_ART_JOB_TYPE) || - albumArtRequestsInFlight.isNotEmpty() - ) { - pendingAlbumArt = pending - logger("[AlbumArt] pending new id=${pending.protocolId}") - return - } - val cacheKey = pending.cacheKey val protocolId = pending.protocolId logAlbumArtDebugIdentity(pending.playbackState, protocolId) - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[AlbumArt][BLE] no iPhone subscriber") + val devices = if (targetDevice != null) { + listOfNotNull(subscribedDevices[targetDevice.address]) + } else { + subscribedDevices.values.toList() + } + if (devices.isEmpty()) { + logger("[AlbumArt][BLE] no controller subscriber") return } currentAlbumArtId = protocolId @@ -1935,32 +2510,60 @@ class BleGattServerManager( albumArtState = "offer", logger = logger ) - albumArtRequestsInFlight.removeIf { - !it.startsWith("$protocolId|") - } - albumArtRequestsCompleted.removeIf { - !it.startsWith("$protocolId|") - } + albumArtRequestsInFlight + .filter { requestKey -> + requestKey.substringAfter('|').substringBefore('|') != protocolId + } + .forEach(albumArtRequestsInFlight::remove) + albumArtRequestCompletedAtMs.keys + .filter { requestKey -> + requestKey.substringAfter('|').substringBefore('|') != protocolId + } + .forEach(albumArtRequestCompletedAtMs::remove) val offer = JSONObject() .put("type", "albumArtOffer") .put("id", protocolId) .toString() .toByteArray(Charsets.UTF_8) - if (offer.size > maximumPayloadFor(device)) { - logger("[AlbumArt] offer failed id=$protocolId reason=MTU") - return + lastAlbumArtKey = cacheKey + currentAlbumArtPlaybackState = JSONObject(pending.playbackState.toString()) + devices.forEach { device -> + val capabilities = connectionCommandCoordinator.capabilities(device.address) + val subscribedAtMs = connectionCommandCoordinator.subscribedAtMs(device.address) + if (!capabilities.negotiated && + SystemClock.elapsedRealtime() - subscribedAtMs < CLIENT_CAPABILITY_WAIT_MS + ) { + pendingAlbumArt = pending + logger( + "[AlbumArt] offer deferred reason=capability negotiation " + + "id=$protocolId device=${device.address}" + ) + return@forEach + } + if (notifyQueue.hasJobTypeActiveOrQueued(ALBUM_ART_JOB_TYPE, device.address) || + albumArtRequestsInFlight.any { it.startsWith("${device.address}|") } + ) { + pendingAlbumArt = pending + logger("[AlbumArt] pending new id=$protocolId device=${device.address}") + return@forEach + } + if (offer.size > maximumPayloadFor(device)) { + logger( + "[AlbumArt] offer failed id=$protocolId " + + "device=${device.address} reason=MTU" + ) + return@forEach + } + logger("[AlbumArt] offer id=$protocolId device=${device.address}") + logger("[AlbumArtDebug] offer sent id=$protocolId device=${device.address}") + notifyQueue.enqueueShort( + device = device, + type = "albumArtOffer", + value = offer, + delayAfterMs = SHORT_MESSAGE_DELAY_MS + ) } - lastAlbumArtKey = cacheKey - currentAlbumArtPlaybackState = JSONObject(pending.playbackState.toString()) - logger("[AlbumArt] offer id=$protocolId") - logger("[AlbumArtDebug] offer sent id=$protocolId") - notifyQueue.enqueueShort( - device = device, - type = "albumArtOffer", - value = offer, - delayAfterMs = SHORT_MESSAGE_DELAY_MS - ) } @Synchronized @@ -1968,6 +2571,9 @@ class BleGattServerManager( val pending = pendingAlbumArt ?: return pendingAlbumArt = null logger("[AlbumArt] send pending id=${pending.protocolId}") + // Re-evaluate every subscribed controller. A second client may still + // be negotiating capabilities or waiting behind its own art transfer; + // enqueueShort coalesces duplicate offers per device. enqueueAlbumArtOfferOrPending(pending) } @@ -2141,7 +2747,7 @@ class BleGattServerManager( } return@synchronized } - if (isLikelyPlaceholderAlbumArt(albumArt.bitmap)) { + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(albumArt.bitmap)) { reactiveMediaController.markAlbumArtFinished( trackId = pending.protocolId, generation = mediaGeneration, @@ -2173,6 +2779,11 @@ class BleGattServerManager( return@synchronized } val costMs = SystemClock.elapsedRealtime() - startedAt + val hadWaitingClientRequest = albumArtPendingRequests.values.any { + it.protocolId == pending.protocolId + } + val recoveredFromUnavailable = + albumArtUnavailableProtocolIds.remove(pending.protocolId) putAlbumArtCache( protocolId = pending.protocolId, cacheKey = pending.cacheKey, @@ -2216,6 +2827,17 @@ class BleGattServerManager( reason = albumArt.source ) fulfillPendingAlbumArtRequests(pending.protocolId) + if (AlbumArtRequestPolicy.shouldResendOfferAfterRecovery( + recoveredFromUnavailable = recoveredFromUnavailable, + hadWaitingClientRequest = hadWaitingClientRequest + ) + ) { + logger( + "[AlbumArtFastPath] recovered real artwork, " + + "resend offer id=${pending.protocolId}" + ) + enqueueAlbumArtOfferOrPending(pending) + } // The same track can resume while an older source-read is // still completing. Flush its refreshed offer now rather // than waiting for another media-session callback. @@ -2237,11 +2859,20 @@ class BleGattServerManager( synchronized(albumArtCache) { for (key in keys) { val entry = albumArtCache[key] ?: continue + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(entry.bitmap)) { + removeAlbumArtCacheEntryLocked(entry) + removeEncodedAlbumArtForProtocol(entry.protocolId) + logger( + "[AlbumArtCache] evict key=$key " + + "reason=qq_fallback_placeholder" + ) + continue + } if (now - entry.createdAtElapsedMs <= ALBUM_ART_CACHE_TTL_MS) { logger("[AlbumArtCache] hit key=$key id=${entry.protocolId}") return entry } - albumArtCache.remove(key) + removeAlbumArtCacheEntryLocked(entry) logger("[AlbumArtCache] evict key=$key reason=ttl") } } @@ -2268,6 +2899,7 @@ class BleGattServerManager( synchronized(albumArtCache) { albumArtCache[albumArtPrimaryCacheKey(protocolId)] = entry albumArtCache[albumArtFallbackCacheKey(cacheKey)] = entry + trimAlbumArtCacheLocked() } logger( "[AlbumArtCache] put id=$protocolId source=$source " + @@ -2275,6 +2907,36 @@ class BleGattServerManager( ) } + private fun trimAlbumArtCacheLocked() { + val uniqueEntries = Collections.newSetFromMap( + IdentityHashMap() + ) + uniqueEntries.addAll(albumArtCache.values) + var totalBytes = uniqueEntries.sumOf { it.byteSize.toLong() } + while ( + uniqueEntries.size > ALBUM_ART_CACHE_CAPACITY || + (totalBytes > ALBUM_ART_CACHE_MAX_BYTES && uniqueEntries.size > 1) + ) { + val eldest = albumArtCache.entries.firstOrNull()?.value ?: break + removeAlbumArtCacheEntryLocked(eldest) + uniqueEntries.remove(eldest) + totalBytes -= eldest.byteSize.toLong() + logger( + "[AlbumArtCache] evict id=${eldest.protocolId} " + + "reason=memory entries=${uniqueEntries.size} bytes=$totalBytes" + ) + } + } + + private fun removeAlbumArtCacheEntryLocked(entry: AlbumArtCacheEntry) { + val iterator = albumArtCache.entries.iterator() + while (iterator.hasNext()) { + if (iterator.next().value === entry) { + iterator.remove() + } + } + } + private fun fulfillPendingAlbumArtRequests(protocolId: String) { val cacheKey = currentAlbumArtPlaybackState?.let(::buildAlbumArtCacheKey) val entry = albumArtCacheEntry(protocolId, cacheKey) ?: return @@ -2300,6 +2962,7 @@ class BleGattServerManager( } private fun failPendingAlbumArtRequests(protocolId: String, reason: String) { + albumArtUnavailableProtocolIds.add(protocolId) val pending = albumArtPendingRequests.values .filter { it.protocolId == protocolId } .toList() @@ -2326,17 +2989,25 @@ class BleGattServerManager( } @Synchronized - private fun handleAlbumArtRequest(request: JSONObject) { + private fun handleAlbumArtRequest(device: BluetoothDevice, request: JSONObject) { val protocolId = request.optString("id") val quality = AlbumArtQuality.fromWireValue( request.optString("quality") ) if (protocolId.isBlank() || protocolId != currentAlbumArtId) { logger("[AlbumArt] request ignored stale id=$protocolId") + sendCommandError( + device, request, "artwork", "stale_track", false, + trackId = protocolId + ) return } if (quality == null) { logger("[AlbumArt] request ignored invalid quality") + sendCommandError( + device, request, "artwork", "invalid_quality", false, + trackId = protocolId + ) return } if (!ALBUM_ART_ENABLED) { @@ -2348,22 +3019,46 @@ class BleGattServerManager( return } if (quality == AlbumArtQuality.HQ) { - if (!clientSupportsBinaryAlbumArt) { + if (!connectionCommandCoordinator.capabilities(device.address).binaryAlbumArt) { logger("[AlbumArtHQ] request skipped reason=binary unsupported") return } } - val requestKey = "$protocolId|${quality.wireValue}" - if (albumArtRequestsCompleted.contains(requestKey)) { - logger("[AlbumArt] skip duplicate sending id=$protocolId") + val requestKey = "${device.address}|$protocolId|${quality.wireValue}" + val forceRefresh = request.optBoolean("forceRefresh", false) + val nowMs = SystemClock.elapsedRealtime() + if (!AlbumArtRequestPolicy.shouldAllowCompletedRequest( + lastCompletedAtMs = albumArtRequestCompletedAtMs[requestKey], + nowMs = nowMs, + forceRefresh = forceRefresh + ) + ) { + logger( + "[AlbumArt] skip recent duplicate id=$protocolId " + + "quality=${quality.wireValue}" + ) return } if (!albumArtRequestsInFlight.add(requestKey)) { - logger("[AlbumArt] skip duplicate sending id=$protocolId") + logger( + "[AlbumArt] skip in-flight duplicate id=$protocolId " + + "quality=${quality.wireValue}" + ) return } logger( - "[AlbumArt] request id=$protocolId quality=${quality.wireValue}" + "[AlbumArt] request id=$protocolId quality=${quality.wireValue} " + + "forceRefresh=$forceRefresh" + ) + sendMediaLoadState( + device = device, + resource = "artwork", + stage = "preparing", + reason = "transfer_preparing", + retryable = false, + trackId = protocolId, + generation = playbackStateReader.runtimeCacheSnapshot() + .track?.currentTrackGeneration ?: 0L ) TrackCapabilityTracker.onAlbumArtRequested(protocolId) if (quality == AlbumArtQuality.HQ) { @@ -2377,11 +3072,10 @@ class BleGattServerManager( ) logger("[AlbumArtDebug] id=$protocolId") - val device = subscribedDevices.values.firstOrNull() - if (device == null) { + if (!subscribedDevices.containsKey(device.address)) { albumArtRequestsInFlight.remove(requestKey) - logger("[AlbumArt] request failed: no iPhone subscriber") - logger("[AlbumArtDebug] unavailable reason=no iPhone subscriber") + logger("[AlbumArt] request failed: controller no longer subscribed") + logger("[AlbumArtDebug] unavailable reason=controller unsubscribed") return } val cached = albumArtCacheEntry(protocolId, buildAlbumArtCacheKeyFromProtocol(protocolId)) @@ -2398,6 +3092,17 @@ class BleGattServerManager( ) return } + sendMediaLoadState( + device = device, + resource = "artwork", + stage = "waiting", + reason = "source_pending", + retryable = true, + trackId = protocolId, + generation = playbackStateReader.runtimeCacheSnapshot() + .track?.currentTrackGeneration ?: 0L, + retryAfterMs = ALBUM_ART_SOURCE_RETRY_DELAYS_MS.first() + ) albumArtPendingRequests[requestKey] = PendingAlbumArtRequest( device = device, protocolId = protocolId, @@ -2427,7 +3132,7 @@ class BleGattServerManager( ) val useBinaryAlbumArt = (quality == AlbumArtQuality.PREVIEW || quality == AlbumArtQuality.HQ) && - clientSupportsBinaryAlbumArt + connectionCommandCoordinator.capabilities(device.address).binaryAlbumArt val maximumPayload = if (useBinaryAlbumArt) { albumArtMaximumPayloadFor(device) } else { @@ -2470,6 +3175,7 @@ class BleGattServerManager( val albumPackets = preparedAlbumArt.packets if (useBinaryAlbumArt) { rememberAlbumArtBinaryTransfer( + device = device, protocolId = protocolId, quality = quality, packets = albumPackets.packets @@ -2518,6 +3224,12 @@ class BleGattServerManager( "quality=${quality.wireValue} chunks=$totalChunks" ) } + val artworkGeneration = playbackStateReader.runtimeCacheSnapshot() + .track?.currentTrackGeneration ?: 0L + sendMediaLoadState( + device, "artwork", "transferring", "transfer_preparing", false, + protocolId, artworkGeneration + ) notifyQueue.enqueueLongJob( type = ALBUM_ART_JOB_TYPE, device = device, @@ -2537,7 +3249,7 @@ class BleGattServerManager( }, onComplete = { albumArtRequestsInFlight.remove(requestKey) - albumArtRequestsCompleted.add(requestKey) + albumArtRequestCompletedAtMs[requestKey] = SystemClock.elapsedRealtime() val costMs = SystemClock.elapsedRealtime() - startAt if (quality == AlbumArtQuality.HQ) { logger("[AlbumArtHQ] send end costMs=$costMs") @@ -2557,6 +3269,10 @@ class BleGattServerManager( "costMs=$costMs" ) TrackCapabilityTracker.onAlbumArtSent(protocolId) + sendMediaLoadState( + device, "artwork", "ready", "transfer_complete", false, + protocolId, artworkGeneration + ) sendPendingAlbumArtIfAny() CurrentTrackRuntimeCache.updateAlbumArt( trackId = protocolId, @@ -2567,6 +3283,10 @@ class BleGattServerManager( }, onFailure = { albumArtRequestsInFlight.remove(requestKey) + sendMediaLoadState( + device, "artwork", "failed", "transfer_failed", true, + protocolId, artworkGeneration + ) if (quality == AlbumArtQuality.HQ && currentAlbumArtId != protocolId) { logger("[AlbumArtHQ] cancelled reason=track changed") } @@ -2593,9 +3313,29 @@ class BleGattServerManager( quality.wireValue, bitmap.width, bitmap.height, - bitmap.generationId, - binaryTransport + bitmap.generationId ).joinToString("|") + val preparationLock = albumArtPreparationLocks.getOrPut(encodedCacheKey) { Any() } + return synchronized(preparationLock) { + prepareAlbumArtLocked( + bitmap = bitmap, + deviceMaximumPayload = deviceMaximumPayload, + protocolId = protocolId, + quality = quality, + binaryTransport = binaryTransport, + encodedCacheKey = encodedCacheKey + ) + } + } + + private fun prepareAlbumArtLocked( + bitmap: Bitmap, + deviceMaximumPayload: Int, + protocolId: String, + quality: AlbumArtQuality, + binaryTransport: Boolean, + encodedCacheKey: String + ): AlbumArtPreparation { getEncodedAlbumArt(encodedCacheKey)?.let { cached -> val cachedPackets = if (binaryTransport) { buildBinaryAlbumArtPackets( @@ -2842,6 +3582,21 @@ class BleGattServerManager( } } + private fun removeEncodedAlbumArtForProtocol(protocolId: String) { + synchronized(encodedAlbumArtCache) { + val prefix = "$protocolId|" + val iterator = encodedAlbumArtCache.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key.startsWith(prefix)) { + encodedAlbumArtCacheBytes -= entry.value.bytes.size + iterator.remove() + } + } + encodedAlbumArtCacheBytes = encodedAlbumArtCacheBytes.coerceAtLeast(0) + } + } + private fun estimateAlbumArtChunks( binaryTransport: Boolean, deviceMaximumPayload: Int, @@ -3188,6 +3943,21 @@ class BleGattServerManager( byteSize = 0, reason = reason ) + sendMediaLoadState( + device = device, + resource = "artwork", + stage = if (reason.contains("compress", true)) "failed" else "unavailable", + reason = when { + reason.contains("compress", true) -> "encode_failed" + reason.contains("source", true) || reason.contains("not found", true) -> + "source_unavailable" + else -> "transfer_failed" + }, + retryable = false, + trackId = protocolId, + generation = playbackStateReader.runtimeCacheSnapshot() + .track?.currentTrackGeneration ?: 0L + ) if (quality == AlbumArtQuality.HQ) { logger( "[AlbumArtHQ] unavailable sent reason=$reason " + @@ -3283,69 +4053,18 @@ class BleGattServerManager( logger("[AlbumArtDebug] id=$protocolId") } - private fun isLikelyPlaceholderAlbumArt(bitmap: Bitmap): Boolean { - if (bitmap.width <= 0 || bitmap.height <= 0) return true - val sampleWidth = minOf(24, bitmap.width) - val sampleHeight = minOf(24, bitmap.height) - val sampled = if (sampleWidth == bitmap.width && - sampleHeight == bitmap.height - ) { - bitmap - } else { - Bitmap.createScaledBitmap(bitmap, sampleWidth, sampleHeight, true) - } - return try { - val pixels = IntArray(sampleWidth * sampleHeight) - sampled.getPixels( - pixels, - 0, - sampleWidth, - 0, - 0, - sampleWidth, - sampleHeight - ) - val colorBuckets = HashSet() - var colorfulPixels = 0 - var visiblePixels = 0 - pixels.forEach { pixel -> - val alpha = pixel ushr 24 - if (alpha < 24) return@forEach - val red = (pixel ushr 16) and 0xff - val green = (pixel ushr 8) and 0xff - val blue = pixel and 0xff - val maximum = maxOf(red, green, blue) - val minimum = minOf(red, green, blue) - if (maximum > 0 && - (maximum - minimum).toFloat() / maximum.toFloat() > 0.12f - ) { - colorfulPixels += 1 - } - colorBuckets.add( - ((red / 32) shl 10) or - ((green / 32) shl 5) or - (blue / 32) - ) - visiblePixels += 1 - } - visiblePixels == 0 || (bitmap.width <= 16 && bitmap.height <= 16) - } finally { - if (sampled !== bitmap) sampled.recycle() - } - } - private fun maximumPayloadFor(device: BluetoothDevice): Int { val mtu = mtuByAddress[device.address] ?: DEFAULT_MTU - return (mtu - ATT_HEADER_SIZE).coerceAtLeast(0) + return BleGattPayloadPolicy.maximumNotificationPayload(mtu) } private fun albumArtMaximumPayloadFor(device: BluetoothDevice): Int { return maximumPayloadFor(device) } - private fun sendRemoteLogs(limit: Int) { - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[RemoteLog] send skipped: no iPhone subscriber") + private fun sendRemoteLogs(device: BluetoothDevice, limit: Int) { + if (!subscribedDevices.containsKey(device.address)) { + logger("[RemoteLog] send skipped: controller unsubscribed") return } val lines = LogBuffer.getRecentLogs(limit) @@ -3437,13 +4156,13 @@ class BleGattServerManager( ) } - private fun sendMediaFieldDump() { + private fun sendMediaFieldDump(device: BluetoothDevice) { logger("[MediaFieldDump] requested") - if (subscribedDevices.isEmpty()) { - logger("[MediaFieldDump] send failed: no iPhone subscriber") + if (!subscribedDevices.containsKey(device.address)) { + logger("[MediaFieldDump] send failed: controller unsubscribed") return } - if (notifyQueue.hasJobTypeActiveOrQueued(MEDIA_FIELD_DUMP_JOB_TYPE) || + if (notifyQueue.hasJobTypeActiveOrQueued(MEDIA_FIELD_DUMP_JOB_TYPE, device.address) || !mediaFieldDumpPreparing.compareAndSet(false, true) ) { logger("[MediaFieldDump] request already active") @@ -3455,13 +4174,14 @@ class BleGattServerManager( val dump = mediaFieldDumpManager.dumpAllFields() val bytes = dump.toByteArray(Charsets.UTF_8) logger("[MediaFieldDump] built bytes=${bytes.size}") - enqueueMediaFieldDump(bytes) + enqueueMediaFieldDump(device, bytes) } catch (throwable: Throwable) { logger( "[MediaFieldDump] build failed: " + "${throwable.javaClass.simpleName}: ${throwable.message}" ) sendMediaFieldDumpError( + device, "${throwable.javaClass.simpleName}: ${throwable.message}" ) } finally { @@ -3470,15 +4190,15 @@ class BleGattServerManager( } } - private fun enqueueMediaFieldDump(bytes: ByteArray) { - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[MediaFieldDump] send failed: no iPhone subscriber") + private fun enqueueMediaFieldDump(device: BluetoothDevice, bytes: ByteArray) { + if (!subscribedDevices.containsKey(device.address)) { + logger("[MediaFieldDump] send failed: controller unsubscribed") return } val maximumPayload = maximumPayloadFor(device) val rawChunkSize = chooseMediaFieldDumpChunkSize(maximumPayload) if (rawChunkSize <= 0) { - sendMediaFieldDumpError("MTU too small") + sendMediaFieldDumpError(device, "MTU too small") return } @@ -3492,7 +4212,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (start.size > maximumPayload) { - sendMediaFieldDumpError("start packet exceeds MTU") + sendMediaFieldDumpError(device, "start packet exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3517,7 +4237,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (chunk.size > maximumPayload) { - sendMediaFieldDumpError("chunk $index exceeds MTU") + sendMediaFieldDumpError(device, "chunk $index exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3532,7 +4252,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (end.size > maximumPayload) { - sendMediaFieldDumpError("end packet exceeds MTU") + sendMediaFieldDumpError(device, "end packet exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3571,8 +4291,8 @@ class BleGattServerManager( return 0 } - private fun sendMediaFieldDumpError(message: String) { - val device = subscribedDevices.values.firstOrNull() ?: return + private fun sendMediaFieldDumpError(device: BluetoothDevice, message: String) { + if (!subscribedDevices.containsKey(device.address)) return val maximumPayload = maximumPayloadFor(device) val safeMessage = message.take(MAX_MEDIA_FIELD_DUMP_ERROR_CHARS) val value = JSONObject() @@ -3592,7 +4312,7 @@ class BleGattServerManager( ) } - private fun sendPlayHistoryPage(request: JSONObject) { + private fun sendPlayHistoryPage(device: BluetoothDevice, request: JSONObject) { val requestId = request.optString("requestId").ifBlank { "history-${System.currentTimeMillis()}" } @@ -3605,7 +4325,7 @@ class BleGattServerManager( } val limit = request.optInt("limit", DEFAULT_HISTORY_PAGE_LIMIT) .coerceIn(1, MAX_HISTORY_PAGE_LIMIT) - executeHistoryQuery(requestId, PLAY_HISTORY_JOB_TYPE, "playHistoryPage") { + executeHistoryQuery(device, requestId, PLAY_HISTORY_JOB_TYPE, "playHistoryPage") { val startedAtMs = SystemClock.elapsedRealtime() val rowsWithLookahead = PlaybackHistoryRepository(appContext) .getRecentSessions(beforeSessionId, limit + 1) @@ -3623,14 +4343,14 @@ class BleGattServerManager( } } - private fun sendPlayHistorySince(request: JSONObject) { + private fun sendPlayHistorySince(device: BluetoothDevice, request: JSONObject) { val requestId = request.optString("requestId").ifBlank { "history-since-${System.currentTimeMillis()}" } val afterSessionId = request.optLong("afterSessionId", 0L).coerceAtLeast(0L) val limit = request.optInt("limit", DEFAULT_HISTORY_PAGE_LIMIT) .coerceIn(1, MAX_HISTORY_PAGE_LIMIT) - executeHistoryQuery(requestId, PLAY_HISTORY_JOB_TYPE, "playHistorySince") { + executeHistoryQuery(device, requestId, PLAY_HISTORY_JOB_TYPE, "playHistorySince") { val startedAtMs = SystemClock.elapsedRealtime() val rowsWithLookahead = PlaybackHistoryRepository(appContext) .getSessionsAfterId(afterSessionId, limit + 1) @@ -3648,17 +4368,17 @@ class BleGattServerManager( } } - private fun sendPlayStats(request: JSONObject) { + private fun sendPlayStats(device: BluetoothDevice, request: JSONObject) { val requestId = request.optString("requestId").ifBlank { "stats-${System.currentTimeMillis()}" } val rangeValue = request.optString("range", StatsRange.WEEK.name) val range = runCatching { StatsRange.valueOf(rangeValue.uppercase()) }.getOrNull() if (range == null) { - sendHistoryError(requestId, "playStats", "invalid range=$rangeValue") + sendHistoryError(device, requestId, "playStats", "invalid range=$rangeValue") return } - executeHistoryQuery(requestId, PLAY_STATS_JOB_TYPE, "playStats") { + executeHistoryQuery(device, requestId, PLAY_STATS_JOB_TYPE, "playStats") { val startedAtMs = SystemClock.elapsedRealtime() val stats = PlaybackHistoryRepository(appContext).stats(range) logger( @@ -3670,18 +4390,19 @@ class BleGattServerManager( } private fun executeHistoryQuery( + device: BluetoothDevice, requestId: String, jobType: String, responseType: String, buildPayload: () -> JSONObject ) { - val device = subscribedDevices.values.firstOrNull() ?: run { + if (!subscribedDevices.containsKey(device.address)) { logger("[HistoryBLE] request skipped requestId=$requestId reason=no subscriber") return } if (!historyQueryPreparing.compareAndSet(false, true)) { logger("[HistoryBLE] request busy requestId=$requestId") - sendHistoryError(requestId, responseType, "history query already active") + sendHistoryError(device, requestId, responseType, "history query already active") return } logger("[HistoryBLE] request type=$responseType requestId=$requestId") @@ -3691,7 +4412,12 @@ class BleGattServerManager( enqueueHistoryPayload(device, jobType, responseType, requestId, buildPayload()) } catch (throwable: Throwable) { logger("[HistoryBLE] failed requestId=$requestId reason=${throwable.message}") - sendHistoryError(requestId, responseType, throwable.message ?: "query failed") + sendHistoryError( + device, + requestId, + responseType, + throwable.message ?: "query failed" + ) } finally { historyQueryPreparing.set(false) } @@ -3720,7 +4446,7 @@ class BleGattServerManager( } val rawChunkSize = chooseHistoryChunkSize(maximumPayload, requestId) if (rawChunkSize <= 0) { - sendHistoryError(requestId, responseType, "MTU too small") + sendHistoryError(device, requestId, responseType, "MTU too small") return } val totalChunks = (bytes.size + rawChunkSize - 1) / rawChunkSize @@ -3734,7 +4460,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (start.size > maximumPayload) { - sendHistoryError(requestId, responseType, "start packet exceeds MTU") + sendHistoryError(device, requestId, responseType, "start packet exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3756,7 +4482,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (chunk.size > maximumPayload) { - sendHistoryError(requestId, responseType, "chunk $index exceeds MTU") + sendHistoryError(device, requestId, responseType, "chunk $index exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3772,7 +4498,7 @@ class BleGattServerManager( .toString() .toByteArray(Charsets.UTF_8) if (end.size > maximumPayload) { - sendHistoryError(requestId, responseType, "end packet exceeds MTU") + sendHistoryError(device, requestId, responseType, "end packet exceeds MTU") return } packets += BleNotifyQueue.Packet( @@ -3811,8 +4537,13 @@ class BleGattServerManager( return 0 } - private fun sendHistoryError(requestId: String, responseType: String, message: String) { - val device = subscribedDevices.values.firstOrNull() ?: return + private fun sendHistoryError( + device: BluetoothDevice, + requestId: String, + responseType: String, + message: String + ) { + if (!subscribedDevices.containsKey(device.address)) return val value = JSONObject() .put("type", "playHistoryError") .put("requestId", requestId) @@ -3912,8 +4643,12 @@ class BleGattServerManager( }) } - private fun sendFullLyrics(request: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: run { + private fun sendFullLyrics(device: BluetoothDevice, request: JSONObject) { + if (request.optBoolean("forceRefresh", false)) { + val started = playbackStateReader.manualRefreshCurrentLyric() + logger("[LyricRetry] BLE force refresh requested started=$started") + } + if (!subscribedDevices.containsKey(device.address)) { val requestedTrackId = request.optString("trackId") if (requestedTrackId.isNotBlank()) { lyricTrace( @@ -3923,12 +4658,12 @@ class BleGattServerManager( extra = mapOf("status" to "no_subscriber") ) } - logger("[FullLyrics] send skipped: no iPhone subscriber") + logger("[FullLyrics] send skipped: controller unsubscribed") return } val buildStartedAtMs = SystemClock.elapsedRealtime() val requestedTrackId = request.optString("trackId") - if (notifyQueue.hasJobTypeActiveOrQueued(FULL_LYRICS_JOB_TYPE)) { + if (notifyQueue.hasJobTypeActiveOrQueued(FULL_LYRICS_JOB_TYPE, device.address)) { logger( "[FullLyrics] request skipped reason=transfer_in_progress " + "trackId=$requestedTrackId" @@ -4016,6 +4751,7 @@ class BleGattServerManager( val diagnostic = playbackStateReader.lyricDiagnosticSnapshot() if (!readyGate.lyricsReady || candidateLines.isEmpty()) { rememberPendingFullLyricsRequest( + device = device, request = request, requestedTrackId = requestedTrackId, protocolTrackId = trackId, @@ -4031,20 +4767,23 @@ class BleGattServerManager( } else { false } + var deferredForForegroundParse = false if (unavailableReason == "lyrics loading" && !request.optBoolean("_lyricsReadyRetry", false) ) { logger("[FullLyrics] pending request reason=lyrics loading") val retryRequest = JSONObject(request.toString()) .put("_lyricsReadyRetry", true) - val pendingToken = pendingFullLyricsRequest + val pendingToken = pendingFullLyricsRequests[device.address] albumArtHandler.postDelayed({ dispatchPendingFullLyricsRetry( + device = device, pendingToken = pendingToken, retryRequest = retryRequest, reason = "lyrics loading" ) }, FULL_LYRICS_PENDING_RETRY_DELAY_MS) + deferredForForegroundParse = true } else if (recoveryRetryStarted) { logger( "[FullLyrics] pending request reason=$unavailableReason " + @@ -4052,15 +4791,64 @@ class BleGattServerManager( ) val retryRequest = JSONObject(request.toString()) .put("_lyricsRecoveryRetry", true) - val pendingToken = pendingFullLyricsRequest + val pendingToken = pendingFullLyricsRequests[device.address] albumArtHandler.postDelayed({ dispatchPendingFullLyricsRetry( + device = device, pendingToken = pendingToken, retryRequest = retryRequest, reason = "recovery nudge" ) }, FULL_LYRICS_RECOVERY_RETRY_DELAY_MS) + deferredForForegroundParse = true + } + if (deferredForForegroundParse) { + sendMediaLoadState( + device = device, + resource = "lyrics", + stage = "waiting", + reason = "qrc_pending", + retryable = true, + trackId = trackId, + generation = runtimeGeneration, + retryAfterMs = FULL_LYRICS_RECOVERY_RETRY_DELAY_MS + ) + sendCommandError( + device = device, + request = request, + domain = "lyrics", + code = "qrc_pending", + retryable = true, + retryAfterMs = FULL_LYRICS_RECOVERY_RETRY_DELAY_MS, + trackId = trackId, + generation = runtimeGeneration + ) + logger( + "[FullLyrics] unavailable deferred trackId=$trackId " + + "reason=$unavailableReason maxWaitMs=$FULL_LYRICS_RECOVERY_RETRY_DELAY_MS" + ) + return } + val v3Reason = lyricLoadReasonCode(unavailableReason) + val parseFailed = v3Reason == "qrc_parse_failed" || v3Reason == "transfer_failed" + sendMediaLoadState( + device = device, + resource = "lyrics", + stage = if (parseFailed) "failed" else "unavailable", + reason = v3Reason, + retryable = false, + trackId = trackId, + generation = runtimeGeneration + ) + sendCommandError( + device = device, + request = request, + domain = "lyrics", + code = v3Reason, + retryable = false, + trackId = trackId, + generation = runtimeGeneration + ) val unavailable = JSONObject() .put("type", "fullLyricsUnavailable") .put("trackId", trackId) @@ -4092,14 +4880,29 @@ class BleGattServerManager( TrackCapabilityTracker.onFullLyricsSent(traceId, trackId, 0) return } - clearMatchingPendingFullLyrics(requestedTrackId, trackId, runtimeGeneration) + sendMediaLoadState( + device = device, + resource = "lyrics", + stage = "preparing", + reason = "transfer_preparing", + retryable = false, + trackId = trackId, + generation = runtimeGeneration + ) + clearMatchingPendingFullLyrics( + device.address, + requestedTrackId, + trackId, + runtimeGeneration + ) val maximumPayload = maximumPayloadFor(device) val requestedFormat = request.optString("format") + val negotiatedCapabilities = connectionCommandCoordinator.capabilities(device.address) if (requestedFormat == FULL_LYRICS_ZLIB_FORMAT && - clientCapabilitiesNegotiated && - clientProtocolVersion >= SERVER_PROTOCOL_VERSION && - clientSupportsFullLyricsZlib && + negotiatedCapabilities.negotiated && + negotiatedCapabilities.protocolVersion >= SERVER_PROTOCOL_VERSION && + negotiatedCapabilities.fullLyricsZlib && sendCompressedFullLyrics( device = device, trackId = trackId, @@ -4114,7 +4917,12 @@ class BleGattServerManager( buildStartedAtMs = buildStartedAtMs ) ) { - clearMatchingPendingFullLyrics(requestedTrackId, trackId, runtimeGeneration) + clearMatchingPendingFullLyrics( + device.address, + requestedTrackId, + trackId, + runtimeGeneration + ) return } val packets = mutableListOf() @@ -4209,6 +5017,10 @@ class BleGattServerManager( ) ) val sendStartedAtMs = SystemClock.elapsedRealtime() + sendMediaLoadState( + device, "lyrics", "transferring", "transfer_preparing", false, + trackId, readyGate.generation + ) logger("[FullLyrics] send start trackId=$trackId count=${lines.size}") lyricTrace( stage = "fullLyricsSendStart", @@ -4229,6 +5041,10 @@ class BleGattServerManager( "${SystemClock.elapsedRealtime() - sendStartedAtMs}" ) logger("[FullLyrics] send end trackId=$trackId") + sendMediaLoadState( + device, "lyrics", "ready", "transfer_complete", false, + trackId, readyGate.generation + ) lyricTrace( stage = "fullLyricsSendEnd", trackId = traceId, @@ -4238,6 +5054,17 @@ class BleGattServerManager( extra = mapOf("lines" to lines.size.toString()) ) TrackCapabilityTracker.onFullLyricsSent(traceId, trackId, lines.size) + }, + onFailure = { + sendMediaLoadState( + device, "lyrics", "failed", "transfer_failed", true, + trackId, readyGate.generation + ) + sendCommandError( + device, request, "lyrics", "transfer_failed", true, + trackId = trackId, + generation = readyGate.generation + ) } ) } @@ -4255,53 +5082,99 @@ class BleGattServerManager( songKey: String, buildStartedAtMs: Long ): Boolean { - val body = JSONObject() - .put("format", FULL_LYRICS_ZLIB_FORMAT) - .put("trackId", trackId) - .put("title", title) - .put("artist", artist) - .put("generation", generation) - .put("count", lines.size) - .put( - "lines", - JSONArray().also { array -> - lines.forEachIndexed { index, line -> - array.put( - JSONObject() - .put("index", index) - .put("timeMs", line.timeMs) - .put("durationMs", line.durationMs) - .put("text", line.text.take(MAX_COMPRESSED_LYRIC_TEXT_LENGTH)) - .also { item -> - if (index in wordLineIndexes && line.words.isNotEmpty()) { - item.put( - "words", - JSONArray().also { words -> - line.words.forEach { word -> - if (word.text.isNotBlank()) { - words.put( - JSONObject() - .put("startMs", word.startMs) - .put("durationMs", word.durationMs) - .put("text", word.text) - ) + val cacheKey = CompressedLyricsCache.Key( + songKey = songKey, + trackId = trackId, + generation = generation, + format = FULL_LYRICS_ZLIB_FORMAT, + contentFingerprint = fullLyricsContentFingerprint( + title = title, + artist = artist, + lines = lines + ), + wordLineIndexes = wordLineIndexes.sorted().joinToString(",") + ) + val compressedLyricsCache = lyricsTransferCoordinator.compressedCache + val cached = compressedLyricsCache.get(cacheKey) + val cacheEntry = cached ?: run { + val body = JSONObject() + .put("format", FULL_LYRICS_ZLIB_FORMAT) + .put("trackId", trackId) + .put("title", title) + .put("artist", artist) + .put("generation", generation) + .put("count", lines.size) + .put( + "lines", + JSONArray().also { array -> + lines.forEachIndexed { index, line -> + array.put( + JSONObject() + .put("index", index) + .put("timeMs", line.timeMs) + .put("durationMs", line.durationMs) + .put( + "text", + line.text.take(MAX_COMPRESSED_LYRIC_TEXT_LENGTH) + ) + .also { item -> + if (index in wordLineIndexes && + line.words.isNotEmpty() + ) { + item.put( + "words", + JSONArray().also { words -> + line.words.forEach { word -> + if (word.text.isNotBlank()) { + words.put( + JSONObject() + .put("startMs", word.startMs) + .put( + "durationMs", + word.durationMs + ) + .put("text", word.text) + ) + } } } - } - ) + ) + } } - } - ) + ) + } } - } - ) - .toString() - .toByteArray(Charsets.UTF_8) - val compressed = BleTransferCodec.zlibCompress(body) + ) + .toString() + .toByteArray(Charsets.UTF_8) + val compressedBody = BleTransferCodec.zlibCompress(body) + if (compressedBody.isEmpty() || + compressedBody.size > MAX_FULL_LYRICS_ZLIB_BYTES + ) { + logger( + "[FullLyricsV2] fallback legacy reason=compressed_size " + + "raw=${body.size} compressed=${compressedBody.size}" + ) + return false + } + CompressedLyricsCache.Entry( + compressed = compressedBody, + uncompressedSize = body.size, + crc32 = BleTransferCodec.crc32Hex(compressedBody) + ).also { compressedLyricsCache.put(cacheKey, it) } + } + val compressed = cacheEntry.compressed + val uncompressedSize = cacheEntry.uncompressedSize + val crc32 = cacheEntry.crc32 + logger( + "[FullLyricsCache] ${if (cached == null) "miss" else "hit"} " + + "trackId=$trackId generation=$generation bytes=${compressed.size} " + + "entries=${compressedLyricsCache.size()} totalBytes=${compressedLyricsCache.bytes()}" + ) if (compressed.isEmpty() || compressed.size > MAX_FULL_LYRICS_ZLIB_BYTES) { logger( "[FullLyricsV2] fallback legacy reason=compressed_size " + - "raw=${body.size} compressed=${compressed.size}" + "raw=$uncompressedSize compressed=${compressed.size}" ) return false } @@ -4317,7 +5190,6 @@ class BleGattServerManager( return false } val transferId = UUID.randomUUID().toString().replace("-", "").take(10) - val crc32 = BleTransferCodec.crc32Hex(compressed) val verboseStart = JSONObject() .put("type", "fullLyricsBinaryStart") .put("trackId", trackId) @@ -4325,7 +5197,7 @@ class BleGattServerManager( .put("generation", generation) .put("format", FULL_LYRICS_ZLIB_FORMAT) .put("size", compressed.size) - .put("uncompressedSize", body.size) + .put("uncompressedSize", uncompressedSize) .put("chunks", binaryChunks.size) .put("count", lines.size) .put("crc32", crc32) @@ -4348,7 +5220,7 @@ class BleGattServerManager( .put("tid", transferId) .put("g", generation) .put("s", compressed.size) - .put("u", body.size) + .put("u", uncompressedSize) .put("c", binaryChunks.size) .put("n", lines.size) .put("crc", crc32) @@ -4385,24 +5257,29 @@ class BleGattServerManager( value = end, delayAfterMs = FULL_LYRICS_JSON_NOTIFICATION_DELAY_MS ) - recentFullLyricsBinaryTransfer = FullLyricsBinaryTransfer( + lyricsTransferCoordinator.retain(FullLyricsBinaryTransfer( trackId = trackId, transferId = transferId, generation = generation, start = startPacket, chunks = chunkPackets, end = endPacket, - expiresAtMs = SystemClock.elapsedRealtime() + FULL_LYRICS_RETRY_TTL_MS - ) + expiresAtMs = SystemClock.elapsedRealtime() + FULL_LYRICS_RETRY_TTL_MS, + ownerAddress = device.address + )) val packets = buildList { add(startPacket) addAll(chunkPackets) add(endPacket) } val sendStartedAtMs = SystemClock.elapsedRealtime() + sendMediaLoadState( + device, "lyrics", "transferring", "transfer_preparing", false, + trackId, generation + ) logger( "[FullLyricsV2] send start trackId=$trackId lines=${lines.size} " + - "raw=${body.size} compressed=${compressed.size} chunks=${binaryChunks.size} " + + "raw=$uncompressedSize compressed=${compressed.size} chunks=${binaryChunks.size} " + "metadata=${if (start === compactStart) "compact" else "verbose"}" ) lyricTrace( @@ -4432,6 +5309,10 @@ class BleGattServerManager( maxSendDurationMs = FULL_LYRICS_MAX_SEND_MS, shouldCancel = { !isLyricsTransferCurrent(trackId, generation) }, onComplete = { + sendMediaLoadState( + device, "lyrics", "ready", "transfer_complete", false, + trackId, generation + ) logger( "[FullLyricsV2] send end trackId=$trackId " + "costMs=${SystemClock.elapsedRealtime() - sendStartedAtMs}" @@ -4448,14 +5329,47 @@ class BleGattServerManager( ) ) TrackCapabilityTracker.onFullLyricsSent(traceId, trackId, lines.size) + }, + onFailure = { + sendMediaLoadState( + device, "lyrics", "failed", "transfer_failed", true, + trackId, generation + ) } ) return true } - private fun sendLyricWindow(request: JSONObject) { - if (!clientCapabilitiesNegotiated || !clientSupportsLyricWindow) return - val device = subscribedDevices.values.firstOrNull() ?: return + private fun fullLyricsContentFingerprint( + title: String, + artist: String, + lines: List + ): Long { + var hash = 1_469_598_103_934_665_603L + fun mix(value: Long) { + hash = (hash xor value) * 1_099_511_628_211L + } + mix(title.hashCode().toLong()) + mix(artist.hashCode().toLong()) + mix(lines.size.toLong()) + lines.forEach { line -> + mix(line.timeMs) + mix(line.durationMs) + mix(line.text.hashCode().toLong()) + mix(line.words.size.toLong()) + line.words.forEach { word -> + mix(word.startMs) + mix(word.durationMs) + mix(word.text.hashCode().toLong()) + } + } + return hash + } + + private fun sendLyricWindow(device: BluetoothDevice, request: JSONObject) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) + if (!capabilities.negotiated || !capabilities.lyricWindow) return + if (!subscribedDevices.containsKey(device.address)) return val source = playbackSourceForRequestedTrack(request.optString("trackId")) val trackId = buildAlbumArtProtocolId(source) val requestedTrackId = request.optString("trackId") @@ -4478,7 +5392,8 @@ class BleGattServerManager( ) ) { val reason = playbackStateReader.lyricUnavailableReason() - pendingLyricWindowRequest = PendingLyricWindowRequest( + pendingLyricWindowRequests[device.address] = PendingLyricWindowRequest( + ownerAddress = device.address, request = JSONObject(request.toString()), requestedTrackId = requestedTrackId, protocolTrackId = trackId, @@ -4490,11 +5405,12 @@ class BleGattServerManager( ) logger( "[LyricWindow] pending trackId=$trackId " + - "generation=${pendingLyricWindowRequest?.generation} reason=$reason" + "generation=${pendingLyricWindowRequests[device.address]?.generation} " + + "device=${device.address} reason=$reason" ) return } - pendingLyricWindowRequest = null + pendingLyricWindowRequests.remove(device.address) val positionMs = request.optLong("positionMs", source.optLong("position", 0L)) val currentIndex = findCurrentLyricIndex(lines, positionMs) val first = (currentIndex - 2).coerceAtLeast(0) @@ -4603,20 +5519,20 @@ class BleGattServerManager( ) } - private fun retryTransfer(request: JSONObject) { - if (!clientCapabilitiesNegotiated || !clientSupportsTransferRetry) return - val device = subscribedDevices.values.firstOrNull() ?: return + private fun retryTransfer(device: BluetoothDevice, request: JSONObject) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) + if (!capabilities.negotiated || !capabilities.transferRetry) return + if (!subscribedDevices.containsKey(device.address)) return val transferId = request.optString("transferId") - recentAlbumArtBinaryTransfers[transferId]?.let { albumTransfer -> + albumArtTransferCoordinator.retained(device.address, transferId)?.let { albumTransfer -> retryAlbumArtTransfer(device, request, albumTransfer) return } - val transfer = recentFullLyricsBinaryTransfer + val transfer = lyricsTransferCoordinator.retained(device.address, transferId) if (transfer == null || - transfer.transferId != transferId || transfer.trackId != request.optString("trackId") || transfer.expiresAtMs < SystemClock.elapsedRealtime() || - currentAlbumArtId != transfer.trackId + !isLyricsTransferCurrent(transfer.trackId, transfer.generation) ) { sendFullLyricsBinaryError(device, transferId, "transfer expired") return @@ -4661,6 +5577,7 @@ class BleGattServerManager( } private fun rememberAlbumArtBinaryTransfer( + device: BluetoothDevice, protocolId: String, quality: AlbumArtQuality, packets: List @@ -4671,17 +5588,16 @@ class BleGattServerManager( JSONObject(start.value.toString(Charsets.UTF_8)).optString("transferId") }.getOrDefault("") if (transferId.isBlank()) return - val now = SystemClock.elapsedRealtime() - recentAlbumArtBinaryTransfers.entries.removeAll { it.value.expiresAtMs < now } - recentAlbumArtBinaryTransfers[transferId] = AlbumArtBinaryTransfer( + albumArtTransferCoordinator.retain(AlbumArtBinaryTransfer( trackId = protocolId, quality = quality, transferId = transferId, start = start, chunks = packets.filter { it.type == "albumArtBinaryChunk" }, end = end, - expiresAtMs = now + ALBUM_ART_RETRY_TTL_MS - ) + expiresAtMs = SystemClock.elapsedRealtime() + ALBUM_ART_RETRY_TTL_MS, + ownerAddress = device.address + )) } private fun retryAlbumArtTransfer( @@ -4790,9 +5706,9 @@ class BleGattServerManager( ) } - private fun sendLyricSecondary(request: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: run { - logger("[LyricSecondary] send skipped: no iPhone subscriber") + private fun sendLyricSecondary(device: BluetoothDevice, request: JSONObject) { + if (!subscribedDevices.containsKey(device.address)) { + logger("[LyricSecondary] send skipped: controller unsubscribed") return } val mode = request.optString("mode") @@ -5070,6 +5986,17 @@ class BleGattServerManager( normalized.contains("no safe qrc candidate") } + private fun lyricLoadReasonCode(reason: String): String { + val value = reason.lowercase() + return when { + "ambiguous" in value || "no safe" in value -> "qrc_ambiguous" + "parse" in value || "decrypt" in value -> "qrc_parse_failed" + "loading" in value || "waiting" in value || "cooldown" in value -> "qrc_pending" + "transfer" in value -> "transfer_failed" + else -> "qrc_not_found" + } + } + private fun lyricsReadyGateAllowsFullLyrics( gate: LyricsReadyGateSnapshot, requestedTrackId: String, @@ -5100,6 +6027,7 @@ class BleGattServerManager( } private fun rememberPendingFullLyricsRequest( + device: BluetoothDevice, request: JSONObject, requestedTrackId: String, protocolTrackId: String, @@ -5110,7 +6038,8 @@ class BleGattServerManager( if (request.optBoolean("_lyricsReadyFlush", false)) { return } - pendingFullLyricsRequest = PendingFullLyricsRequest( + pendingFullLyricsRequests[device.address] = PendingFullLyricsRequest( + ownerAddress = device.address, request = JSONObject(request.toString()) .put("_lyricsReadyPending", true), requestedTrackId = requestedTrackId, @@ -5137,11 +6066,12 @@ class BleGattServerManager( } private fun clearMatchingPendingFullLyrics( + ownerAddress: String, requestedTrackId: String, protocolTrackId: String, generation: Long ) { - val pending = pendingFullLyricsRequest ?: return + val pending = pendingFullLyricsRequests[ownerAddress] ?: return val trackMatches = isSameProtocolTrackId(pending.protocolTrackId, protocolTrackId) || (requestedTrackId.isNotBlank() && isSameProtocolTrackId(pending.requestedTrackId, requestedTrackId)) @@ -5149,30 +6079,35 @@ class BleGattServerManager( generation <= 0L || pending.generation == generation if (trackMatches && generationMatches) { - pendingFullLyricsRequest = null + pendingFullLyricsRequests.remove(ownerAddress, pending) } } private fun dispatchPendingFullLyricsRetry( + device: BluetoothDevice, pendingToken: PendingFullLyricsRequest?, retryRequest: JSONObject, reason: String ) { - if (pendingToken == null || pendingFullLyricsRequest !== pendingToken) { + if (pendingToken == null || pendingFullLyricsRequests[device.address] !== pendingToken) { logger("[FullLyrics] pending retry skipped reason=already_flushed source=$reason") return } lyricCommandExecutor.execute { - if (!started || pendingFullLyricsRequest !== pendingToken) { + if (!started || + pendingFullLyricsRequests[device.address] !== pendingToken || + !subscribedDevices.containsKey(device.address) + ) { logger("[FullLyrics] pending retry skipped reason=stale source=$reason") return@execute } logger("[FullLyrics] pending retry source=$reason") - sendFullLyrics(retryRequest) + sendFullLyrics(device, retryRequest) } } private fun handleLyricsReady(snapshot: LyricsReadyGateSnapshot) { + schedulePlaybackUiRefresh("lyrics_ready") flushPendingLyricWindow(snapshot) if (!snapshot.lyricsReady) { return @@ -5188,84 +6123,94 @@ class BleGattServerManager( // runtime cache instead of waiting for a later playback poll. scheduleCurrentWordPush(0L) } - val pending = pendingFullLyricsRequest ?: return - val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || - isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) - val generationMatches = pending.generation <= 0L || - snapshot.generation <= 0L || - pending.generation == snapshot.generation - if (!trackMatches || !generationMatches) { - pendingFullLyricsRequest = null - logger( - "[LyricsState] pending request dropped reason=stale " + - "readyTrackId=${snapshot.trackId} pendingTrackId=${pending.protocolTrackId} " + - "readyGeneration=${snapshot.generation} pendingGeneration=${pending.generation}" - ) - return - } - pendingFullLyricsRequest = null - lyricCommandExecutor.execute { - if (!started) { - return@execute + pendingFullLyricsRequests.entries.toList().forEach { (ownerAddress, pending) -> + val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || + isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) + val generationMatches = pending.generation <= 0L || + snapshot.generation <= 0L || + pending.generation == snapshot.generation + if (!trackMatches || !generationMatches) { + pendingFullLyricsRequests.remove(ownerAddress, pending) + logger( + "[LyricsState] pending request dropped reason=stale " + + "device=$ownerAddress readyTrackId=${snapshot.trackId} " + + "pendingTrackId=${pending.protocolTrackId}" + ) + return@forEach } - logger( - "[LyricsState] pending request flushed trackId=${pending.protocolTrackId} " + - "lines=${snapshot.lineCount} waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" - ) - logger( - "[Lyrics] pending flushed trackId=${pending.protocolTrackId} " + - "generation=${snapshot.generation}" - ) - lyricTrace( - stage = "pendingFlush", - trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, - songKey = snapshot.songKey, - generation = snapshot.generation, - extra = mapOf( - "protocolTrackId" to pending.protocolTrackId, - "waitMs" to (SystemClock.elapsedRealtime() - pending.createdAtMs).toString() + val device = subscribedDevices[ownerAddress] ?: run { + pendingFullLyricsRequests.remove(ownerAddress, pending) + return@forEach + } + pendingFullLyricsRequests.remove(ownerAddress, pending) + lyricCommandExecutor.execute { + if (!started || !subscribedDevices.containsKey(ownerAddress)) { + return@execute + } + logger( + "[LyricsState] pending request flushed trackId=${pending.protocolTrackId} " + + "device=$ownerAddress lines=${snapshot.lineCount} " + + "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" ) - ) - sendFullLyrics( - JSONObject(pending.request.toString()) - .put("_lyricsReadyFlush", true) - ) + lyricTrace( + stage = "pendingFlush", + trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, + songKey = snapshot.songKey, + generation = snapshot.generation, + extra = mapOf( + "protocolTrackId" to pending.protocolTrackId, + "waitMs" to (SystemClock.elapsedRealtime() - pending.createdAtMs).toString() + ) + ) + sendFullLyrics( + device, + JSONObject(pending.request.toString()).put("_lyricsReadyFlush", true) + ) + } } } private fun flushPendingLyricWindow(snapshot: LyricsReadyGateSnapshot) { - val pending = pendingLyricWindowRequest ?: return - val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || - isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) - val generationMatches = pending.generation <= 0L || - snapshot.generation <= 0L || - pending.generation == snapshot.generation - if (!trackMatches || !generationMatches) { - pendingLyricWindowRequest = null - logger( - "[LyricWindow] pending dropped reason=stale " + - "readyTrackId=${snapshot.trackId} pendingTrackId=${pending.protocolTrackId}" - ) - return - } - if (!snapshot.lyricsReady) { - if (snapshot.state.name == "FAILED") { - pendingLyricWindowRequest = null - subscribedDevices.values.firstOrNull()?.let { device -> - sendLyricWindowUnavailable(device, pending.protocolTrackId, snapshot.reason) + pendingLyricWindowRequests.entries.toList().forEach { (ownerAddress, pending) -> + val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || + isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) + val generationMatches = pending.generation <= 0L || + snapshot.generation <= 0L || + pending.generation == snapshot.generation + if (!trackMatches || !generationMatches) { + pendingLyricWindowRequests.remove(ownerAddress, pending) + logger( + "[LyricWindow] pending dropped reason=stale " + + "device=$ownerAddress readyTrackId=${snapshot.trackId} " + + "pendingTrackId=${pending.protocolTrackId}" + ) + return@forEach + } + val device = subscribedDevices[ownerAddress] ?: run { + pendingLyricWindowRequests.remove(ownerAddress, pending) + return@forEach + } + if (!snapshot.lyricsReady) { + if (snapshot.state.name == "FAILED") { + pendingLyricWindowRequests.remove(ownerAddress, pending) + sendLyricWindowUnavailable( + device, + pending.protocolTrackId, + snapshot.reason + ) } + return@forEach + } + pendingLyricWindowRequests.remove(ownerAddress, pending) + lyricCommandExecutor.execute { + if (!started || !subscribedDevices.containsKey(ownerAddress)) return@execute + logger( + "[LyricWindow] pending flushed trackId=${pending.protocolTrackId} " + + "device=$ownerAddress lines=${snapshot.lineCount} " + + "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" + ) + sendLyricWindow(device, JSONObject(pending.request.toString())) } - return - } - pendingLyricWindowRequest = null - lyricCommandExecutor.execute { - if (!started) return@execute - logger( - "[LyricWindow] pending flushed trackId=${pending.protocolTrackId} " + - "lines=${snapshot.lineCount} " + - "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" - ) - sendLyricWindow(JSONObject(pending.request.toString())) } } @@ -5301,11 +6246,13 @@ class BleGattServerManager( private fun isLyricsTransferCurrent(trackId: String, generation: Long): Boolean { val track = playbackStateReader.runtimeCacheSnapshot().track ?: return false - val trackMatches = isSameProtocolTrackId(track.trackId, trackId) - val generationMatches = generation <= 0L || - track.currentTrackGeneration <= 0L || - track.currentTrackGeneration == generation - return trackMatches && generationMatches + return BleTransferCodec.isCurrentTransfer( + transferTrackId = trackId, + transferGeneration = generation, + currentTrackId = track.trackId, + currentGeneration = track.currentTrackGeneration, + trackIdsMatch = ::isSameProtocolTrackId + ) } private fun buildFittingFullLyricsChunk( @@ -5431,9 +6378,16 @@ class BleGattServerManager( text: String, maximumPayload: Int ): List { - val codePoints = text.codePoints() - .toArray() - .map { codePoint -> String(Character.toChars(codePoint)) } + // String.codePoints() is only available from API 24. Build Unicode scalar + // strings explicitly so secondary lyrics remain surrogate-safe on minSdk 23. + val codePoints = buildList { + var offset = 0 + while (offset < text.length) { + val codePoint = Character.codePointAt(text, offset) + add(String(Character.toChars(codePoint))) + offset += Character.charCount(codePoint) + } + } val parts = mutableListOf() var current = "" codePoints.forEach { unit -> @@ -5553,10 +6507,16 @@ class BleGattServerManager( private const val DEFAULT_MTU = 23 private const val ATT_HEADER_SIZE = 3 private const val AUTO_PUSH_INTERVAL_MS = 1000L + private const val AUTO_PUSH_PAUSED_INTERVAL_MS = 5000L private const val CURRENT_WORD_INITIAL_DELAY_MS = 100L private const val CURRENT_WORD_TRACK_SWITCH_DELAY_MS = 450L private const val CURRENT_WORD_DRIFT_CORRECTION_MS = 500L private const val SERVER_PROTOCOL_VERSION = 2 + private val MULTI_CONTROLLER_DEDUP_COMMANDS = setOf( + "PLAY_PAUSE", + "NEXT", + "PREVIOUS" + ) private const val CLIENT_CAPABILITY_WAIT_MS = 250L private const val RECONNECT_SYNC_COOLDOWN_MS = 1_000L private const val RECONNECT_SYNC_CURRENT_WORD_DELAY_MS = 350L @@ -5570,9 +6530,9 @@ class BleGattServerManager( private const val ALBUM_ART_HQ_MAX_SEND_MS = 8000L private const val ALBUM_ART_FULL_MAX_SEND_MS = 3500L private const val ALBUM_ART_RETRY_TTL_MS = 10_000L - private const val ALBUM_ART_COOLDOWN_AFTER_TRACK_CHANGE_MS = 1500L private val ALBUM_ART_SOURCE_RETRY_DELAYS_MS = longArrayOf(250L, 800L, 2_000L) private const val ALBUM_ART_CACHE_CAPACITY = 20 + private const val ALBUM_ART_CACHE_MAX_BYTES = 24L * 1024L * 1024L private const val ALBUM_ART_CACHE_TTL_MS = 30 * 60 * 1_000L private const val ENCODED_ART_CACHE_CAPACITY = 40 private const val ENCODED_ART_CACHE_MAX_BYTES = 16 * 1024 * 1024 @@ -5597,6 +6557,14 @@ class BleGattServerManager( private const val MAX_MEDIA_FIELD_DUMP_CHUNK_BYTES = 300 private const val MAX_MEDIA_FIELD_DUMP_ERROR_CHARS = 80 private const val MEDIA_FIELD_DUMP_DELAY_MS = 25L + + internal fun autoPushPollIntervalMs(isPlaying: Boolean?): Long { + return if (isPlaying == false) { + AUTO_PUSH_PAUSED_INTERVAL_MS + } else { + AUTO_PUSH_INTERVAL_MS + } + } private const val FULL_LYRICS_NOTIFICATION_DELAY_MS = 20L private const val FULL_LYRICS_JSON_NOTIFICATION_DELAY_MS = 5L private const val FULL_LYRICS_BINARY_NOTIFICATION_DELAY_MS = 2L @@ -5616,7 +6584,7 @@ class BleGattServerManager( private const val FULL_LYRICS_JOB_TYPE = "fullLyrics" private const val LYRIC_WINDOW_JOB_TYPE = "lyricWindow" private const val FULL_LYRICS_PENDING_RETRY_DELAY_MS = 900L - private const val FULL_LYRICS_RECOVERY_RETRY_DELAY_MS = 2_500L + private const val FULL_LYRICS_RECOVERY_RETRY_DELAY_MS = 1_200L private const val LYRIC_SECONDARY_JOB_TYPE = "lyricSecondary" private const val MAX_LYRIC_TEXT_LENGTH = 30 private const val REMOTE_LOG_JOB_TYPE = "remoteLog" @@ -5677,6 +6645,7 @@ class BleGattServerManager( ) private data class PendingFullLyricsRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, @@ -5685,6 +6654,7 @@ class BleGattServerManager( ) private data class PendingLyricWindowRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, @@ -5692,26 +6662,6 @@ class BleGattServerManager( val createdAtMs: Long ) - private data class FullLyricsBinaryTransfer( - val trackId: String, - val transferId: String, - val generation: Long, - val start: BleNotifyQueue.Packet, - val chunks: List, - val end: BleNotifyQueue.Packet, - val expiresAtMs: Long - ) - - private data class AlbumArtBinaryTransfer( - val trackId: String, - val quality: AlbumArtQuality, - val transferId: String, - val start: BleNotifyQueue.Packet, - val chunks: List, - val end: BleNotifyQueue.Packet, - val expiresAtMs: Long - ) - private data class AlbumArtCacheEntry( val protocolId: String, val cacheKey: String, @@ -5723,20 +6673,6 @@ class BleGattServerManager( val createdAtElapsedMs: Long ) - private enum class AlbumArtQuality(val wireValue: String) { - PREVIEW("preview"), - HQ("hq"), - FULL("full"); - - companion object { - fun fromWireValue(value: String): AlbumArtQuality? { - return entries.firstOrNull { - it.wireValue.equals(value, ignoreCase = true) - } - } - } - } - enum class ServerState { STOPPED, STARTING, diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt new file mode 100644 index 0000000..3211fb4 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt @@ -0,0 +1,155 @@ +package com.example.playeragent.ble + +/** + * Per-connection pacing state. The profile is owned by BleNotifyQueue's serial + * thread and is reset whenever a device reconnects or negotiates a new MTU. + */ +internal class BleLinkProfile(initialMtu: Int) { + enum class PayloadKind { + JSON_LYRIC, + BINARY_LYRIC, + BINARY_ARTWORK, + OTHER + } + + var mtu: Int = initialMtu + private set + var jsonDelayMs: Long = JSON_INITIAL_DELAY_MS + private set + var binaryDelayMs: Long = BINARY_INITIAL_DELAY_MS + private set + var artworkDelayMs: Long = ARTWORK_INITIAL_DELAY_MS + private set + var ewmaCallbackRttMs: Double = 0.0 + private set + var successCount: Long = 0 + private set + var failureCount: Long = 0 + private set + + private var jsonSuccessWindow = 0 + private var binarySuccessWindow = 0 + private var artworkSuccessWindow = 0 + + fun reset(newMtu: Int = mtu) { + mtu = newMtu + jsonDelayMs = JSON_INITIAL_DELAY_MS + binaryDelayMs = BINARY_INITIAL_DELAY_MS + artworkDelayMs = ARTWORK_INITIAL_DELAY_MS + ewmaCallbackRttMs = 0.0 + successCount = 0 + failureCount = 0 + jsonSuccessWindow = 0 + binarySuccessWindow = 0 + artworkSuccessWindow = 0 + } + + fun updateMtu(newMtu: Int) { + if (newMtu > 0 && newMtu != mtu) { + reset(newMtu) + } + } + + fun delayFor(kind: PayloadKind, fallbackMs: Long): Long { + return when (kind) { + PayloadKind.JSON_LYRIC -> jsonDelayMs + PayloadKind.BINARY_LYRIC -> binaryDelayMs + PayloadKind.BINARY_ARTWORK -> artworkDelayMs + PayloadKind.OTHER -> fallbackMs + } + } + + fun recordSuccess(kind: PayloadKind, callbackRttMs: Long) { + successCount += 1 + val rtt = callbackRttMs.coerceAtLeast(0L).toDouble() + ewmaCallbackRttMs = if (ewmaCallbackRttMs <= 0.0) { + rtt + } else { + EWMA_ALPHA * rtt + (1.0 - EWMA_ALPHA) * ewmaCallbackRttMs + } + + if (callbackRttMs > CONGESTED_CALLBACK_RTT_MS) { + increaseDelay(kind) + resetSuccessWindow(kind) + return + } + + when (kind) { + PayloadKind.JSON_LYRIC -> { + jsonSuccessWindow += 1 + if (jsonSuccessWindow >= SUCCESS_WINDOW && + ewmaCallbackRttMs < FAST_CALLBACK_RTT_MS + ) { + jsonDelayMs = (jsonDelayMs - 1L).coerceAtLeast(JSON_MIN_DELAY_MS) + jsonSuccessWindow = 0 + } + } + PayloadKind.BINARY_LYRIC -> { + binarySuccessWindow += 1 + if (binarySuccessWindow >= SUCCESS_WINDOW && + ewmaCallbackRttMs < FAST_CALLBACK_RTT_MS + ) { + binaryDelayMs = (binaryDelayMs - 1L) + .coerceAtLeast(BINARY_MIN_DELAY_MS) + binarySuccessWindow = 0 + } + } + PayloadKind.BINARY_ARTWORK -> { + artworkSuccessWindow += 1 + if (artworkSuccessWindow >= SUCCESS_WINDOW && + ewmaCallbackRttMs < FAST_CALLBACK_RTT_MS + ) { + artworkDelayMs = (artworkDelayMs - 1L) + .coerceAtLeast(ARTWORK_MIN_DELAY_MS) + artworkSuccessWindow = 0 + } + } + PayloadKind.OTHER -> Unit + } + } + + fun recordFailure(kind: PayloadKind) { + failureCount += 1 + increaseDelay(kind) + resetSuccessWindow(kind) + } + + private fun increaseDelay(kind: PayloadKind) { + when (kind) { + PayloadKind.JSON_LYRIC -> { + jsonDelayMs = (jsonDelayMs + FAILURE_STEP_MS).coerceAtMost(MAX_DELAY_MS) + } + PayloadKind.BINARY_LYRIC -> { + binaryDelayMs = (binaryDelayMs + FAILURE_STEP_MS).coerceAtMost(MAX_DELAY_MS) + } + PayloadKind.BINARY_ARTWORK -> { + artworkDelayMs = (artworkDelayMs + FAILURE_STEP_MS).coerceAtMost(MAX_DELAY_MS) + } + PayloadKind.OTHER -> Unit + } + } + + private fun resetSuccessWindow(kind: PayloadKind) { + when (kind) { + PayloadKind.JSON_LYRIC -> jsonSuccessWindow = 0 + PayloadKind.BINARY_LYRIC -> binarySuccessWindow = 0 + PayloadKind.BINARY_ARTWORK -> artworkSuccessWindow = 0 + PayloadKind.OTHER -> Unit + } + } + + companion object { + private const val JSON_INITIAL_DELAY_MS = 5L + private const val BINARY_INITIAL_DELAY_MS = 2L + private const val ARTWORK_INITIAL_DELAY_MS = 3L + private const val JSON_MIN_DELAY_MS = 2L + private const val BINARY_MIN_DELAY_MS = 1L + private const val ARTWORK_MIN_DELAY_MS = 1L + private const val FAILURE_STEP_MS = 5L + private const val MAX_DELAY_MS = 30L + private const val SUCCESS_WINDOW = 20 + private const val CONGESTED_CALLBACK_RTT_MS = 120L + private const val FAST_CALLBACK_RTT_MS = 60.0 + private const val EWMA_ALPHA = 0.2 + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt index aa8766b..add1177 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt @@ -7,12 +7,31 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattServer import android.os.Build import android.os.Handler +import android.os.HandlerThread import android.os.Looper import android.os.SystemClock import com.example.playeragent.logging.LogConfig import org.json.JSONObject import java.util.ArrayDeque import java.util.LinkedHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal class BleQueueTerminalCallbackGate { + private val dispatched = AtomicBoolean(false) + + fun dispatch( + post: ((() -> Unit) -> Unit), + callback: (() -> Unit)? + ): Boolean { + if (!dispatched.compareAndSet(false, true)) { + return false + } + callback?.let(post) + return true + } +} class BleNotifyQueue( private val serverProvider: () -> BluetoothGattServer?, @@ -20,12 +39,20 @@ class BleNotifyQueue( private val logger: (String) -> Unit, private val localOnlyLogger: (String) -> Unit, private val verboseLogger: (String) -> Unit, - private val onNotifySuccess: (type: String) -> Unit = {}, - private val onNotifyFailure: (type: String, status: Int, reason: String) -> Unit = { _, _, _ -> } + private val onNotifySuccess: (deviceAddress: String, type: String) -> Unit = { _, _ -> }, + private val onNotifyFailure: ( + deviceAddress: String, + type: String, + status: Int, + reason: String + ) -> Unit = { _, _, _, _ -> } ) { - private val handler = Handler(Looper.getMainLooper()) + private val handlerThread = HandlerThread("BleNotifyQueue").apply { start() } + private val handler = Handler(handlerThread.looper) private val jobs = ArrayDeque() + private val linkProfiles = LinkedHashMap() + private val lastServedDeviceByPriority = LinkedHashMap() private var activeJob: SendJob? = null private var activePacketIndex = 0 private var activeJobStartedAtMs = 0L @@ -42,6 +69,31 @@ class BleNotifyQueue( private var drainingCancelledCallback = false private var cancelledCallbackDrainRunnable: Runnable? = null private var commandResponseQuietUntilMs = 0L + private var activeNotifyStartedAtMs = 0L + private var activeNotifyDeviceAddress: String? = null + private var activeNotifyPacketType: String? = null + + fun resetLinkProfile(address: String, mtu: Int) { + runOnQueueThread { resetLinkProfileOnQueue(address, mtu) } + } + + @Synchronized + private fun resetLinkProfileOnQueue(address: String, mtu: Int) { + if (address.isBlank()) return + linkProfiles[address] = BleLinkProfile(mtu) + localOnlyLogger("[BleLink] reset device=$address mtu=$mtu") + } + + fun updateLinkMtu(address: String, mtu: Int) { + runOnQueueThread { updateLinkMtuOnQueue(address, mtu) } + } + + @Synchronized + private fun updateLinkMtuOnQueue(address: String, mtu: Int) { + if (address.isBlank() || mtu <= 0) return + linkProfiles.getOrPut(address) { BleLinkProfile(mtu) }.updateMtu(mtu) + localOnlyLogger("[BleLink] mtu device=$address mtu=$mtu pacing reset") + } fun enqueueShort( device: BluetoothDevice, @@ -70,8 +122,12 @@ class BleNotifyQueue( * Older Sony Bluetooth stacks can otherwise acknowledge sendResponse() locally while the * iOS client never receives its write callback during a dense album-art transfer. */ - @Synchronized fun onCommandResponseSent() { + runOnQueueThread { onCommandResponseSentOnQueue() } + } + + @Synchronized + private fun onCommandResponseSentOnQueue() { commandResponseQuietUntilMs = maxOf( commandResponseQuietUntilMs, SystemClock.elapsedRealtime() + COMMAND_RESPONSE_QUIET_MS @@ -109,14 +165,25 @@ class BleNotifyQueue( ) } - @Synchronized fun setLatestInterleavedShort( device: BluetoothDevice, type: String, value: ByteArray, delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS ) { - latestInterleavedPackets[type] = InterleavedPacket( + runOnQueueThread { + setLatestInterleavedShortOnQueue(device, type, value, delayAfterMs) + } + } + + @Synchronized + private fun setLatestInterleavedShortOnQueue( + device: BluetoothDevice, + type: String, + value: ByteArray, + delayAfterMs: Long + ) { + latestInterleavedPackets[interleavedPacketKey(device.address, type)] = InterleavedPacket( device = device, packet = Packet( type = type, @@ -131,22 +198,58 @@ class BleNotifyQueue( } @Synchronized - fun hasLongJobActiveOrQueued(): Boolean { - return activeJob?.isLongJob == true || jobs.any { it.isLongJob } + fun hasLongJobActiveOrQueued(deviceAddress: String? = null): Boolean { + return activeJob?.let { + it.isLongJob && (deviceAddress == null || it.device.address == deviceAddress) + } == true || jobs.any { + it.isLongJob && (deviceAddress == null || it.device.address == deviceAddress) + } } @Synchronized - fun hasJobTypeActiveOrQueued(type: String): Boolean { - return activeJob?.type == type || jobs.any { it.type == type } + fun hasJobTypeActiveOrQueued(type: String, deviceAddress: String? = null): Boolean { + return activeJob?.let { + it.type == type && (deviceAddress == null || it.device.address == deviceAddress) + } == true || jobs.any { + it.type == type && (deviceAddress == null || it.device.address == deviceAddress) + } + } + + fun cancelJobTypes( + types: Set, + reason: String, + deviceAddress: String? = null + ) { + runOnQueueThread { cancelJobTypesOnQueue(types, reason, deviceAddress) } } @Synchronized - fun cancelJobTypes(types: Set, reason: String) { - val removedJobs = jobs.filter { it.type in types } - jobs.removeAll { it.type in types } - removedJobs.forEach { failJob(it, reason) } + private fun cancelJobTypesOnQueue( + types: Set, + reason: String, + deviceAddress: String? + ) { + val matches: (SendJob) -> Boolean = { job -> + job.type in types && + (deviceAddress == null || job.device.address == deviceAddress) + } + val removedJobs = jobs.filter(matches) + jobs.removeAll(matches) + latestInterleavedPackets.entries.removeAll { (_, packet) -> + packet.packet.type in types && + (deviceAddress == null || packet.device.address == deviceAddress) + } val current = activeJob - if (current != null && current.type in types) { + val activeJobRemoved = current != null && matches(current) + if (activeJobRemoved) { + // Never clear the HandlerThread wholesale here: another device + // may already have queued an enqueue/callback action. The stale + // sendNext runnables are harmless and the active timeout is the + // only callback that belongs to this cancelled notification. + cancelNotifyTimeout() + } + removedJobs.forEach { failJob(it, reason) } + if (activeJobRemoved && current != null) { failJob(current, reason) activeJob = null activePacketIndex = 0 @@ -155,18 +258,21 @@ class BleNotifyQueue( interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS } - if (removedJobs.isNotEmpty() || current?.type in types) { - handler.removeCallbacksAndMessages(null) + if (removedJobs.isNotEmpty() || activeJobRemoved) { if (notificationInFlight) { beginCancelledCallbackDrain("cancelled active notify") } else { - sendNextPacket() + handler.post { sendNextPacket() } } } } + fun onNotificationSent(deviceAddress: String?, status: Int) { + runOnQueueThread { onNotificationSentOnQueue(deviceAddress, status) } + } + @Synchronized - fun onNotificationSent(status: Int) { + private fun onNotificationSentOnQueue(deviceAddress: String?, status: Int) { if (drainingCancelledCallback) { logger("[BleNotifyQueue] ignored callback status=$status while draining cancelled notify") finishCancelledCallbackDrain() @@ -176,18 +282,39 @@ class BleNotifyQueue( if (!notificationInFlight) { return } + val expectedAddress = activeNotifyDeviceAddress ?: job.device.address + if (!deviceAddress.isNullOrBlank() && deviceAddress != expectedAddress) { + logger( + "[BleNotifyQueue] ignored callback from other device " + + "expected=$expectedAddress actual=$deviceAddress status=$status" + ) + return + } cancelNotifyTimeout() notificationInFlight = false + val callbackAddress = deviceAddress + ?.takeIf { it.isNotBlank() } + ?: activeNotifyDeviceAddress + ?: job.device.address + val callbackType = activeNotifyPacketType + val callbackRttMs = if (activeNotifyStartedAtMs > 0L) { + SystemClock.elapsedRealtime() - activeNotifyStartedAtMs + } else { + 0L + } + clearActiveNotifyMetrics() if (interleavedPacketInFlight) { - val type = interleavedPacketType ?: job.type + val type = interleavedPacketType ?: callbackType ?: job.type interleavedPacketInFlight = false interleavedPacketType = null if (status == BluetoothGatt.GATT_SUCCESS) { - onNotifySuccess(type) + dispatchNotifySuccess(callbackAddress, type) + recordAdaptiveSuccess(type, callbackAddress, callbackRttMs) } else { + recordAdaptiveFailure(type, callbackAddress) logger("[BleNotifyQueue] interleaved notify failed type=$type status=$status") - onNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(callbackAddress, type, status, "callback_failed") } handler.postDelayed({ sendNextPacket() }, interleavedDelayAfterMs) interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -195,58 +322,77 @@ class BleNotifyQueue( } val packet = job.packets.getOrNull(activePacketIndex) if (status != BluetoothGatt.GATT_SUCCESS) { - val type = packet?.type ?: job.type - recordAdaptiveFailure(type) + val type = packet?.type ?: callbackType ?: job.type + recordAdaptiveFailure(type, callbackAddress) logger( "[BleNotifyQueue] notify failed " + "type=$type status=$status" ) - onNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(callbackAddress, type, status, "callback_failed") abortActiveJob(job, "notify callback failed type=$type status=$status") return } else { - onNotifySuccess(packet?.type ?: job.type) - packet?.let { recordAdaptiveSuccess(it.type) } + dispatchNotifySuccess(callbackAddress, packet?.type ?: job.type) + packet?.let { + recordAdaptiveSuccess(it.type, callbackAddress, callbackRttMs) + } } activePacketIndex += 1 job.packetsSinceYield += 1 - val delay = packet?.let { adaptiveDelayFor(it) } ?: SHORT_MESSAGE_DELAY_MS + val delay = packet?.let { + adaptiveDelayFor(it, callbackAddress) + } ?: SHORT_MESSAGE_DELAY_MS handler.postDelayed({ sendNextPacket() }, delay) } - @Synchronized fun removeDevice(address: String) { + runOnQueueThread { removeDeviceOnQueue(address) } + } + + @Synchronized + private fun removeDeviceOnQueue(address: String) { val removedJobs = jobs.filter { it.device.address == address } jobs.removeAll { it.device.address == address } latestInterleavedPackets.entries.removeAll { it.value.device.address == address } - val activeDeviceRemoved = activeJob?.device?.address == address - if (interleavedPacketInFlight) { + linkProfiles.remove(address) + lastServedDeviceByPriority.entries.removeAll { it.value == address } + val activeDeviceRemoved = activeJob?.device?.address == address || + activeNotifyDeviceAddress == address + if (interleavedPacketInFlight && activeNotifyDeviceAddress == address) { interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS } + if (activeDeviceRemoved) { + cancelNotifyTimeout() + } removedJobs.forEach { failJob(it, "device disconnected") } if (activeDeviceRemoved) { activeJob?.let { failJob(it, "device disconnected") } activeJob = null activePacketIndex = 0 } - handler.removeCallbacksAndMessages(null) if (notificationInFlight && activeDeviceRemoved) { beginCancelledCallbackDrain("device disconnected") } else { - sendNextPacket() + handler.post { sendNextPacket() } } } - @Synchronized fun clearAllForDisconnect(reason: String) { + runOnQueueThread { clearAllForDisconnectOnQueue(reason) } + } + + @Synchronized + private fun clearAllForDisconnectOnQueue(reason: String) { logger("[BleNotifyQueue] clear all reason=$reason") handler.removeCallbacksAndMessages(null) activeJob?.let { failJob(it, reason) } jobs.forEach { failJob(it, reason) } jobs.clear() latestInterleavedPackets.clear() + linkProfiles.clear() + lastServedDeviceByPriority.clear() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -257,18 +403,25 @@ class BleNotifyQueue( commandResponseQuietUntilMs = 0L activeRequestType = null activeRequestId += 1 + clearActiveNotifyMetrics() cancelNotifyTimeout() drainingCancelledCallback = false cancelledCallbackDrainRunnable = null } - @Synchronized fun clear() { + runOnQueueThread { clearOnQueue() } + } + + @Synchronized + private fun clearOnQueue() { handler.removeCallbacksAndMessages(null) activeJob?.let { failJob(it, "queue cleared") } jobs.forEach { failJob(it, "queue cleared") } jobs.clear() latestInterleavedPackets.clear() + linkProfiles.clear() + lastServedDeviceByPriority.clear() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -278,6 +431,7 @@ class BleNotifyQueue( commandResponseQuietUntilMs = 0L activeRequestType = null activeRequestId += 1 + clearActiveNotifyMetrics() cancelNotifyTimeout() drainingCancelledCallback = false cancelledCallbackDrainRunnable = null @@ -294,8 +448,19 @@ class BleNotifyQueue( ) } - @Synchronized private fun enqueueJob(job: SendJob) { + runOnQueueThread { enqueueJobOnQueue(job) } + } + + @Synchronized + private fun enqueueJobOnQueue(job: SendJob) { + if (!job.isLongJob && shouldCoalesceShortType(job.type)) { + jobs.removeAll { + !it.isLongJob && + it.type == job.type && + it.device.address == job.device.address + } + } jobs.addLast(job) sendNextPacket() } @@ -348,12 +513,11 @@ class BleNotifyQueue( job.startedAtMs = activeJobStartedAtMs } if (job.shouldCancel?.invoke() == true) { - markJobFailed(job, "cancelled") - job.onFailure?.invoke() + failJob(job, "cancelled") activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } val maxSendDurationMs = job.maxSendDurationMs @@ -365,12 +529,11 @@ class BleNotifyQueue( if (job.type == "albumArt") { logger("[AlbumArt] timeout stop id=${job.albumArtId}") } - markJobFailed(job, "timeout") - job.onFailure?.invoke() + failJob(job, "timeout") activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } val interleaveInterval = interleaveIntervalFor(job.type) @@ -389,6 +552,10 @@ class BleNotifyQueue( interleavedPacketInFlight = true interleavedPacketType = interleaved.packet.type notificationInFlight = true + markNotifyStarted( + interleaved.device.address, + interleaved.packet.type + ) logInterleavedEventThrottled( isSavedEvent = false, message = "[BleNotifyQueue] latest ${interleaved.packet.type} flushed during long job" @@ -400,7 +567,8 @@ class BleNotifyQueue( interleaved.packet.value ) if (!requested) { - onNotifyFailure( + dispatchNotifyFailure( + interleaved.device.address, interleaved.packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -408,6 +576,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null notificationInFlight = false + clearActiveNotifyMetrics() interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS handler.postDelayed( { sendNextPacket() }, @@ -418,7 +587,9 @@ class BleNotifyQueue( } return } else { - latestInterleavedPackets[interleaved.packet.type] = interleaved + latestInterleavedPackets[ + interleavedPacketKey(interleaved.device.address, interleaved.packet.type) + ] = interleaved } } } @@ -426,7 +597,7 @@ class BleNotifyQueue( if (activePacketIndex >= job.packets.size) { if (job.isLongJob) { if (job.failed) { - job.onFailure?.invoke() + dispatchJobFailure(job) } else { when (job.type) { "albumArt" -> logger( @@ -442,13 +613,18 @@ class BleNotifyQueue( logger("[TrackInfo] send end") else -> logger("[BleNotifyQueue] job end type=${job.type}") } - job.onComplete?.invoke() } } activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + if (job.isLongJob && !job.failed) { + dispatchJobComplete(job) + } + // Terminal callbacks can enter BleGattServerManager. Queue the + // next drain behind them so no callback runs while this monitor + // is held and callback ordering remains deterministic. + handler.post { sendNextPacket() } return } @@ -461,12 +637,13 @@ class BleNotifyQueue( activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } notificationInFlight = true activeRequestType = packet.type + markNotifyStarted(job.device.address, packet.type) val requested = notify(server, characteristic, job.device, packet.value) if (job.type == "albumArt") { logAlbumArtPacketProgress(job, packet) @@ -507,9 +684,11 @@ class BleNotifyQueue( if (!requested) { notificationInFlight = false activeRequestType = null - recordAdaptiveFailure(packet.type) + clearActiveNotifyMetrics() + recordAdaptiveFailure(packet.type, job.device.address) logger("[BleNotifyQueue] notify request rejected type=${packet.type}") - onNotifyFailure( + dispatchNotifyFailure( + job.device.address, packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -521,12 +700,12 @@ class BleNotifyQueue( } private fun abortActiveJob(job: SendJob, reason: String) { - markJobFailed(job, reason) - job.onFailure?.invoke() + failJob(job, reason) activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L activeRequestType = null + clearActiveNotifyMetrics() handler.postDelayed({ sendNextPacket() }, SHORT_MESSAGE_DELAY_MS) } @@ -540,10 +719,16 @@ class BleNotifyQueue( } val job = activeJob logger("[BleNotifyQueue] notify callback timeout type=$type job=${job?.type}") - onNotifyFailure(type, NOTIFY_CALLBACK_TIMEOUT_STATUS, "callback_timeout") + val address = activeNotifyDeviceAddress ?: job?.device?.address.orEmpty() + recordAdaptiveFailure(type, address) + dispatchNotifyFailure( + address, + type, + NOTIFY_CALLBACK_TIMEOUT_STATUS, + "callback_timeout" + ) if (job != null) { - markJobFailed(job, "notify callback timeout type=$type") - job.onFailure?.invoke() + failJob(job, "notify callback timeout type=$type") activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L @@ -552,6 +737,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null activeRequestType = null + clearActiveNotifyMetrics() beginCancelledCallbackDrain("notify callback timeout") } } @@ -570,6 +756,7 @@ class BleNotifyQueue( drainingCancelledCallback = true notificationInFlight = false activeRequestType = null + clearActiveNotifyMetrics() cancelledCallbackDrainRunnable?.let(handler::removeCallbacks) val drain = Runnable { synchronized(this) { @@ -598,11 +785,13 @@ class BleNotifyQueue( "volumeState", "currentWord" ) - val selectedKey = preferredTypes.firstOrNull { type -> - latestInterleavedPackets[type]?.device?.address == deviceAddress - } ?: latestInterleavedPackets.entries.firstOrNull { - it.value.device.address == deviceAddress - }?.key ?: return null + val selectedKey = preferredTypes + .map { type -> interleavedPacketKey(deviceAddress, type) } + .firstOrNull(latestInterleavedPackets::containsKey) + ?: latestInterleavedPackets.entries.firstOrNull { + it.value.device.address == deviceAddress + }?.key + ?: return null return latestInterleavedPackets.remove(selectedKey) } @@ -670,13 +859,51 @@ class BleNotifyQueue( private fun failJob(job: SendJob, reason: String) { markJobFailed(job, reason) - job.onFailure?.invoke() + dispatchJobFailure(job) + } + + /** + * Queue methods use the BleNotifyQueue monitor to serialize state reads + * from diagnostic and media threads. Never call a manager callback inline + * while that monitor is held: BleGattServerManager may hold its own monitor + * while querying this queue, which otherwise creates an AB/BA deadlock. + */ + private fun dispatchJobComplete(job: SendJob) { + job.terminalCallbackGate.dispatch( + post = { callback -> handler.post(callback) }, + callback = job.onComplete + ) + } + + private fun dispatchJobFailure(job: SendJob) { + job.terminalCallbackGate.dispatch( + post = { callback -> handler.post(callback) }, + callback = job.onFailure + ) + } + + private fun dispatchNotifySuccess(deviceAddress: String, type: String) { + handler.post { onNotifySuccess(deviceAddress, type) } + } + + private fun dispatchNotifyFailure( + deviceAddress: String, + type: String, + status: Int, + reason: String + ) { + handler.post { onNotifyFailure(deviceAddress, type, status, reason) } } private fun pollNextJob(): SendJob? { val priority = jobs.minOfOrNull { it.priority.rank } ?: return null - val selected = jobs.firstOrNull { it.priority.rank == priority } ?: return null + val priorityValue = Priority.entries.first { it.rank == priority } + val previousAddress = lastServedDeviceByPriority[priorityValue] + val selected = jobs.firstOrNull { + it.priority.rank == priority && it.device.address != previousAddress + } ?: jobs.firstOrNull { it.priority.rank == priority } ?: return null jobs.remove(selected) + lastServedDeviceByPriority[priorityValue] = selected.device.address return selected } @@ -685,9 +912,19 @@ class BleNotifyQueue( return false } val waitingPriority = jobs.minOfOrNull { it.priority.rank } ?: return false - if (waitingPriority >= job.priority.rank) { + if (waitingPriority > job.priority.rank) { return false } + if (waitingPriority == job.priority.rank) { + val anotherDeviceWaiting = jobs.any { + it.priority == job.priority && it.device.address != job.device.address + } + return shouldYieldToPeerAtSamePriority( + priority = job.priority, + packetsSinceYield = job.packetsSinceYield, + anotherDeviceWaiting = anotherDeviceWaiting + ) + } return shouldYieldForPriorities( active = job.priority, waiting = Priority.entries.first { it.rank == waitingPriority }, @@ -695,64 +932,84 @@ class BleNotifyQueue( ) } - private fun adaptiveDelayFor(packet: Packet): Long { - return when (packet.type) { - "fullLyricsBinaryChunk" -> binaryLyricDelayMs - "fullLyricsStart", "fullLyricsChunk", "fullLyricsEnd", - "fullLyricsBinaryStart", "fullLyricsBinaryEnd", - "lyricWindowStart", "lyricWindowChunk", "lyricWindowEnd" -> - jsonLyricDelayMs - else -> packet.delayAfterMs + private fun adaptiveDelayFor(packet: Packet, address: String): Long { + val profile = linkProfiles.getOrPut(address) { + BleLinkProfile(DEFAULT_LINK_MTU) } + return profile.delayFor(payloadKind(packet.type), packet.delayAfterMs) } - private fun recordAdaptiveSuccess(type: String) { - when { - type == "fullLyricsBinaryChunk" -> { - binaryLyricSuccesses += 1 - if (binaryLyricSuccesses >= ADAPTIVE_SUCCESS_WINDOW) { - binaryLyricDelayMs = (binaryLyricDelayMs - 1L) - .coerceAtLeast(BINARY_LYRIC_MIN_DELAY_MS) - binaryLyricSuccesses = 0 - } - } - type in JSON_LYRIC_PACKET_TYPES -> { - jsonLyricSuccesses += 1 - if (jsonLyricSuccesses >= ADAPTIVE_SUCCESS_WINDOW) { - jsonLyricDelayMs = (jsonLyricDelayMs - 1L) - .coerceAtLeast(JSON_LYRIC_MIN_DELAY_MS) - jsonLyricSuccesses = 0 - } - } + private fun recordAdaptiveSuccess( + type: String, + address: String, + callbackRttMs: Long + ) { + if (address.isBlank()) return + val profile = linkProfiles.getOrPut(address) { + BleLinkProfile(DEFAULT_LINK_MTU) + } + val beforeJson = profile.jsonDelayMs + val beforeBinary = profile.binaryDelayMs + val beforeArtwork = profile.artworkDelayMs + profile.recordSuccess(payloadKind(type), callbackRttMs) + if (beforeJson != profile.jsonDelayMs || + beforeBinary != profile.binaryDelayMs || + beforeArtwork != profile.artworkDelayMs || + callbackRttMs > CONGESTED_CALLBACK_LOG_RTT_MS + ) { + localOnlyLogger( + "[BleLink] success device=$address type=$type rttMs=$callbackRttMs " + + "ewmaMs=${profile.ewmaCallbackRttMs.toInt()} " + + "jsonDelayMs=${profile.jsonDelayMs} " + + "binaryDelayMs=${profile.binaryDelayMs} " + + "artworkDelayMs=${profile.artworkDelayMs}" + ) } } - private fun recordAdaptiveFailure(type: String) { - when { - type == "fullLyricsBinaryChunk" -> { - binaryLyricDelayMs = (binaryLyricDelayMs + ADAPTIVE_FAILURE_STEP_MS) - .coerceAtMost(ADAPTIVE_MAX_DELAY_MS) - binaryLyricSuccesses = 0 - } - type in JSON_LYRIC_PACKET_TYPES -> { - jsonLyricDelayMs = (jsonLyricDelayMs + ADAPTIVE_FAILURE_STEP_MS) - .coerceAtMost(ADAPTIVE_MAX_DELAY_MS) - jsonLyricSuccesses = 0 - } + private fun recordAdaptiveFailure(type: String, address: String) { + if (address.isBlank()) return + val profile = linkProfiles.getOrPut(address) { + BleLinkProfile(DEFAULT_LINK_MTU) } + profile.recordFailure(payloadKind(type)) + logger( + "[BleLink] backoff device=$address type=$type " + + "jsonDelayMs=${profile.jsonDelayMs} " + + "binaryDelayMs=${profile.binaryDelayMs} " + + "artworkDelayMs=${profile.artworkDelayMs} " + + "failures=${profile.failureCount}" + ) } - private fun interleaveIntervalFor(jobType: String): Int { - return when (jobType) { - "albumArt" -> ALBUM_ART_INTERLEAVE_INTERVAL - "fullLyrics" -> FULL_LYRICS_INTERLEAVE_INTERVAL - "lyricSecondary" -> LYRIC_SECONDARY_INTERLEAVE_INTERVAL - "remoteLog", "mediaFieldDump", "qrcDump", "playHistory", "playStats" -> - OTHER_LONG_JOB_INTERLEAVE_INTERVAL - else -> 0 + private fun payloadKind(type: String): BleLinkProfile.PayloadKind { + return when { + type == "fullLyricsBinaryChunk" -> + BleLinkProfile.PayloadKind.BINARY_LYRIC + type == "albumArtBinaryChunk" -> + BleLinkProfile.PayloadKind.BINARY_ARTWORK + type in JSON_LYRIC_PACKET_TYPES -> + BleLinkProfile.PayloadKind.JSON_LYRIC + else -> BleLinkProfile.PayloadKind.OTHER } } + private fun markNotifyStarted(address: String, type: String) { + activeNotifyStartedAtMs = SystemClock.elapsedRealtime() + activeNotifyDeviceAddress = address + activeNotifyPacketType = type + } + + private fun clearActiveNotifyMetrics() { + activeNotifyStartedAtMs = 0L + activeNotifyDeviceAddress = null + activeNotifyPacketType = null + } + + private fun interleaveIntervalFor(jobType: String): Int { + return realtimeInterleaveIntervalFor(jobType) + } + private fun logInterleavedEventThrottled( isSavedEvent: Boolean, message: String @@ -805,6 +1062,29 @@ class BleNotifyQueue( } } + fun shutdown(reason: String) { + if (Looper.myLooper() == handlerThread.looper) { + clearAllForDisconnectOnQueue(reason) + handlerThread.quitSafely() + return + } + val completed = CountDownLatch(1) + handler.post { + clearAllForDisconnectOnQueue(reason) + completed.countDown() + handlerThread.quitSafely() + } + completed.await(2, TimeUnit.SECONDS) + } + + private fun runOnQueueThread(action: () -> Unit) { + if (Looper.myLooper() == handlerThread.looper) { + action() + } else { + handler.post(action) + } + } + data class Packet( val type: String, val value: ByteArray, @@ -838,7 +1118,9 @@ class BleNotifyQueue( var failureLogged: Boolean = false, var nextPacketIndex: Int = 0, var startedAtMs: Long = 0L, - var packetsSinceYield: Int = 0 + var packetsSinceYield: Int = 0, + val terminalCallbackGate: BleQueueTerminalCallbackGate = + BleQueueTerminalCallbackGate() ) { val chunkCount: Int get() = packets.count { @@ -847,6 +1129,7 @@ class BleNotifyQueue( || it.type == "mediaFieldDumpChunk" || it.type == "trackInfoChunk" || it.type == "fullLyricsChunk" + || it.type == "fullLyricsBinaryChunk" || it.type == "historyPayloadChunk" } @@ -870,16 +1153,10 @@ class BleNotifyQueue( } companion object { - private var jsonLyricDelayMs = 5L - private var binaryLyricDelayMs = 2L - private var jsonLyricSuccesses = 0 - private var binaryLyricSuccesses = 0 + private const val DEFAULT_LINK_MTU = 23 private const val SHORT_MESSAGE_DELAY_MS = 20L private const val CHUNK_PROGRESS_INTERVAL = 20 - private const val ALBUM_ART_INTERLEAVE_INTERVAL = 1 - private const val FULL_LYRICS_INTERLEAVE_INTERVAL = 10 - private const val LYRIC_SECONDARY_INTERLEAVE_INTERVAL = 3 - private const val OTHER_LONG_JOB_INTERLEAVE_INTERVAL = 5 + private const val REALTIME_INTERLEAVE_INTERVAL = 1 private const val INTERLEAVED_LOG_THROTTLE_MS = 10_000L private const val NOTIFY_REQUEST_REJECTED_STATUS = -1 private const val NOTIFY_CALLBACK_TIMEOUT_STATUS = -2 @@ -888,11 +1165,7 @@ class BleNotifyQueue( private const val COMMAND_RESPONSE_QUIET_MS = 25L private const val BULK_YIELD_INTERVAL = 4 private const val BACKGROUND_YIELD_INTERVAL = 1 - private const val JSON_LYRIC_MIN_DELAY_MS = 2L - private const val BINARY_LYRIC_MIN_DELAY_MS = 1L - private const val ADAPTIVE_FAILURE_STEP_MS = 5L - private const val ADAPTIVE_MAX_DELAY_MS = 30L - private const val ADAPTIVE_SUCCESS_WINDOW = 20 + private const val CONGESTED_CALLBACK_LOG_RTT_MS = 120L private val JSON_LYRIC_PACKET_TYPES = setOf( "fullLyricsStart", "fullLyricsChunk", @@ -903,6 +1176,12 @@ class BleNotifyQueue( "lyricWindowChunk", "lyricWindowEnd" ) + private val COALESCIBLE_SHORT_TYPES = setOf( + "playbackState", + "currentWord", + "volumeState", + "albumArtOffer" + ) fun priorityFor(type: String): Priority { return when (type) { @@ -936,6 +1215,37 @@ class BleNotifyQueue( internal fun remainingQuietDelayMs(quietUntilMs: Long, nowMs: Long): Long { return (quietUntilMs - nowMs).coerceAtLeast(0L) } + + internal fun realtimeInterleaveIntervalFor(jobType: String): Int { + return when (jobType) { + "albumArt", "fullLyrics", "lyricSecondary", + "remoteLog", "mediaFieldDump", "qrcDump", "playHistory", "playStats" -> + REALTIME_INTERLEAVE_INTERVAL + else -> 0 + } + } + + internal fun shouldCoalesceShortType(type: String): Boolean { + return type in COALESCIBLE_SHORT_TYPES + } + + internal fun interleavedPacketKey(deviceAddress: String, type: String): String { + return "$deviceAddress|$type" + } + + internal fun shouldYieldToPeerAtSamePriority( + priority: Priority, + packetsSinceYield: Int, + anotherDeviceWaiting: Boolean + ): Boolean { + if (!anotherDeviceWaiting) return false + return when (priority) { + Priority.P0_REALTIME -> false + Priority.P1_INTERACTIVE -> packetsSinceYield >= 1 + Priority.P2_BULK -> packetsSinceYield >= BULK_YIELD_INTERVAL + Priority.P3_BACKGROUND -> packetsSinceYield >= BACKGROUND_YIELD_INTERVAL + } + } } enum class Priority(val rank: Int) { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt new file mode 100644 index 0000000..fb62178 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt @@ -0,0 +1,75 @@ +package com.example.playeragent.ble + +/** + * Detects a subscribed GATT link that has stopped producing any observable + * activity. Silence is only a reason to probe the link, never proof that the + * link is dead: iOS can legitimately suspend all business traffic while the + * app is in the background. + */ +internal object BleSubscribedLinkPolicy { + fun isActivityStale( + subscribed: Boolean, + subscribedAtMs: Long, + lastCommandSuccessAtMs: Long, + lastNotifySuccessAtMs: Long, + nowMs: Long, + maxAgeMs: Long + ): Boolean { + if (!subscribed || maxAgeMs < 0L) { + return false + } + val lastActivityAtMs = maxOf( + subscribedAtMs, + lastCommandSuccessAtMs, + lastNotifySuccessAtMs + ) + if (lastActivityAtMs <= 0L) { + return false + } + val ageMs = nowMs - lastActivityAtMs + return ageMs >= 0L && ageMs > maxAgeMs + } + + fun isWithoutSuccessStale( + subscribed: Boolean, + subscribedAtMs: Long, + lastCommandSuccessAtMs: Long, + lastNotifySuccessAtMs: Long, + nowMs: Long, + maxAgeMs: Long + ): Boolean { + return subscribed && + lastCommandSuccessAtMs <= 0L && + lastNotifySuccessAtMs <= 0L && + subscribedAtMs > 0L && + nowMs - subscribedAtMs >= 0L && + nowMs - subscribedAtMs > maxAgeMs + } + + fun shouldSendProbe( + subscribedAtMs: Long, + lastCommandSuccessAtMs: Long, + lastNotifySuccessAtMs: Long, + lastProbeAtMs: Long, + nowMs: Long, + staleAfterMs: Long, + minimumProbeIntervalMs: Long + ): Boolean { + if (!isActivityStale( + subscribed = true, + subscribedAtMs = subscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtMs, + lastNotifySuccessAtMs = lastNotifySuccessAtMs, + nowMs = nowMs, + maxAgeMs = staleAfterMs + ) + ) { + return false + } + if (lastProbeAtMs <= 0L) { + return true + } + val sinceLastProbeMs = nowMs - lastProbeAtMs + return sinceLastProbeMs >= 0L && sinceLastProbeMs >= minimumProbeIntervalMs + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleTransferCodec.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleTransferCodec.kt index c678998..069087d 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleTransferCodec.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleTransferCodec.kt @@ -70,4 +70,26 @@ object BleTransferCodec { valid } } + + /** + * Validates a retained transfer against the authoritative media identity. + * + * Album-art state must never participate in this decision: a song can have + * valid lyrics while artwork is unavailable, delayed, or disabled. + */ + fun isCurrentTransfer( + transferTrackId: String, + transferGeneration: Long, + currentTrackId: String, + currentGeneration: Long, + trackIdsMatch: (String, String) -> Boolean = { left, right -> left == right } + ): Boolean { + if (transferTrackId.isBlank() || currentTrackId.isBlank()) { + return false + } + val generationMatches = transferGeneration <= 0L || + currentGeneration <= 0L || + transferGeneration == currentGeneration + return generationMatches && trackIdsMatch(transferTrackId, currentTrackId) + } } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleV3SessionCoordinator.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleV3SessionCoordinator.kt new file mode 100644 index 0000000..4a8bfef --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleV3SessionCoordinator.kt @@ -0,0 +1,125 @@ +package com.example.playeragent.ble + +import org.json.JSONObject +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +internal object BleV3Features { + const val STATUS_META_V1 = 1 shl 0 + const val STRUCTURED_ERROR_V1 = 1 shl 1 + const val MEDIA_LOAD_STATE_V1 = 1 shl 2 + const val ALL = STATUS_META_V1 or STRUCTURED_ERROR_V1 or MEDIA_LOAD_STATE_V1 + const val STATUS_META_MIN_NOTIFY_PAYLOAD = 247 +} + +internal object BleV3CapabilityPolicy { + fun negotiateF3(protocolVersion: Int, requestedF3: Int?, notifyPayload: Int): Int { + if (protocolVersion < 3 || requestedF3 == null) return 0 + var negotiated = requestedF3 and BleV3Features.ALL + if (notifyPayload < BleV3Features.STATUS_META_MIN_NOTIFY_PAYLOAD) { + negotiated = negotiated and BleV3Features.STATUS_META_V1.inv() + } + return negotiated + } + + fun f2( + albumArtBinary: Boolean, + fullLyricsZlib: Boolean, + lyricWindow: Boolean, + ping: Boolean, + clockSyncV1: Boolean, + transferRetry: Boolean + ): Int { + var value = 0 + if (albumArtBinary) value = value or (1 shl 0) + if (fullLyricsZlib) value = value or (1 shl 1) + if (lyricWindow) value = value or (1 shl 2) + if (ping) value = value or (1 shl 3) + if (clockSyncV1) value = value or (1 shl 4) + if (transferRetry) value = value or (1 shl 5) + return value + } +} + +internal object BleCapabilitiesAckPolicy { + fun build( + capabilities: ConnectionCommandCoordinator.Capabilities, + requestedF3Present: Boolean, + sessionId: String + ): JSONObject { + return if (capabilities.protocolVersion >= 3 && requestedF3Present) { + JSONObject() + .put("type", "clientCapabilitiesAck") + .put("protocolVersion", 3) + .put("f2", capabilities.f2) + .put("f3", capabilities.f3) + .put("sid", sessionId) + } else { + JSONObject() + .put("type", "clientCapabilitiesAck") + .put("protocolVersion", 2) + .put("albumArtBinary", true) + .put("fullLyricsZlib", true) + .put("lyricWindow", true) + .put("ping", true) + .put("clockSyncV1", true) + .put("transferRetry", true) + } + } +} + +internal object BleV3PayloadFactory { + fun commandError( + seq: String, + command: String, + domain: String, + code: String, + retryable: Boolean + ): JSONObject { + return JSONObject() + .put("type", "commandError") + .put("seq", seq) + .put("cmd", command) + .put("domain", domain) + .put("code", code) + .put("retryable", retryable) + } +} + +internal class BleV3SessionCoordinator( + val sessionId: String = UUID.randomUUID().toString().replace("-", "").take(8) +) { + private val enqueueSequence = ConcurrentHashMap() + private val mediaLoadStateKeys = ConcurrentHashMap() + + fun decorate(address: String, value: JSONObject): JSONObject { + val sequence = enqueueSequence.getOrPut(address) { AtomicLong(0L) }.incrementAndGet() + return JSONObject(value.toString()) + .put("sid", sessionId) + .put("es", sequence) + } + + fun shouldSendMediaLoadState( + address: String, + resource: String, + trackId: String, + generation: Long, + stage: String, + reason: String + ): Boolean { + val mapKey = "$address|$resource|$trackId" + val stateKey = "$generation|$stage|$reason" + return mediaLoadStateKeys.put(mapKey, stateKey) != stateKey + } + + fun resetAddress(address: String) { + enqueueSequence.remove(address) + mediaLoadStateKeys.keys.removeAll { it.startsWith("$address|") } + } + + fun clear() { + enqueueSequence.clear() + mediaLoadStateKeys.clear() + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt new file mode 100644 index 0000000..d938cdf --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt @@ -0,0 +1,63 @@ +package com.example.playeragent.ble + +import java.util.LinkedHashMap + +internal class CompressedLyricsCache( + private val maximumEntries: Int = 16, + private val maximumBytes: Int = 512 * 1024 +) { + data class Key( + val songKey: String, + val trackId: String, + val generation: Long, + val format: String, + val contentFingerprint: Long, + val wordLineIndexes: String + ) + + data class Entry( + val compressed: ByteArray, + val uncompressedSize: Int, + val crc32: String + ) { + val byteSize: Int + get() = compressed.size + } + + private val entries = LinkedHashMap(16, 0.75f, true) + private var totalBytes = 0 + + @Synchronized + fun get(key: Key): Entry? = entries[key] + + @Synchronized + fun put(key: Key, entry: Entry) { + if (entry.byteSize <= 0 || entry.byteSize > maximumBytes) return + entries.remove(key)?.let { totalBytes -= it.byteSize } + entries[key] = entry + totalBytes += entry.byteSize + trim() + } + + @Synchronized + fun clear() { + entries.clear() + totalBytes = 0 + } + + @Synchronized + fun size(): Int = entries.size + + @Synchronized + fun bytes(): Int = totalBytes + + private fun trim() { + val iterator = entries.entries.iterator() + while ((entries.size > maximumEntries || totalBytes > maximumBytes) && + iterator.hasNext() + ) { + totalBytes -= iterator.next().value.byteSize + iterator.remove() + } + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt index 05303e5..f757461 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt @@ -17,7 +17,12 @@ class CurrentWordPushEngine( private val logger: (String) -> Unit, private val sendStatusMessage: (String) -> Boolean, private val normalizeTrackId: (String) -> String = { it }, - private val expectedGeneration: () -> Long = { CurrentTrackRuntimeCache.currentGeneration() } + private val includeClockSyncFields: () -> Boolean = { false }, + private val expectedGeneration: () -> Long = { CurrentTrackRuntimeCache.currentGeneration() }, + private val currentWordState: () -> CurrentWordState? = { + CurrentTrackRuntimeCache.currentWordState() + }, + private val elapsedRealtime: () -> Long = { SystemClock.elapsedRealtime() } ) { private val lock = Any() private var lastPushedKey: String = "" @@ -30,13 +35,17 @@ class CurrentWordPushEngine( private var lastSkipLogAtMs: Long = 0L private var lastObservedGeneration: Long = -1L private var generationFirstSeenAtMs: Long = 0L + private var lastPushedPositionMs: Long = -1L + private var nextSequence: Long = 0L + private var lastLoggedLineIndex: Int = -1 + @Synchronized fun pushCurrentWord( reason: String = "diff", force: Boolean = false ): CurrentWordState? { - val startedAt = SystemClock.elapsedRealtime() - val state = CurrentTrackRuntimeCache.currentWordState() ?: run { + val startedAt = elapsedRealtime() + val state = currentWordState() ?: run { recordSkip("missing") return null } @@ -49,7 +58,7 @@ class CurrentWordPushEngine( protocolId = outgoingTrackId ) synchronized(lock) { - recordSkipLocked("stale_generation", SystemClock.elapsedRealtime(), state, key) + recordSkipLocked("stale_generation", elapsedRealtime(), state, key) } logger( "[CurrentWordFence] skip reason=stale_generation " + @@ -60,10 +69,14 @@ class CurrentWordPushEngine( } synchronized(lock) { - val now = SystemClock.elapsedRealtime() + val now = elapsedRealtime() if (state.trackGeneration != lastObservedGeneration) { lastObservedGeneration = state.trackGeneration generationFirstSeenAtMs = now + lastPushedKey = "" + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 } val generationAgeMs = now - generationFirstSeenAtMs if (!force && generationAgeMs < TRACK_SWITCH_BASELINE_HOLDOFF_MS) { @@ -86,6 +99,27 @@ class CurrentWordPushEngine( recordSkipLocked("same word", now, state, key) return null } + if (!force && lastPushedPositionMs >= 0L && state.positionMs < lastPushedPositionMs) { + val regressionMs = lastPushedPositionMs - state.positionMs + if (regressionMs <= MAX_JITTER_REGRESSION_MS) { + recordSkipLocked( + "position regression", + now, + state, + key, + extra = " regressionMs=$regressionMs" + ) + return null + } + // A larger backwards jump is a real seek. Start a new local + // timeline while preserving the connection generation. + lastPushedKey = "" + lastPushedPositionMs = -1L + logger( + "[CurrentWordFence] timeline discontinuity " + + "trackId=$outgoingTrackId regressionMs=$regressionMs" + ) + } if (!force && lastPushElapsedMs > 0L && now - lastPushElapsedMs < MIN_CURRENT_WORD_INTERVAL_MS @@ -95,28 +129,38 @@ class CurrentWordPushEngine( } } + val sequence = synchronized(lock) { + nextSequence += 1L + nextSequence + } val payload = JSONObject() .put("type", "currentWord") .put("trackId", outgoingTrackId) + .put("generation", state.trackGeneration) + .put("seq", sequence) .put("line", state.lineIndex) .put("word", state.wordIndex) .put("position", state.positionMs) .put("timestamp", state.timestampMs) .put("version", state.version) + if (includeClockSyncFields()) { + payload.put("sampleMono", state.sampleElapsedMs) + } val sent = sendStatusMessage(payload.toString()) synchronized(lock) { if (!sent) { - recordSkipLocked("send failed", SystemClock.elapsedRealtime(), state, key) + recordSkipLocked("send failed", elapsedRealtime(), state, key) return null } - val now = SystemClock.elapsedRealtime() + val now = elapsedRealtime() if (lastPushElapsedMs > 0L) { intervalTotalMs += (now - lastPushElapsedMs).coerceAtLeast(0L) intervalCount += 1 } lastPushElapsedMs = now lastPushedKey = key + lastPushedPositionMs = state.positionMs pushCount += 1 lastPushCostMs = now - startedAt val normalizedSuffix = if (outgoingTrackId != state.trackId) { @@ -124,15 +168,21 @@ class CurrentWordPushEngine( } else { " idMode=canonical" } - logger( - "[CurrentWordPush] push trackId=$outgoingTrackId$normalizedSuffix " + - "generation=${state.trackGeneration} " + - "line=${state.lineIndex} word=${state.wordIndex} " + - "wordText=${state.wordText.take(MAX_LOG_WORD_TEXT)} " + - "wordStartMs=${state.wordStartMs} wordEndMs=${state.wordEndMs} " + - "hasWordTiming=${state.hasWordTiming} positionMs=${state.positionMs} " + - "currentWordKey=$key reason=$reason force=$force costMs=$lastPushCostMs" - ) + if (force || + state.lineIndex != lastLoggedLineIndex || + pushCount % PUSH_LOG_SAMPLE_INTERVAL == 0L + ) { + lastLoggedLineIndex = state.lineIndex + logger( + "[CurrentWordPush] push trackId=$outgoingTrackId$normalizedSuffix " + + "generation=${state.trackGeneration} seq=$sequence " + + "line=${state.lineIndex} word=${state.wordIndex} " + + "wordText=${state.wordText.take(MAX_LOG_WORD_TEXT)} " + + "wordStartMs=${state.wordStartMs} wordEndMs=${state.wordEndMs} " + + "hasWordTiming=${state.hasWordTiming} positionMs=${state.positionMs} " + + "currentWordKey=$key reason=$reason force=$force costMs=$lastPushCostMs" + ) + } TrackCapabilityTracker.onCurrentWordPushed( trackId = state.trackId, protocolId = outgoingTrackId @@ -147,6 +197,16 @@ class CurrentWordPushEngine( lastPushElapsedMs = 0L lastObservedGeneration = -1L generationFirstSeenAtMs = 0L + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 + } + } + + fun resetTimeline() { + synchronized(lock) { + lastPushedKey = "" + lastPushedPositionMs = -1L } } @@ -176,7 +236,7 @@ class CurrentWordPushEngine( private fun recordSkip(reason: String) { synchronized(lock) { - recordSkipLocked(reason, SystemClock.elapsedRealtime()) + recordSkipLocked(reason, elapsedRealtime()) } } @@ -214,5 +274,7 @@ class CurrentWordPushEngine( private const val TRACK_SWITCH_BASELINE_HOLDOFF_MS = 450L private const val SKIP_LOG_INTERVAL_MS = 5_000L private const val MAX_LOG_WORD_TEXT = 24 + private const val MAX_JITTER_REGRESSION_MS = 1_500L + private const val PUSH_LOG_SAMPLE_INTERVAL = 10L } } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt new file mode 100644 index 0000000..17043c9 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt @@ -0,0 +1,238 @@ +package com.example.playeragent.ble + +import android.os.SystemClock +import java.util.concurrent.ConcurrentHashMap + +internal object MultiControllerPolicy { + const val MAX_CONTROLLERS = 2 + + fun hasConnectionCapacity(connectedCount: Int): Boolean { + return connectedCount.coerceAtLeast(0) < MAX_CONTROLLERS + } + + fun shouldIsolateOnlyFailingControllers( + failingCount: Int, + subscribedCount: Int + ): Boolean { + return failingCount > 0 && failingCount < subscribedCount + } +} + +/** + * Owns the retained full-lyrics transfer and its compressed payload cache. + * + * Keeping both pieces behind one coordinator prevents artwork lifecycle changes + * from invalidating a lyrics retry and gives disconnect/reset a single boundary. + */ +internal class LyricsTransferCoordinator( + val compressedCache: CompressedLyricsCache = CompressedLyricsCache() +) { + private val retainedTransfers = ConcurrentHashMap() + + fun retain(transfer: FullLyricsBinaryTransfer) { + retainedTransfers[transferKey(transfer.ownerAddress, transfer.transferId)] = transfer + } + + fun retained(ownerAddress: String, transferId: String): FullLyricsBinaryTransfer? { + return retainedTransfers[transferKey(ownerAddress, transferId)] + } + + fun reset() { + retainedTransfers.clear() + compressedCache.clear() + } + + fun clearRetryState() { + retainedTransfers.clear() + } + + fun resetAddress(ownerAddress: String) { + retainedTransfers.entries.removeAll { it.value.ownerAddress == ownerAddress } + } + + private fun transferKey(ownerAddress: String, transferId: String): String { + return "$ownerAddress|$transferId" + } +} + +/** + * Owns short-lived album-art retry records independently from lyrics state. + */ +internal class AlbumArtTransferCoordinator { + private val retainedTransfers = ConcurrentHashMap() + + fun retain(transfer: AlbumArtBinaryTransfer, nowMs: Long = SystemClock.elapsedRealtime()) { + retainedTransfers.entries.removeAll { it.value.expiresAtMs < nowMs } + retainedTransfers[transferKey(transfer.ownerAddress, transfer.transferId)] = transfer + } + + fun retained(ownerAddress: String, transferId: String): AlbumArtBinaryTransfer? { + return retainedTransfers[transferKey(ownerAddress, transferId)] + } + + fun resetAddress(ownerAddress: String) { + retainedTransfers.entries.removeAll { it.value.ownerAddress == ownerAddress } + } + + fun reset() { + retainedTransfers.clear() + } + + private fun transferKey(ownerAddress: String, transferId: String): String { + return "$ownerAddress|$transferId" + } +} + +/** + * Owns connection-scoped protocol negotiation. This state is deliberately reset + * for every subscription generation so an old central cannot leak capabilities + * into a new connection. + */ +internal class ConnectionCommandCoordinator { + data class Capabilities( + val protocolVersion: Int = 1, + val binaryAlbumArt: Boolean = false, + val fullLyricsZlib: Boolean = false, + val lyricWindow: Boolean = false, + val ping: Boolean = false, + val clockSyncV1: Boolean = false, + val transferRetry: Boolean = false, + val f2: Int = 0, + val f3: Int = 0, + val negotiated: Boolean = false + ) { + val statusMetaV1: Boolean get() = f3 and BleV3Features.STATUS_META_V1 != 0 + val structuredErrorV1: Boolean get() = f3 and BleV3Features.STRUCTURED_ERROR_V1 != 0 + val mediaLoadStateV1: Boolean get() = f3 and BleV3Features.MEDIA_LOAD_STATE_V1 != 0 + val v3: Boolean get() = protocolVersion >= 3 && f3 != 0 + } + + private data class ClientSession( + val capabilities: Capabilities = Capabilities(), + val negotiationGeneration: Long = 0L, + val subscribedAtMs: Long = 0L + ) + + private val sessions = ConcurrentHashMap() + + @Synchronized + fun beginNegotiation(address: String, nowMs: Long = SystemClock.elapsedRealtime()): Long { + val previous = sessions[address] + val session = ClientSession( + negotiationGeneration = (previous?.negotiationGeneration ?: 0L) + 1L, + subscribedAtMs = nowMs + ) + sessions[address] = session + return session.negotiationGeneration + } + + @Synchronized + fun accept(address: String, requested: Capabilities) { + val previous = sessions[address] ?: ClientSession() + sessions[address] = previous.copy( + capabilities = requested.copy(negotiated = true) + ) + } + + @Synchronized + fun useLegacyIfCurrent(address: String, generation: Long): Boolean { + val session = sessions[address] ?: return false + if (generation != session.negotiationGeneration || session.capabilities.negotiated) { + return false + } + sessions[address] = session.copy( + capabilities = session.capabilities.copy(negotiated = true) + ) + return true + } + + fun capabilities(address: String): Capabilities { + return sessions[address]?.capabilities ?: Capabilities() + } + + fun subscribedAtMs(address: String): Long { + return sessions[address]?.subscribedAtMs ?: 0L + } + + @Synchronized + fun invalidate(address: String) { + val session = sessions[address] ?: return + sessions[address] = session.copy( + capabilities = Capabilities(), + negotiationGeneration = session.negotiationGeneration + 1L + ) + } + + fun remove(address: String) { + sessions.remove(address) + } + + fun clear() { + sessions.clear() + } +} + +/** + * Prevents two controllers reacting to the same human action from toggling or + * skipping twice. Repeated commands from the same controller remain untouched. + */ +internal class MultiControllerCommandGate( + private val duplicateWindowMs: Long = 300L +) { + private data class AcceptedCommand(val ownerAddress: String, val acceptedAtMs: Long) + + private val acceptedByCommand = ConcurrentHashMap() + + @Synchronized + fun shouldExecute(command: String, ownerAddress: String, nowMs: Long): Boolean { + val previous = acceptedByCommand[command] + val duplicateFromAnotherController = previous != null && + previous.ownerAddress != ownerAddress && + nowMs - previous.acceptedAtMs in 0..duplicateWindowMs + if (duplicateFromAnotherController) { + return false + } + acceptedByCommand[command] = AcceptedCommand(ownerAddress, nowMs) + return true + } + + fun reset() { + acceptedByCommand.clear() + } +} + +internal data class FullLyricsBinaryTransfer( + val trackId: String, + val transferId: String, + val generation: Long, + val start: BleNotifyQueue.Packet, + val chunks: List, + val end: BleNotifyQueue.Packet, + val expiresAtMs: Long, + val ownerAddress: String = "" +) + +internal data class AlbumArtBinaryTransfer( + val trackId: String, + val quality: AlbumArtQuality, + val transferId: String, + val start: BleNotifyQueue.Packet, + val chunks: List, + val end: BleNotifyQueue.Packet, + val expiresAtMs: Long, + val ownerAddress: String = "" +) + +internal enum class AlbumArtQuality(val wireValue: String) { + PREVIEW("preview"), + HQ("hq"), + FULL("full"); + + companion object { + fun fromWireValue(value: String): AlbumArtQuality? { + return entries.firstOrNull { + it.wireValue.equals(value, ignoreCase = true) + } + } + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/PlaybackStateBuffer.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/PlaybackStateBuffer.kt index b0c03d9..527bde1 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/PlaybackStateBuffer.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/PlaybackStateBuffer.kt @@ -12,11 +12,13 @@ import java.util.concurrent.TimeUnit class PlaybackStateBuffer( private val logger: (String) -> Unit, private val flushDelayMs: Long = DEFAULT_FLUSH_DELAY_MS, + scheduledExecutor: ScheduledExecutorService? = null, private val flush: (JSONObject, PlaybackStateSnapshot, PlaybackStateDiff, String, Int) -> Boolean ) { private val lock = Any() + private val ownsExecutor = scheduledExecutor == null private val executor: ScheduledExecutorService = - Executors.newSingleThreadScheduledExecutor { runnable -> + scheduledExecutor ?: Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "PlaybackStateBufferThread") } @@ -92,7 +94,7 @@ class PlaybackStateBuffer( fun shutdown() { reset() - executor.shutdownNow() + if (ownsExecutor) executor.shutdownNow() } private fun isImmediate(diff: PlaybackStateDiff): Boolean { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryModels.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryModels.kt index b29adc1..f6931da 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryModels.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryModels.kt @@ -11,6 +11,25 @@ data class FastPlaybackSnapshot( val durationMs: Long ) +internal object PlaybackHistorySamplingPolicy { + const val PLAYING_INTERVAL_MS = 5_000L + const val PAUSED_INTERVAL_MS = 30_000L + const val IDLE_INTERVAL_MS = 10_000L + + fun intervalMs( + snapshot: FastPlaybackSnapshot?, + hasActiveSession: Boolean + ): Long { + return when { + snapshot == null || snapshot.title.isBlank() -> + if (hasActiveSession) PLAYING_INTERVAL_MS else IDLE_INTERVAL_MS + snapshot.playing -> PLAYING_INTERVAL_MS + snapshot.stopped -> IDLE_INTERVAL_MS + else -> PAUSED_INTERVAL_MS + } + } +} + enum class StatsRange { TODAY, WEEK, diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt index 9a21ed1..1994c8c 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt @@ -2,7 +2,9 @@ package com.example.playeragent.history import android.content.Context import android.os.SystemClock +import com.example.playeragent.media.PlayerAgentExecutionHub import com.example.playeragent.media.PlaybackStateReader +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture @@ -11,7 +13,8 @@ import java.util.concurrent.atomic.AtomicReference class PlaybackHistoryMonitor( context: Context, - private val logger: (String) -> Unit + private val logger: (String) -> Unit, + executionHub: PlayerAgentExecutionHub? = null ) { private val appContext = context.applicationContext private val repository = PlaybackHistoryRepository(appContext) @@ -20,12 +23,14 @@ class PlaybackHistoryMonitor( logger = logger, includeLyric = false ) - private val executor: ScheduledExecutorService = - Executors.newSingleThreadScheduledExecutor { runnable -> + private val ownsExecutor = executionHub == null + private val scheduler: ScheduledExecutorService = + executionHub?.scheduled ?: Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "PlaybackHistoryMonitorThread").apply { isDaemon = true } } + private val worker: ExecutorService = executionHub?.maintenance ?: scheduler @Volatile private var running = false @@ -34,6 +39,8 @@ class PlaybackHistoryMonitor( private var lastTickElapsedMs: Long = 0L private var lastTickPlaying = false private var missingSinceElapsedMs: Long? = null + private var nextTickDelayMs: Long = PlaybackHistorySamplingPolicy.IDLE_INTERVAL_MS + private var lastUpsertSignature: TrackUpsertSignature? = null fun start() { if (running) { @@ -41,12 +48,7 @@ class PlaybackHistoryMonitor( } running = true updateStatus() - scheduledFuture = executor.scheduleWithFixedDelay( - { safeTick() }, - 0L, - MONITOR_INTERVAL_MS, - TimeUnit.MILLISECONDS - ) + scheduleTick(0L) logger("[History] monitor started") } @@ -57,7 +59,7 @@ class PlaybackHistoryMonitor( running = false scheduledFuture?.cancel(false) scheduledFuture = null - executor.execute { + worker.execute { runCatching { endActiveSession(EndReason.SERVICE_STOPPED) updateStatus() @@ -65,16 +67,33 @@ class PlaybackHistoryMonitor( logger("[History] stop flush failed error=${it.message}") } } - executor.shutdown() + if (ownsExecutor) scheduler.shutdown() reader.close() logger("[History] monitor stopped") } + private fun scheduleTick(delayMs: Long) { + scheduledFuture = scheduler.schedule( + { + runCatching { worker.execute(::safeTick) } + .onFailure { logger("[History] schedule failed error=${it.message}") } + }, + delayMs, + TimeUnit.MILLISECONDS + ) + } + private fun safeTick() { - runCatching { - tick() - }.onFailure { - logger("[History] tick failed error=${it.message}") + try { + runCatching { + tick() + }.onFailure { + logger("[History] tick failed error=${it.message}") + } + } finally { + if (running) { + scheduleTick(nextTickDelayMs) + } } } @@ -85,6 +104,10 @@ class PlaybackHistoryMonitor( val nowWallMs = System.currentTimeMillis() val nowElapsedMs = SystemClock.elapsedRealtime() val snapshot = reader.readFastPlaybackSnapshot() + nextTickDelayMs = PlaybackHistorySamplingPolicy.intervalMs( + snapshot = snapshot, + hasActiveSession = activeSession != null + ) if (snapshot == null || snapshot.title.isBlank()) { handleMissingSnapshot(nowElapsedMs, nowWallMs) return @@ -98,7 +121,18 @@ class PlaybackHistoryMonitor( } else { proposedTrackKey } - repository.upsertTrackForSnapshot(trackKey, snapshot, nowWallMs) + val upsertSignature = TrackUpsertSignature( + trackKey = trackKey, + title = snapshot.title.trim(), + artist = snapshot.artist.trim(), + album = snapshot.album.trim(), + packageName = snapshot.packageName.trim(), + durationBucketMs = snapshot.durationMs.coerceAtLeast(0L) / 1_000L + ) + if (upsertSignature != lastUpsertSignature) { + repository.upsertTrackForSnapshot(trackKey, snapshot, nowWallMs) + lastUpsertSignature = upsertSignature + } if (snapshot.stopped) { endActiveSession(EndReason.STOPPED) @@ -371,6 +405,15 @@ class PlaybackHistoryMonitor( } } + private data class TrackUpsertSignature( + val trackKey: String, + val title: String, + val artist: String, + val album: String, + val packageName: String, + val durationBucketMs: Long + ) + private enum class EndReason { TRACK_CHANGED, MEDIA_SESSION_MISSING, @@ -380,7 +423,6 @@ class PlaybackHistoryMonitor( } companion object { - private const val MONITOR_INTERVAL_MS = 1_000L private const val MAX_LISTEN_DELTA_MS = 5_000L private const val ACTIVE_PERSIST_INTERVAL_MS = 5_000L private const val MEDIA_SESSION_MISSING_TIMEOUT_MS = 10_000L diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/logging/LogBuffer.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/logging/LogBuffer.kt index 6ea883e..0063a5a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/logging/LogBuffer.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/logging/LogBuffer.kt @@ -56,7 +56,7 @@ object LogBuffer { } private const val MAX_ENTRIES = 300 - private const val MAX_REQUEST_LIMIT = 100 + private const val MAX_REQUEST_LIMIT = 300 private const val MAX_MESSAGE_LENGTH = 300 private const val TRUNCATED_SUFFIX = "..." private const val TAG = "PlayerAgentLogBuffer" diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtPlaceholderPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtPlaceholderPolicy.kt new file mode 100644 index 0000000..78d3ffc --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtPlaceholderPolicy.kt @@ -0,0 +1,119 @@ +package com.example.playeragent.media + +import android.graphics.Bitmap + +/** + * Identifies QQ Music's temporary fallback artwork without rejecting ordinary + * monochrome album covers. On the Sony player the fallback is a 228 px square + * with two dominant gray tones; real notification artwork is normally 280 px. + * + * Keep the pixel-only overload free of Android behavior so the thresholds can + * be covered by local JVM unit tests. + */ +internal object AlbumArtPlaceholderPolicy { + fun isLikelyPlaceholder(bitmap: Bitmap): Boolean { + if (bitmap.width <= 0 || bitmap.height <= 0) { + return true + } + val sampleWidth = minOf(SAMPLE_SIZE, bitmap.width) + val sampleHeight = minOf(SAMPLE_SIZE, bitmap.height) + val sampled = if (sampleWidth == bitmap.width && + sampleHeight == bitmap.height + ) { + bitmap + } else { + Bitmap.createScaledBitmap(bitmap, sampleWidth, sampleHeight, true) + } + return try { + val pixels = IntArray(sampleWidth * sampleHeight) + sampled.getPixels( + pixels, + 0, + sampleWidth, + 0, + 0, + sampleWidth, + sampleHeight + ) + isLikelyPlaceholder( + sourceWidth = bitmap.width, + sourceHeight = bitmap.height, + pixels = pixels + ) + } finally { + if (sampled !== bitmap) { + sampled.recycle() + } + } + } + + fun isLikelyPlaceholder( + sourceWidth: Int, + sourceHeight: Int, + pixels: IntArray + ): Boolean { + if (sourceWidth <= 0 || sourceHeight <= 0 || pixels.isEmpty()) { + return true + } + if (sourceWidth <= MIN_VALID_ARTWORK_SIZE && + sourceHeight <= MIN_VALID_ARTWORK_SIZE + ) { + return true + } + + val colorBuckets = HashMap() + var colorfulPixels = 0 + var visiblePixels = 0 + pixels.forEach { pixel -> + val alpha = pixel ushr 24 + if (alpha < MIN_VISIBLE_ALPHA) { + return@forEach + } + val red = (pixel ushr 16) and 0xff + val green = (pixel ushr 8) and 0xff + val blue = pixel and 0xff + val maximum = maxOf(red, green, blue) + val minimum = minOf(red, green, blue) + if (maximum > 0 && + (maximum - minimum).toFloat() / maximum.toFloat() > + COLORFUL_SATURATION_THRESHOLD + ) { + colorfulPixels += 1 + } + val bucket = + ((red / COLOR_BUCKET_SIZE) shl 10) or + ((green / COLOR_BUCKET_SIZE) shl 5) or + (blue / COLOR_BUCKET_SIZE) + colorBuckets[bucket] = (colorBuckets[bucket] ?: 0) + 1 + visiblePixels += 1 + } + if (visiblePixels == 0) { + return true + } + + val nearlySquare = + kotlin.math.abs(sourceWidth - sourceHeight) <= + maxOf(sourceWidth, sourceHeight) / 20 + val knownFallbackSize = + sourceWidth in QQ_FALLBACK_MIN_SIZE..QQ_FALLBACK_MAX_SIZE && + sourceHeight in QQ_FALLBACK_MIN_SIZE..QQ_FALLBACK_MAX_SIZE + val dominantPixels = colorBuckets.values.maxOrNull() ?: 0 + val extremelyLowInformation = + colorBuckets.size <= QQ_FALLBACK_MAX_COLOR_BUCKETS && + colorfulPixels * 100 <= visiblePixels * QQ_FALLBACK_MAX_COLORFUL_PERCENT && + dominantPixels * 100 >= visiblePixels * QQ_FALLBACK_MIN_DOMINANT_PERCENT + + return nearlySquare && knownFallbackSize && extremelyLowInformation + } + + private const val SAMPLE_SIZE = 24 + private const val MIN_VALID_ARTWORK_SIZE = 16 + private const val MIN_VISIBLE_ALPHA = 24 + private const val COLOR_BUCKET_SIZE = 32 + private const val COLORFUL_SATURATION_THRESHOLD = 0.12f + private const val QQ_FALLBACK_MIN_SIZE = 200 + private const val QQ_FALLBACK_MAX_SIZE = 240 + private const val QQ_FALLBACK_MAX_COLOR_BUCKETS = 8 + private const val QQ_FALLBACK_MAX_COLORFUL_PERCENT = 1 + private const val QQ_FALLBACK_MIN_DOMINANT_PERCENT = 70 +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt index f864e4c..9282c1c 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt @@ -37,53 +37,79 @@ class AlbumArtTestManager( expectedTitle: String = "", expectedArtist: String = "" ): NotificationAlbumArt? { - val candidates = mutableListOf() - candidates += readMediaMetadataCandidates() - candidates += readNotificationCandidates(expectedTitle, expectedArtist) - logger("[AlbumArtSource] candidates count=${candidates.size}") - if (candidates.isEmpty()) { - logger("[AlbumArtDebug] unavailable reason=no album art candidate") - logger("[AlbumArt][BLE] unavailable") - return null + val selectedNotification = currentNotification(expectedTitle, expectedArtist) + val notificationFingerprint = selectedNotification?.let { + notificationFingerprint(it, expectedTitle) } + synchronized(sharedArtworkReadLock) { + val now = android.os.SystemClock.elapsedRealtime() + val cached = sharedArtworkReadCache + if (notificationFingerprint != null && + cached != null && + cached.notificationFingerprint == notificationFingerprint && + now - cached.createdAtElapsedMs <= SHARED_READ_CACHE_TTL_MS + ) { + logger("[AlbumArtSource] shared cache hit source=${cached.result.source}") + return cached.result + } - val valid = candidates.filter { candidate -> - if (isLikelyPlaceholder(candidate.bitmap)) { - logger("[AlbumArtSource] rejected placeholder source=${candidate.source}") - false - } else { - true + val candidates = mutableListOf() + candidates += readMediaMetadataCandidates() + candidates += readNotificationCandidates(selectedNotification) + logger("[AlbumArtSource] candidates count=${candidates.size}") + if (candidates.isEmpty()) { + logger("[AlbumArtDebug] unavailable reason=no album art candidate") + logger("[AlbumArt][BLE] unavailable") + return null + } + + val valid = candidates.filter { candidate -> + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(candidate.bitmap)) { + logger("[AlbumArtSource] rejected placeholder source=${candidate.source}") + false + } else { + true + } + } + if (valid.isEmpty()) { + logger("[AlbumArtDebug] unavailable reason=all candidates placeholder") + logger("[AlbumArt][BLE] unavailable") + return null } - } - if (valid.isEmpty()) { - logger("[AlbumArtDebug] unavailable reason=all candidates placeholder") - logger("[AlbumArt][BLE] unavailable") - return null - } - val selected = valid.maxWithOrNull( - compareBy { it.bitmap.width.toLong() * it.bitmap.height.toLong() } - .thenBy { it.priority } - ) ?: return null - valid.filter { it !== selected }.forEach { candidate -> + val selected = valid.maxWithOrNull( + compareBy { + it.bitmap.width.toLong() * it.bitmap.height.toLong() + }.thenBy { it.priority } + ) ?: return null + valid.filter { it !== selected }.forEach { candidate -> + logger( + "[AlbumArtSource] rejected smaller source=${candidate.source} " + + "width=${candidate.bitmap.width} height=${candidate.bitmap.height}" + ) + } + logger( + "[AlbumArtSource] selected source=${selected.source} " + + "width=${selected.bitmap.width} height=${selected.bitmap.height}" + ) + logger("[AlbumArtDebug] source ${selected.source} exists=true") logger( - "[AlbumArtSource] rejected smaller source=${candidate.source} " + - "width=${candidate.bitmap.width} height=${candidate.bitmap.height}" + "[AlbumArtDebug] bitmap width=${selected.bitmap.width} " + + "height=${selected.bitmap.height}" + ) + val result = NotificationAlbumArt( + bitmap = selected.bitmap, + source = selected.source ) + if (notificationFingerprint != null) { + sharedArtworkReadCache = SharedArtworkReadCache( + notificationFingerprint = notificationFingerprint, + result = result, + createdAtElapsedMs = now + ) + } + return result } - logger( - "[AlbumArtSource] selected source=${selected.source} " + - "width=${selected.bitmap.width} height=${selected.bitmap.height}" - ) - logger("[AlbumArtDebug] source ${selected.source} exists=true") - logger( - "[AlbumArtDebug] bitmap width=${selected.bitmap.width} " + - "height=${selected.bitmap.height}" - ) - return NotificationAlbumArt( - bitmap = selected.bitmap, - source = selected.source - ) } fun readCurrentNotificationLargeIcon(): Bitmap? { @@ -186,21 +212,26 @@ class AlbumArtTestManager( ) } - private fun readNotificationCandidates( + private fun currentNotification( expectedTitle: String, expectedArtist: String - ): List { + ): StatusBarNotification? { if (!isNotificationAccessEnabled() || !PlayerNotificationListenerService.isConnected() ) { - logger("[AlbumArtSource] notificationLargeIcon size=missing") - return emptyList() + return null } - val selected = selectMusicNotification( + return selectMusicNotification( PlayerNotificationListenerService.activeNotificationsSnapshot(), expectedTitle, expectedArtist - ) ?: run { + ) + } + + private fun readNotificationCandidates( + selected: StatusBarNotification? + ): List { + if (selected == null) { logger("[AlbumArtSource] notificationLargeIcon size=missing") return emptyList() } @@ -224,6 +255,19 @@ class AlbumArtTestManager( ) } + private fun notificationFingerprint( + notification: StatusBarNotification, + expectedTitle: String + ): String { + val extras = notification.notification.extras + val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() + val artist = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty() + // A title-less notification is accepted by the legacy matcher, but it + // must not share artwork across two different expected tracks. + val expectedScope = if (title.isBlank()) expectedTitle.trim().lowercase() else "" + return "${notification.key}|${notification.postTime}|$title|$artist|$expectedScope" + } + private fun albumArtCandidate( source: String, bitmap: Bitmap?, @@ -381,57 +425,6 @@ class AlbumArtTestManager( return bitmap } - private fun isLikelyPlaceholder(bitmap: Bitmap): Boolean { - if (bitmap.width <= 0 || bitmap.height <= 0) return true - val sampleWidth = minOf(24, bitmap.width) - val sampleHeight = minOf(24, bitmap.height) - val sampled = if (sampleWidth == bitmap.width && - sampleHeight == bitmap.height - ) { - bitmap - } else { - Bitmap.createScaledBitmap(bitmap, sampleWidth, sampleHeight, true) - } - return try { - val pixels = IntArray(sampleWidth * sampleHeight) - sampled.getPixels( - pixels, - 0, - sampleWidth, - 0, - 0, - sampleWidth, - sampleHeight - ) - val colorBuckets = HashSet() - var colorfulPixels = 0 - var visiblePixels = 0 - pixels.forEach { pixel -> - val alpha = pixel ushr 24 - if (alpha < 24) return@forEach - val red = (pixel ushr 16) and 0xff - val green = (pixel ushr 8) and 0xff - val blue = pixel and 0xff - val maximum = maxOf(red, green, blue) - val minimum = minOf(red, green, blue) - if (maximum > 0 && - (maximum - minimum).toFloat() / maximum.toFloat() > 0.12f - ) { - colorfulPixels += 1 - } - colorBuckets.add( - ((red / 32) shl 10) or - ((green / 32) shl 5) or - (blue / 32) - ) - visiblePixels += 1 - } - visiblePixels == 0 || (bitmap.width <= 16 && bitmap.height <= 16) - } finally { - if (sampled !== bitmap) sampled.recycle() - } - } - private data class AlbumArtCandidate( val source: String, val bitmap: Bitmap, @@ -444,9 +437,18 @@ class AlbumArtTestManager( ) companion object { + private val sharedArtworkReadLock = Any() + private var sharedArtworkReadCache: SharedArtworkReadCache? = null + private const val SHARED_READ_CACHE_TTL_MS = 750L private const val QQ_MUSIC_PACKAGE = "com.tencent.qqmusic" private const val SOURCE_PRIORITY_METADATA = 2 private const val SOURCE_PRIORITY_NOTIFICATION = 1 private const val DEBUG_ART_DIAGNOSTICS = false } + + private data class SharedArtworkReadCache( + val notificationFingerprint: String, + val result: NotificationAlbumArt, + val createdAtElapsedMs: Long + ) } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt index 9017b0d..44504b6 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt @@ -46,12 +46,38 @@ data class CurrentWordState( val hasWordTiming: Boolean, val positionMs: Long, val timestampMs: Long, - val version: Int = 1 + val sampleElapsedMs: Long = 0L, + val playbackSpeed: Float = 1f, + val version: Int = 2 ) { val wordKey: String get() = "$trackId|$trackGeneration|$lineIndex|$wordIndex|$wordStartMs" } +internal object PlaybackPositionAnchorPolicy { + fun projectedPositionMs( + positionMs: Long, + positionAnchorElapsedMs: Long, + nowElapsedMs: Long, + durationMs: Long, + isPlaying: Boolean, + playbackSpeed: Float + ): Long { + val elapsedMs = if (isPlaying && positionAnchorElapsedMs > 0L) { + (nowElapsedMs - positionAnchorElapsedMs).coerceAtLeast(0L) + } else { + 0L + } + val speed = playbackSpeed.takeIf { it.isFinite() && it > 0f } ?: 1f + val projected = positionMs + (elapsedMs * speed).toLong() + return if (durationMs > 0L) { + projected.coerceIn(0L, durationMs) + } else { + projected.coerceAtLeast(0L) + } + } +} + object CurrentTrackRuntimeCache { private val lock = Any() @@ -72,8 +98,10 @@ object CurrentTrackRuntimeCache { artist: String, album: String, positionMs: Long, + positionSampleElapsedMs: Long, durationMs: Long, isPlaying: Boolean, + playbackSpeed: Float, currentLine: String, lyricSource: String, lastPlaybackState: JSONObject, @@ -110,8 +138,10 @@ object CurrentTrackRuntimeCache { artist = artist, album = album, positionMs = positionMs, + positionAnchorElapsedMs = positionSampleElapsedMs, durationMs = durationMs, isPlaying = isPlaying, + playbackSpeed = playbackSpeed, currentLine = currentLine, currentWord = findCurrentWord(base.lyricLines, positionMs), lyricSource = lyricSource, @@ -162,7 +192,7 @@ object CurrentTrackRuntimeCache { logger: ((String) -> Unit)? = null ) { val runtimeLines = lines.map { it.toRuntimeLine() } - mutate(logger) { previous, now, _ -> + mutate(logger) { previous, now, startedAt -> if (previous == null || previous.songKey != songKey) { previous } else { @@ -181,6 +211,7 @@ object CurrentTrackRuntimeCache { translationLines = runtimeLines.map { it.translation }, romanizationLines = runtimeLines.map { it.romanization }, positionMs = positionMs, + positionAnchorElapsedMs = startedAt, currentLine = currentLine, currentWord = currentWord, lastUpdatedAtMs = now @@ -195,12 +226,13 @@ object CurrentTrackRuntimeCache { currentLine: String, logger: ((String) -> Unit)? = null ) { - mutate(logger) { previous, now, _ -> + mutate(logger) { previous, now, startedAt -> if (previous == null || previous.songKey != songKey) { previous } else { previous.copy( positionMs = positionMs, + positionAnchorElapsedMs = startedAt, currentLine = currentLine, currentWord = findCurrentWord(previous.lyricLines, positionMs), lastUpdatedAtMs = now @@ -290,7 +322,11 @@ object CurrentTrackRuntimeCache { ): PlaybackStateSnapshot? { synchronized(lock) { val track = current ?: return null - val wordState = findCurrentWordStateLocked(track, System.currentTimeMillis()) + val wordState = findCurrentWordStateLocked( + track = track, + timestampMs = System.currentTimeMillis(), + elapsedRealtimeMs = SystemClock.elapsedRealtime() + ) val snapshot = PlaybackStateSnapshot( trackId = track.trackId, title = track.title, @@ -314,7 +350,9 @@ object CurrentTrackRuntimeCache { albumArtState = track.albumArtState, volume = volume, connectionState = connectionState, - lastUpdatedAtMs = track.lastUpdatedAtMs + // Playback diffs compare position samples, so this must use the + // monotonic position anchor rather than metadata/artwork update time. + lastUpdatedAtMs = track.positionAnchorElapsedMs ) lastSnapshot = snapshot PlaybackStateDiffEngine.recordSnapshotBuilt() @@ -322,22 +360,30 @@ object CurrentTrackRuntimeCache { } } - fun currentWordState(timestampMs: Long = System.currentTimeMillis()): CurrentWordState? { + fun currentWordState( + timestampMs: Long = System.currentTimeMillis(), + elapsedRealtimeMs: Long = SystemClock.elapsedRealtime() + ): CurrentWordState? { synchronized(lock) { val track = current ?: return null - return findCurrentWordStateLocked(track, timestampMs) + return findCurrentWordStateLocked(track, timestampMs, elapsedRealtimeMs) } } /** Delay to the next word/line boundary, capped for periodic drift correction. */ fun nextCurrentWordBoundaryDelayMs( timestampMs: Long = System.currentTimeMillis(), + elapsedRealtimeMs: Long = SystemClock.elapsedRealtime(), maximumDriftCorrectionMs: Long = 500L ): Long? { synchronized(lock) { val track = current ?: return null if (!track.isPlaying || track.lyricLines.isEmpty()) return null - val state = findCurrentWordStateLocked(track, timestampMs) ?: return maximumDriftCorrectionMs + val state = findCurrentWordStateLocked( + track, + timestampMs, + elapsedRealtimeMs + ) ?: return maximumDriftCorrectionMs val line = track.lyricLines.getOrNull(state.lineIndex) val candidates = mutableListOf() if (line != null && state.wordIndex >= 0) { @@ -445,22 +491,20 @@ object CurrentTrackRuntimeCache { private fun findCurrentWordStateLocked( track: CurrentTrackSnapshot, - timestampMs: Long + timestampMs: Long, + elapsedRealtimeMs: Long ): CurrentWordState? { if (track.trackId.isBlank() || track.lyricLines.isEmpty()) { return null } - val elapsedMs = if (track.isPlaying && track.lastUpdatedAtMs > 0L) { - (timestampMs - track.lastUpdatedAtMs).coerceAtLeast(0L) - } else { - 0L - } - val rawPosition = track.positionMs + elapsedMs - val position = if (track.durationMs > 0L) { - rawPosition.coerceIn(0L, track.durationMs) - } else { - rawPosition.coerceAtLeast(0L) - } + val position = PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = track.positionMs, + positionAnchorElapsedMs = track.positionAnchorElapsedMs, + nowElapsedMs = elapsedRealtimeMs, + durationMs = track.durationMs, + isPlaying = track.isPlaying, + playbackSpeed = track.playbackSpeed + ) val indexed = findCurrentWordIndexed(track.lyricLines, position) ?: findCurrentLineIndexed(track.lyricLines, position) ?: return null @@ -474,7 +518,9 @@ object CurrentTrackRuntimeCache { wordEndMs = indexed.word.startMs + indexed.word.durationMs.coerceAtLeast(0L), hasWordTiming = indexed.hasWordTiming, positionMs = position, - timestampMs = timestampMs + timestampMs = timestampMs, + sampleElapsedMs = elapsedRealtimeMs, + playbackSpeed = track.playbackSpeed ) } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt index 701e05a..b23968a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt @@ -13,7 +13,8 @@ import java.util.concurrent.atomic.AtomicBoolean class LyricManager( context: Context, private val logger: (String) -> Unit, - private val onLyricsReady: (LyricsReadyGateSnapshot) -> Unit = {} + private val onLyricsReady: (LyricsReadyGateSnapshot) -> Unit = {}, + executionHub: PlayerAgentExecutionHub? = null ) { private val appContext = context.applicationContext @@ -53,14 +54,15 @@ class LyricManager( private var lyricsReadyState: LyricsReadyState = LyricsReadyState.NOT_STARTED private var activeLyricsTaskId: Long = 0L private val requestCancellationGate = LyricRequestCancellationGate() - private val lyricExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "QrcLyricLoaderThread") - } - private val foregroundLyricExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "QrcForegroundLyricThread").apply { - priority = Thread.NORM_PRIORITY + 1 + private val ownsLyricExecutors = executionHub == null + private val lyricExecutor = executionHub?.foregroundIO + ?: Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "QrcLyricLoaderThread") } + private val foregroundLyricExecutor = executionHub?.foregroundIO + ?: Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "QrcForegroundLyricThread").apply { + priority = Thread.NORM_PRIORITY + 1 + } } - } private val parsedCache = object : LinkedHashMap( PARSED_CACHE_MAX_KEYS, 0.75f, @@ -83,6 +85,8 @@ class LyricManager( context = appContext, logger = logger ) + @Volatile + private var fuzzyIndexReady: Boolean = qrcLyricManager.fuzzyIndexStatus().ready private val predictiveLyricsPipeline = PredictiveLyricsPipeline( logger = logger, loader = { track -> @@ -112,10 +116,12 @@ class LyricManager( logger = logger, retryCallback = { reason, bypassRetryableCooldown -> retryActiveSongFromRecovery(reason, bypassRetryableCooldown) - } + }, + scheduledExecutor = executionHub?.scheduled ) private val closed = AtomicBoolean(false) private val fuzzyIndexReadyListener: (QrcFuzzyIndexStatus) -> Unit = { + fuzzyIndexReady = it.ready if (!closed.get()) { // A miss made while the on-disk cache index was cold is provisional. // Let the just-built index participate in the next foreground lookup. @@ -153,8 +159,10 @@ class LyricManager( MaintenanceGuard.removeWindowEndListener(maintenanceWindowEndListener) recoveryEngine.shutdown() requestCancellationGate.cancelAll() - lyricExecutor.shutdownNow() - foregroundLyricExecutor.shutdownNow() + if (ownsLyricExecutors) { + lyricExecutor.shutdownNow() + foregroundLyricExecutor.shutdownNow() + } qrcLyricManager.close() synchronized(this) { pendingRequest = null @@ -603,6 +611,10 @@ class LyricManager( @Synchronized fun currentUnavailableReason(): String { + return currentUnavailableReasonLocked() + } + + private fun currentUnavailableReasonLocked(): String { val key = activeSongKey return when { key != null && isInFlightLocked(key) -> "lyrics loading" @@ -634,10 +646,28 @@ class LyricManager( @Synchronized fun diagnosticSnapshot(trackId: String): LyricDiagnosticSnapshot { + return diagnosticSnapshotLocked(trackId) + } + + @Synchronized + fun playbackLyricStatusSnapshot(trackId: String): PlaybackLyricStatusSnapshot { + val reason = currentUnavailableReasonLocked() + val statusText = currentStatusTextLocked() + return PlaybackLyricStatusSnapshot( + statusText = statusText, + reason = reason, + diagnostic = diagnosticSnapshotLocked(trackId, reason) + ) + } + + private fun diagnosticSnapshotLocked( + trackId: String, + unavailableReason: String = currentUnavailableReasonLocked() + ): LyricDiagnosticSnapshot { val key = activeSongKey.orEmpty() val now = System.currentTimeMillis() val lines = if (key.isNotBlank() && key == loadedSongKey) cachedLines.size else 0 - val reason = currentUnavailableReason() + val reason = unavailableReason val status = when { key.isBlank() -> "no_lyrics_final" lines > 0 -> "loaded" @@ -691,7 +721,7 @@ class LyricManager( nextRetryAt = nextRetryAt, retryCount = retryCountInWindow, cooldownUntil = cooldownUntil, - fuzzyIndexReady = QrcLyricCacheManager(appContext, logger).fuzzyIndexStatus().ready, + fuzzyIndexReady = fuzzyIndexReady, qrcIndexLoaded = true, maintenanceBusy = QrcMaintenanceCoordinator.isRunning(), waitingQqMusicCache = status == "waiting_qqmusic_cache", @@ -1756,7 +1786,13 @@ class LyricManager( currentTrack.artist, currentTrack.album ) - if (!isSameIncrementalLyricTrack( + val adoptRuntimeTrack = canAdoptIncrementalLyricTrack( + activeSongKey = activeSongKey, + activeTrackId = activeTrackId, + snapshotSongKey = activeKey, + snapshotTrackId = currentTrack.trackId + ) + if (!adoptRuntimeTrack && !isSameIncrementalLyricTrack( activeSongKey = activeSongKey, activeTrackId = activeTrackId, snapshotSongKey = activeKey, @@ -1769,6 +1805,24 @@ class LyricManager( ) return false } + if (adoptRuntimeTrack) { + // The Sony UI deliberately reads playback state without triggering a + // raw-QRC lookup. A watcher can therefore finish before any BLE + // client has established LyricManager's active identity. Adopt only + // that empty state; never replace an already-active different song. + activeSongKey = activeKey + activeTitle = currentTrack.title + activeArtist = currentTrack.artist + activeAlbum = currentTrack.album + activeTrackId = currentTrack.trackId + activeDurationMs = currentTrack.durationMs + activePositionMs = currentTrack.positionMs + activeTrackChangedAtMs = currentTrack.trackChangedAtMs + logger( + "[Lyric] incremental lyrics adopted runtime track " + + "songKey=${currentTrack.songKey} trackId=${currentTrack.trackId}" + ) + } val lines = ready.parsed.lines.map { LyricLine( timeMs = it.timeMs, @@ -1849,6 +1903,10 @@ class LyricManager( @Synchronized fun currentStatusText(): String { + return currentStatusTextLocked() + } + + private fun currentStatusTextLocked(): String { val key = activeSongKey ?: return "unknown" return when { key == loadedSongKey && cachedLines.isNotEmpty() -> "loaded" @@ -2361,6 +2419,12 @@ class LyricManager( val recentQrcCandidateCount: Int = 0 ) + data class PlaybackLyricStatusSnapshot( + val statusText: String, + val reason: String, + val diagnostic: LyricDiagnosticSnapshot + ) + private data class LyricCandidate( val file: File, val score: Int @@ -2470,3 +2534,15 @@ internal fun isSameIncrementalLyricTrack( snapshotTrackId.isNotBlank() && activeTrackId == snapshotTrackId } + +internal fun canAdoptIncrementalLyricTrack( + activeSongKey: String?, + activeTrackId: String, + snapshotSongKey: String, + snapshotTrackId: String +): Boolean { + return activeSongKey.isNullOrBlank() && + activeTrackId.isBlank() && + snapshotSongKey.isNotBlank() && + snapshotTrackId.isNotBlank() +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt index 20bde88..c5703cc 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt @@ -1,6 +1,7 @@ package com.example.playeragent.media import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit @@ -34,9 +35,11 @@ data class LyricRecoverySnapshot( class LyricRecoveryEngine( private val logger: (String) -> Unit, - private val retryCallback: (reason: String, bypassRetryableCooldown: Boolean) -> Boolean + private val retryCallback: (reason: String, bypassRetryableCooldown: Boolean) -> Boolean, + scheduledExecutor: ScheduledExecutorService? = null ) { - private val scheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + private val ownsScheduler = scheduledExecutor == null + private val scheduler = scheduledExecutor ?: Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "LyricRecoveryThread") } private var session: RecoverySession? = null @@ -248,7 +251,7 @@ class LyricRecoveryEngine( @Synchronized fun shutdown() { cancelTimersLocked() - scheduler.shutdownNow() + if (ownsScheduler) scheduler.shutdownNow() } private fun scheduleTimerRetryLocked() { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/MediaExecutionContext.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/MediaExecutionContext.kt new file mode 100644 index 0000000..9d6533f --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/MediaExecutionContext.kt @@ -0,0 +1,49 @@ +package com.example.playeragent.media + +import java.io.Closeable +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Bounded execution lanes shared by the BLE media coordinators. + * + * Real-time control/reconnect work is isolated from foreground media I/O, while + * history and diagnostics remain serialized on the maintenance lane. + */ +class MediaExecutionContext( + threadNamePrefix: String = "PlayerAgent" +) : Closeable { + private val foregroundThreadIndex = AtomicInteger(0) + + val realtime: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-Realtime") + } + val foregroundIO: ExecutorService = Executors.newFixedThreadPool(2) { runnable -> + Thread( + runnable, + "$threadNamePrefix-ForegroundIO-${foregroundThreadIndex.incrementAndGet()}" + ) + } + val maintenance: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-Maintenance").apply { + priority = Thread.MIN_PRIORITY + } + } + + override fun close() { + listOf(realtime, foregroundIO, maintenance).forEach(ExecutorService::shutdownNow) + } + + fun awaitTermination(timeoutMs: Long): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs) + return listOf(realtime, foregroundIO, maintenance).all { executor -> + val remaining = deadline - System.nanoTime() + remaining > 0L && executor.awaitTermination( + remaining, + TimeUnit.NANOSECONDS + ) + } + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt index 9c7dc7b..ef29f3a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt @@ -20,7 +20,8 @@ class PlaybackStateReader( private val logger: (String) -> Unit, private val includeLyric: Boolean = true, private val reactiveMediaController: ReactiveMediaController = ReactiveMediaController(logger), - private val onLyricsReady: (LyricsReadyGateSnapshot) -> Unit = {} + private val onLyricsReady: (LyricsReadyGateSnapshot) -> Unit = {}, + private val executionHub: PlayerAgentExecutionHub? = null ) { private val appContext = context.applicationContext @@ -30,6 +31,7 @@ class PlaybackStateReader( LyricManager( context = appContext, logger = logger, + executionHub = executionHub, onLyricsReady = { snapshot -> reactiveMediaController.markLyricsTaskFinished( trackId = snapshot.trackId, @@ -114,7 +116,12 @@ class PlaybackStateReader( } val playbackState = selected.playbackState val playing = playbackState?.state == PlaybackState.STATE_PLAYING - val position = calculatePosition(playbackState) + val positionSampleElapsedMs = SystemClock.elapsedRealtime() + val positionSampleUnixMs = System.currentTimeMillis() + val playbackSpeed = playbackState?.playbackSpeed + ?.takeIf { it.isFinite() && it > 0f } + ?: 1f + val position = calculatePosition(playbackState, positionSampleElapsedMs) val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE).orEmpty() val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST).orEmpty() @@ -262,9 +269,10 @@ class PlaybackStateReader( lastLoggedLyric = lyric logger("[PlaybackState] lyric=$lyric") } - val lyricStatus = lyricManager.currentStatusText() - val lyricReason = lyricManager.currentUnavailableReason() - val diagnostic = lyricManager.diagnosticSnapshot(lastTrackId) + val lyricState = lyricManager.playbackLyricStatusSnapshot(lastTrackId) + val lyricStatus = lyricState.statusText + val lyricReason = lyricState.reason + val diagnostic = lyricState.diagnostic val totalCostMs = SystemClock.elapsedRealtime() - startedAtMs if (LogConfig.DEBUG_VERBOSE_LOG || totalCostMs > SLOW_PLAYBACK_READ_MS) { logger( @@ -292,6 +300,9 @@ class PlaybackStateReader( .put("artist", artist) .put("album", album) .put("position", position) + .put("positionSampleElapsedMs", positionSampleElapsedMs) + .put("positionSampleUnixMs", positionSampleUnixMs) + .put("speed", playbackSpeed.toDouble()) .put("duration", duration) .put("lyric", lyric) .put("lyricStatus", lyricStatus) @@ -304,8 +315,10 @@ class PlaybackStateReader( artist = artist, album = album, positionMs = position, + positionSampleElapsedMs = positionSampleElapsedMs, durationMs = duration, isPlaying = playing, + playbackSpeed = playbackSpeed, currentLine = lyric, lyricSource = diagnostic.source, lastPlaybackState = response, @@ -820,7 +833,10 @@ class PlaybackStateReader( return 0L } - private fun calculatePosition(playbackState: PlaybackState?): Long { + private fun calculatePosition( + playbackState: PlaybackState?, + nowElapsedMs: Long = SystemClock.elapsedRealtime() + ): Long { if (playbackState == null) { return 0L } @@ -830,7 +846,7 @@ class PlaybackStateReader( return basePosition } - val elapsedSinceUpdate = SystemClock.elapsedRealtime() - playbackState.lastPositionUpdateTime + val elapsedSinceUpdate = nowElapsedMs - playbackState.lastPositionUpdateTime val adjustedPosition = basePosition + (elapsedSinceUpdate * playbackState.playbackSpeed).toLong() return adjustedPosition.coerceAtLeast(0L) } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlayerAgentExecutionHub.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlayerAgentExecutionHub.kt new file mode 100644 index 0000000..1d8ce78 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlayerAgentExecutionHub.kt @@ -0,0 +1,53 @@ +package com.example.playeragent.media + +import java.io.Closeable +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** Service-scoped bounded execution lanes for Sony PlayerAgent work. */ +class PlayerAgentExecutionHub( + threadNamePrefix: String = "PlayerAgent" +) : Closeable { + private val foregroundIndex = AtomicInteger(0) + private val closed = AtomicBoolean(false) + + val realtime: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-Realtime") + } + val foregroundIO: ExecutorService = Executors.newFixedThreadPool(2) { runnable -> + Thread(runnable, "$threadNamePrefix-ForegroundIO-${foregroundIndex.incrementAndGet()}") + } + val scheduled: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-Scheduled") + } + val maintenance: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-Maintenance").apply { + priority = Thread.MIN_PRIORITY + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + executors().forEach(ExecutorService::shutdownNow) + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SHUTDOWN_WAIT_MS) + executors().forEach { executor -> + val remaining = deadline - System.nanoTime() + if (remaining > 0L) { + runCatching { executor.awaitTermination(remaining, TimeUnit.NANOSECONDS) } + } + } + } + + fun isShutdown(): Boolean = executors().all(ExecutorService::isShutdown) + + private fun executors(): List = + listOf(realtime, foregroundIO, scheduled, maintenance) + + companion object { + private const val SHUTDOWN_WAIT_MS = 1_500L + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcAliasCacheManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcAliasCacheManager.kt index eba0042..4361410 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcAliasCacheManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcAliasCacheManager.kt @@ -124,7 +124,13 @@ class QrcAliasCacheManager( val root = JSONObject() .put("version", CACHE_VERSION) .put("items", array) - cacheFile().writeText(root.toString(), Charsets.UTF_8) + val target = cacheFile() + val temporary = File(target.parentFile, "${target.name}.tmp") + temporary.writeText(root.toString(), Charsets.UTF_8) + if (!temporary.renameTo(target)) { + temporary.copyTo(target, overwrite = true) + temporary.delete() + } } catch (exception: Exception) { logger("[QrcAlias] save failed: ${exception.message}") } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicy.kt new file mode 100644 index 0000000..dbfebf6 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicy.kt @@ -0,0 +1,122 @@ +package com.example.playeragent.media + +import kotlin.math.abs + +/** + * Conservative policy for binding a newly written QQ Music QRC group to the + * current MediaSession track when producer/metadata sidecars are incomplete. + * + * This policy intentionally never accepts an artist-only match. A candidate + * with no artist is accepted only when its lyric title is an exact normalized + * match and it is the only recent candidate that can represent the request. + */ +internal object QrcCurrentTrackMatchPolicy { + + data class Track( + val title: String, + val artist: String, + val album: String = "", + val durationMs: Long = 0L, + val requestStillValid: Boolean = true + ) + + data class Candidate( + val groupId: String, + val metadataTitle: String, + val metadataArtist: String, + val lyricTitleCandidate: String, + val lastLineEndMs: Long, + val effectiveModifiedAtMs: Long + ) + + data class Decision( + val candidate: Candidate? = null, + val reason: String, + val inferredTitle: String = "" + ) { + val matched: Boolean get() = candidate != null + } + + fun evaluateRecent( + track: Track, + candidates: List, + recentCandidateCount: Int = candidates.size + ): Decision { + if (!track.requestStillValid) { + return Decision(reason = "stale request") + } + if (recentCandidateCount <= 0) { + return Decision(reason = "no recent candidates") + } + val trackTitle = QrcLyricUtils.normalizeForMatch(track.title) + if (trackTitle.isBlank()) { + return Decision(reason = "empty current title") + } + val trackArtists = QrcLyricUtils.splitArtists(track.artist) + val safeExact = candidates.filter { candidate -> + val candidateTitle = candidate.metadataTitle + .ifBlank { candidate.lyricTitleCandidate } + val titleExact = QrcLyricUtils.normalizeForMatch(candidateTitle) == trackTitle + val candidateArtists = QrcLyricUtils.splitArtists(candidate.metadataArtist) + val artistSafe = candidateArtists.isEmpty() || + trackArtists.isEmpty() || + trackArtists.any(candidateArtists::contains) + titleExact && artistSafe && durationIsReasonable(track.durationMs, candidate.lastLineEndMs) + } + if (safeExact.isEmpty()) { + return Decision(reason = "no exact title candidate") + } + if (safeExact.size != 1) { + return Decision(reason = "ambiguous recent candidates") + } + val candidate = safeExact.single() + return Decision( + candidate = candidate, + reason = if (candidate.metadataArtist.isBlank()) { + "unique exact lyric title without artist" + } else { + "exact title and artist" + }, + inferredTitle = candidate.metadataTitle.ifBlank { candidate.lyricTitleCandidate } + ) + } + + fun extractTitleCandidate(lines: List): String { + return lines.asSequence() + .map(String::trim) + .filter(String::isNotBlank) + .take(MAX_TITLE_SCAN_LINES) + .firstOrNull { line -> + line.length <= MAX_TITLE_LENGTH && + !CREDIT_LINE_REGEX.containsMatchIn(line) && + !line.startsWith("[") && + !line.startsWith("{") + } + .orEmpty() + } + + fun lastLineEndMs(lines: List): Long { + val last = lines.maxByOrNull(QrcLyricLine::timeMs) ?: return 0L + val wordEnd = last.words.maxOfOrNull { it.startMs + it.durationMs } ?: 0L + return maxOf(last.timeMs + last.durationMs, wordEnd, last.timeMs) + } + + private fun durationIsReasonable(trackDurationMs: Long, lyricEndMs: Long): Boolean { + if (trackDurationMs <= 0L || lyricEndMs <= 0L) { + return true + } + val delta = abs(trackDurationMs - lyricEndMs) + val ratio = lyricEndMs.toDouble() / trackDurationMs.toDouble() + return ratio in MIN_DURATION_RATIO..MAX_DURATION_RATIO || delta <= DURATION_TOLERANCE_MS + } + + private const val MAX_TITLE_SCAN_LINES = 8 + private const val MAX_TITLE_LENGTH = 80 + private const val MIN_DURATION_RATIO = 0.45 + private const val MAX_DURATION_RATIO = 1.35 + private const val DURATION_TOLERANCE_MS = 90_000L + private val CREDIT_LINE_REGEX = Regex( + """^(词|作词|曲|作曲|编曲|制作|制作人|演唱|歌手|混音|和声|录音|母带|出品|发行|监制|吉他|贝斯|鼓)\s*[::]""", + RegexOption.IGNORE_CASE + ) +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcDirectoryWatcher.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcDirectoryWatcher.kt index 6d907e1..3f2cf34 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcDirectoryWatcher.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcDirectoryWatcher.kt @@ -1,25 +1,31 @@ package com.example.playeragent.media +import android.content.Context import android.os.FileObserver import java.io.File import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit class QrcDirectoryWatcher( + context: Context, private val incrementalPrebuildManager: QrcIncrementalPrebuildManager, private val logger: (String) -> Unit, - private val statusListener: (QrcWatcherStatus) -> Unit + private val statusListener: (QrcWatcherStatus) -> Unit, + executionHub: PlayerAgentExecutionHub? = null ) { + private val appContext = context.applicationContext private val directory: File = QrcLyricUtils.qrcDirectory() - private val scheduler = ScheduledThreadPoolExecutor(1) { runnable -> - Thread(runnable, "QrcDirectoryWatcherThread").apply { - priority = Thread.MIN_PRIORITY - } - }.apply { - removeOnCancelPolicy = true - } + private val watermarkStore = QrcWatcherWatermarkStore(appContext) + private val ownsScheduler = executionHub == null + private val scheduler: ScheduledExecutorService = executionHub?.scheduled + ?: ScheduledThreadPoolExecutor(1) { runnable -> + Thread(runnable, "QrcDirectoryWatcherThread").apply { + priority = Thread.MIN_PRIORITY + } + }.apply { removeOnCancelPolicy = true } private val lock = Any() private val pendingGroups = linkedSetOf() private var debounceFuture: ScheduledFuture<*>? = null @@ -54,6 +60,7 @@ class QrcDirectoryWatcher( running = true logger("[QrcWatcher] started dir=${directory.absolutePath}") publishStatusLocked() + scheduler.execute(::enqueueStartupCatchup) } } @@ -65,6 +72,7 @@ class QrcDirectoryWatcher( debounceFuture = null pendingGroups.clear() running = false + if (ownsScheduler) scheduler.shutdownNow() logger("[QrcWatcher] stopped") publishStatusLocked() } @@ -116,6 +124,7 @@ class QrcDirectoryWatcher( } if (batch.isNotEmpty()) { incrementalPrebuildManager.processGroups(batch) + watermarkStore.advanceTo(System.currentTimeMillis()) } synchronized(lock) { if (pendingGroups.isNotEmpty() && running) { @@ -125,6 +134,45 @@ class QrcDirectoryWatcher( } } + private fun enqueueStartupCatchup() { + if (!running) return + val previousWatermark = watermarkStore.read() + val signals = QrcLyricUtils.scanGroups().map { group -> + QrcGroupChangeSignal( + groupId = group.groupId, + changedAtMs = maxOf( + group.lastModified, + QrcLyricUtils.sidecarLastModified(group), + QrcLyricUtils.readExSavingTime(group.exFile) + ) + ) + } + val changed = QrcWatcherCatchupPolicy.selectChanged( + signals = signals, + watermarkMs = previousWatermark, + limit = STARTUP_CATCHUP_GROUPS + ) + if (changed.isEmpty()) { + watermarkStore.advanceTo(signals.maxOfOrNull(QrcGroupChangeSignal::changedAtMs) ?: 0L) + logger("[QrcWatcher] startup catchup none watermark=$previousWatermark") + return + } + synchronized(lock) { + changed.forEach { signal -> + if (pendingGroups.add(signal.groupId)) { + QrcDirectoryGeneration.markChanged(signal.groupId, "STARTUP_CATCHUP", logger) + } + } + scheduleDebounceLocked() + publishStatusLocked() + } + watermarkStore.advanceTo(changed.maxOf(QrcGroupChangeSignal::changedAtMs)) + logger( + "[QrcWatcher] startup catchup groups=${changed.size} " + + "watermark=$previousWatermark newest=${changed.first().changedAtMs}" + ) + } + private fun publishStatusLocked() { statusListener( incrementalPrebuildManager.currentStatus( @@ -156,6 +204,7 @@ class QrcDirectoryWatcher( companion object { private const val DEBOUNCE_MS = 180L private const val MAX_BATCH_GROUPS = 20 + private const val STARTUP_CATCHUP_GROUPS = 3 private const val EVENT_MASK = FileObserver.CREATE or FileObserver.MOVED_TO or @@ -167,3 +216,39 @@ class QrcDirectoryWatcher( Regex("""^(-?\d+)\.(qrc|producer|ex|translrc|romaqrc|lrc)$""") } } + +internal data class QrcGroupChangeSignal( + val groupId: String, + val changedAtMs: Long +) + +internal object QrcWatcherCatchupPolicy { + fun selectChanged( + signals: List, + watermarkMs: Long, + limit: Int + ): List { + if (limit <= 0) return emptyList() + return signals.asSequence() + .filter { it.groupId.isNotBlank() && it.changedAtMs > watermarkMs } + .sortedByDescending(QrcGroupChangeSignal::changedAtMs) + .take(limit) + .toList() + } +} + +private class QrcWatcherWatermarkStore(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun read(): Long = preferences.getLong(KEY_WATERMARK_MS, 0L) + + fun advanceTo(value: Long) { + if (value <= read()) return + preferences.edit().putLong(KEY_WATERMARK_MS, value).apply() + } + + companion object { + private const val PREFERENCES_NAME = "qrc_watcher_state" + private const val KEY_WATERMARK_MS = "last_processed_change_ms_v2" + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcIncrementalPrebuildManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcIncrementalPrebuildManager.kt index 0695bf7..6110bc2 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcIncrementalPrebuildManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcIncrementalPrebuildManager.kt @@ -12,7 +12,8 @@ class QrcIncrementalPrebuildManager( private val statusListener: (QrcWatcherStatus) -> Unit, private val currentTrackProvider: () -> CurrentTrackSnapshot? = { null }, private val onIncrementalLyricsReady: (IncrementalLyricsReady) -> Unit = {}, - private val onBatchProcessed: (Set) -> Unit = {} + private val onBatchProcessed: (Set) -> Unit = {}, + executionHub: PlayerAgentExecutionHub? = null ) { private val appContext = context.applicationContext @@ -28,11 +29,13 @@ class QrcIncrementalPrebuildManager( context = appContext, logger = logger ) - private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "QrcIncrementalPrebuildThread").apply { - priority = Thread.MIN_PRIORITY + private val ownsExecutor = executionHub == null + private val executor: ExecutorService = executionHub?.maintenance + ?: Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "QrcIncrementalPrebuildThread").apply { + priority = Thread.MIN_PRIORITY + } } - } private val stopped = AtomicBoolean(false) private val statusLock = Any() private var running = false @@ -112,7 +115,7 @@ class QrcIncrementalPrebuildManager( fun stop() { stopped.set(true) - executor.shutdownNow() + if (ownsExecutor) executor.shutdownNow() setRunning(false) QrcMaintenanceCoordinator.finishCurrentIf( MaintenanceTaskType.QRC_INCREMENTAL_PREBUILD, @@ -154,6 +157,11 @@ class QrcIncrementalPrebuildManager( if (!shouldTryCurrentTrack) { val validation = cacheManager.validateGroupCache(group, requireComplete = true) if (validation.valid) { + validation.cached?.let { cached -> + cacheManager.registerValidatedCache(cached)?.let { indexed -> + persistentIndexManager.updateParsedMetadata(groupId, indexed) + } + } logger("[QrcIncremental] skip cached groupId=$groupId") logger("[QrcIndex] not marked dirty for skip cached groupId=$groupId") incrementSkipped() @@ -189,6 +197,7 @@ class QrcIncrementalPrebuildManager( var savedAny = false if (parsed.title.isNotBlank()) { cacheManager.save(parsed) + persistentIndexManager.updateParsedMetadata(groupId, parsed) negativeCacheManager.removeNegative(parsed.songKey) savedAny = true } else { @@ -331,44 +340,37 @@ class QrcIncrementalPrebuildManager( logger("[QrcIncrementalMatch] rejected reason=current lyrics already available") return false } - if (!isRecentForCurrentTrack(group, currentTrack)) { - logger("[QrcIncrementalMatch] rejected reason=not recent for current track") - return false - } - - val currentArtistTokens = QrcLyricUtils.splitArtists(currentTrack.artist) - val parsedArtistTokens = QrcLyricUtils.splitArtists(parsed.artist) - val rawNormalized = QrcLyricUtils.normalizeForMatch(parsed.rawText) - val artistMatches = currentArtistTokens.isNotEmpty() && - (currentArtistTokens.any(parsedArtistTokens::contains) || - currentArtistTokens.any { rawNormalized.contains(it) }) - if (!artistMatches) { - logger("[QrcIncrementalMatch] rejected reason=artist mismatch") - return false - } - - val currentTitle = QrcLyricUtils.normalizeForMatch(currentTrack.title) - val parsedTitle = QrcLyricUtils.normalizeForMatch(parsed.title) - if (currentTitle.isNotBlank() && parsedTitle == currentTitle) { - logger("[QrcIncrementalMatch] matched reason=title_exact") - return true - } - if (currentTitle.isNotBlank() && rawNormalized.contains(currentTitle)) { - logger("[QrcIncrementalMatch] matched reason=raw_contains_current_title") - return true - } - val parsedTitleUnreliable = parsed.title.isBlank() || - QrcLyricUtils.isInvalidMetadataTitle(parsed.title) - if (parsedTitleUnreliable && batchSize == 1) { - logger("[QrcIncrementalMatch] matched reason=recent_single_group_artist_match") - return true - } - if (parsedTitleUnreliable && batchSize > 1) { - logger("[QrcIncrementalMatch] ambiguous groups=$batchSize skip alias") - } else { - logger("[QrcIncrementalMatch] rejected reason=title mismatch") - } - return false + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track( + title = currentTrack.title, + artist = currentTrack.artist, + album = currentTrack.album, + durationMs = currentTrack.durationMs, + requestStillValid = currentTrack.trackId.isNotBlank() + ), + candidates = listOf( + QrcCurrentTrackMatchPolicy.Candidate( + groupId = group.groupId, + metadataTitle = parsed.title, + metadataArtist = parsed.artist, + lyricTitleCandidate = QrcCurrentTrackMatchPolicy.extractTitleCandidate( + parsed.lines.map(QrcLyricLine::text) + ), + lastLineEndMs = QrcCurrentTrackMatchPolicy.lastLineEndMs(parsed.lines), + effectiveModifiedAtMs = maxOf( + group.lastModified, + QrcLyricUtils.sidecarLastModified(group), + QrcLyricUtils.readExSavingTime(group.exFile) + ) + ) + ), + recentCandidateCount = batchSize + ) + logger( + "[QrcIncrementalMatch] ${if (decision.matched) "matched" else "rejected"} " + + "reason=${decision.reason}" + ) + return decision.matched } private fun findGroup(groupId: String): QrcFileGroup { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt index 6fd3250..063309e 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt @@ -321,6 +321,11 @@ class QrcLyricCacheManager( directory.mkdirs() val existing = readBySongKeyFromDirectory(canonicalParsed.songKey, directory) if (existing != null && shouldKeepExisting(existing, canonicalParsed)) { + if (runCatching { directory.canonicalPath == cacheRoot().canonicalPath } + .getOrDefault(false) + ) { + registerValidatedCache(existing) + } logger("[QrcPrebuild] duplicate songKey=${canonicalParsed.songKey} keep=existing") return } @@ -342,6 +347,44 @@ class QrcLyricCacheManager( logger("[QrcCache] saved songKey=${canonicalParsed.songKey} lines=${canonicalParsed.lines.size}") } + /** + * Makes an already-validated group cache immediately queryable without + * decrypting or rewriting it. This is important during an app upgrade: the + * parsed cache can be valid while the versioned index is still rebuilding + * behind the real-time playback guard. + */ + @Synchronized + fun registerValidatedCache(parsed: ParsedLyric): ParsedLyric? { + if (parsed.lines.isEmpty()) return null + val safeTitle = parsed.title.ifBlank { + QrcCurrentTrackMatchPolicy.extractTitleCandidate( + parsed.lines.map(QrcLyricLine::text) + ) + } + if (safeTitle.isBlank()) { + logger("[QrcCacheIndex] validated cache ignored reason=title missing groupId=${parsed.groupId}") + return null + } + val indexed = parsed.copy( + songKey = QrcLyricUtils.buildSongKey(safeTitle, parsed.artist, parsed.album), + title = safeTitle + ) + val songFile = songCacheFile(indexed.songKey) + val groupFile = groupCacheFile(indexed.groupId) + val sourceFile = when { + songFile.isFile -> songFile + groupFile.isFile -> groupFile + else -> return null + } + memoryCache[indexed.songKey] = indexed + upsertParsedIndex(indexed, sourceFile) + logger( + "[QrcCacheIndex] validated cache registered groupId=${indexed.groupId} " + + "title=${indexed.title} lines=${indexed.lines.size}" + ) + return indexed + } + @Synchronized fun clearMemory() { memoryCache.clear() @@ -577,9 +620,7 @@ class QrcLyricCacheManager( return null } val currentTitle = QrcLyricUtils.normalizeForMatch(title) - if (currentTitle.length < MIN_FUZZY_TITLE_LENGTH || - GENERIC_TITLES.contains(currentTitle) - ) { + if (currentTitle.isBlank() || GENERIC_TITLES.contains(currentTitle)) { logger("[QrcCache] fuzzy rejected reason=bad title") trace(traceId, "fuzzy", "result=skipped reason=bad_title costMs=${System.currentTimeMillis() - startedAt}") return null @@ -633,6 +674,19 @@ class QrcLyricCacheManager( ) listOf(direct to DIRECT_TITLE_ARTIST_SCORE) } else { + // A one-character CJK title is unsafe for a broad fuzzy scan, but a + // unique exact title+artist row above is deterministic and should + // still use the parsed cache (for example QQ Music's 《当》). + if (currentTitle.length < MIN_FUZZY_TITLE_LENGTH) { + logger("[QrcCache] fuzzy rejected reason=short title without exact artist") + trace( + traceId, + "fuzzy", + "result=skipped reason=short_title_without_exact_artist " + + "costMs=${System.currentTimeMillis() - startedAt}" + ) + return null + } val scoredCandidates = mutableListOf>() // Most alias cases differ only in artist spelling. Restrict those // lookups to exact-title rows instead of scoring the whole parsed @@ -787,7 +841,19 @@ class QrcLyricCacheManager( } val groupId = objectValue.optString("groupId") val qrcLastModified = objectValue.optLong("qrcLastModified") - val lines = readLines(objectValue.optJSONArray("lines") ?: JSONArray()) + val cacheBuildVersion = objectValue.optInt("cacheBuildVersion", 0) + val cachedLines = readLines(objectValue.optJSONArray("lines") ?: JSONArray()) + val lines = if (cacheBuildVersion < QRC_CACHE_BUILD_VERSION) { + if (cachedLines.any { it.words.isNotEmpty() }) { + logger( + "[QrcCache] discard legacy word timing build=$cacheBuildVersion " + + "required=$QRC_CACHE_BUILD_VERSION groupId=$groupId" + ) + } + cachedLines.map { it.copy(words = emptyList()) } + } else { + cachedLines + } if (lines.isEmpty()) { return CacheReadResult(rejectReason = "lines empty") } @@ -821,14 +887,18 @@ class QrcLyricCacheManager( lines = lines, schemaVersion = schemaVersion, qrcPath = objectValue.optString("qrcPath"), - wordTimingStatus = QrcWordTimingStatus.fromValue( - objectValue.optString( - "wordTimingStatus", - QrcWordTimingStatus.fromLines(lines).name + wordTimingStatus = if (cacheBuildVersion < QRC_CACHE_BUILD_VERSION) { + QrcWordTimingStatus.fromLines(lines) + } else { + QrcWordTimingStatus.fromValue( + objectValue.optString( + "wordTimingStatus", + QrcWordTimingStatus.fromLines(lines).name + ) ) - ), + }, groupFingerprint = readFingerprint(objectValue), - cacheBuildVersion = objectValue.optInt("cacheBuildVersion", 0), + cacheBuildVersion = cacheBuildVersion, translationParseFailed = objectValue.optBoolean( "translationParseFailed", false diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt index 755375a..4d29131 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt @@ -43,6 +43,10 @@ class QrcLyricManager( } } + fun fuzzyIndexStatus(): QrcFuzzyIndexStatus { + return cacheManager.fuzzyIndexStatus() + } + @Synchronized fun load(title: String, artist: String, album: String): Boolean { return loadWithResult(title, artist, album).success @@ -280,7 +284,7 @@ class QrcLyricManager( if (parsed != null && parsedMatchesTitle(parsed, normalizedTitle) ) { - applyParsedResult(songKey, entry, parsed) + applyParsedResult(songKey, entry, parsed, title, artist, album) logger("[QrcLyric] cache hit songKey=$songKey") logger( "[QrcLyric] parsed lines=${cachedLines.size} " + @@ -301,7 +305,7 @@ class QrcLyricManager( normalizedTitle = normalizedTitle, normalizedArtist = normalizedArtist, normalizedAlbum = normalizedAlbum, - allowRecentFallback = allowIndexRefresh, + allowRecentFallback = true, shouldCancel = shouldCancel, traceId = traceId ) @@ -412,7 +416,7 @@ class QrcLyricManager( ) } - applyParsedResult(songKey, best.entry, parsed) + applyParsedResult(songKey, best.entry, parsed, title, artist, album) negativeCacheManager.removeNegative(songKey) logger( "[QrcLyric] best group=${best.entry.groupId} score=${best.score}" @@ -775,27 +779,12 @@ class QrcLyricManager( val attemptedGroupIds = producerConfirmCandidates .map { it.entry.groupId } .toSet() - val fallbackCount = if ( - normalizedTitle.isNotBlank() && - sortedProducerCandidates.isEmpty() && - hasRecentQrcFiles(entries) - ) { - RECENT_FALLBACK_DECRYPT_CANDIDATES - } else { - FALLBACK_DECRYPT_CANDIDATES - } val fallbackCandidates = fallbackRecentCandidates( entries = entries, excludedGroupIds = attemptedGroupIds, - limit = fallbackCount + limit = FALLBACK_DECRYPT_CANDIDATES ) - if (fallbackCount == RECENT_FALLBACK_DECRYPT_CANDIDATES) { - logger( - "[QrcLyric] recent fallback decrypt candidates=$fallbackCount " + - "reason=new qrc files" - ) - } - confirmed = confirmCandidates( + confirmed = confirmRecentCandidates( candidates = fallbackCandidates, normalizedTitle = normalizedTitle, normalizedArtist = normalizedArtist, @@ -806,17 +795,6 @@ class QrcLyricManager( "[QrcLyric] fallback decrypt candidates=${fallbackCandidates.size} " + "costMs=${System.currentTimeMillis() - fallbackStartedAt}" ) - } else if (confirmed == null && !allowRecentFallback) { - // The foreground player path must not decrypt unrelated recent - // groups when the current title has no indexed producer match. - // QrcDirectoryWatcher parses the newly written current group after - // its size/mtime stabilise and then retries the exact song. - logger("[QrcLyric] foreground recent fallback skipped") - trace( - traceId, - "fuzzy", - "result=skipped reason=foreground_no_recent_fallback" - ) } return SearchResult( best = confirmed, @@ -829,15 +807,18 @@ class QrcLyricManager( excludedGroupIds: Set, limit: Int ): List { - return entries - .asSequence() - .filter { it.groupId !in excludedGroupIds } - .sortedByDescending(QrcGroupIndexEntry::lastModified) - .take(limit) - .map { entry -> - ScoredGroup(entry = entry, score = 100) + val selected = ArrayList(limit) + entries.forEach { entry -> + if (entry.groupId in excludedGroupIds) return@forEach + val insertAt = selected.indexOfFirst { + entry.effectiveModifiedAt() > it.effectiveModifiedAt() } - .toList() + if (insertAt >= 0) selected.add(insertAt, entry) else selected.add(entry) + if (selected.size > limit) selected.removeAt(selected.lastIndex) + } + return selected.map { entry -> + ScoredGroup(entry = entry, score = 100) + } } private fun hasRecentQrcFiles(entries: List): Boolean { @@ -894,6 +875,49 @@ class QrcLyricManager( return null } + private fun confirmRecentCandidates( + candidates: List, + normalizedTitle: String, + normalizedArtist: String, + shouldCancel: () -> Boolean, + traceId: String + ): ScoredGroup? { + val parsedCandidates = candidates.mapNotNull { candidate -> + if (shouldCancel()) return null + val parsed = decryptAndParseQrc(candidate.entry, traceId) ?: return@mapNotNull null + candidate to parsed + } + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track( + title = normalizedTitle, + artist = normalizedArtist, + requestStillValid = !shouldCancel() + ), + candidates = parsedCandidates.map { (candidate, parsed) -> + QrcCurrentTrackMatchPolicy.Candidate( + groupId = candidate.entry.groupId, + metadataTitle = parsed.title, + metadataArtist = parsed.artist, + lyricTitleCandidate = QrcCurrentTrackMatchPolicy.extractTitleCandidate( + parsed.lines.map(LyricLine::text) + ), + lastLineEndMs = parsed.lines.lastOrNull()?.let { + maxOf(it.timeMs + it.durationMs, it.timeMs) + } ?: 0L, + effectiveModifiedAtMs = candidate.entry.effectiveModifiedAt() + ) + }, + recentCandidateCount = 1 + ) + logger( + "[QrcCurrentMatch] result=${if (decision.matched) "matched" else "rejected"} " + + "reason=${decision.reason} groupId=${decision.candidate?.groupId.orEmpty()}" + ) + val matchedId = decision.candidate?.groupId ?: return null + val (candidate, parsed) = parsedCandidates.first { it.first.entry.groupId == matchedId } + return candidate.copy(score = candidate.score + 50, parsed = parsed) + } + private fun decryptAndParseQrc( entry: QrcGroupIndexEntry, traceId: String = "" @@ -992,10 +1016,18 @@ class QrcLyricManager( private fun applyParsedResult( songKey: String, entry: QrcGroupIndexEntry, - parsed: ParsedQrc + parsed: ParsedQrc, + requestedTitle: String, + requestedArtist: String, + requestedAlbum: String ) { + val bound = parsed.copy( + title = requestedTitle.ifBlank { parsed.title }, + artist = requestedArtist.ifBlank { parsed.artist }, + album = requestedAlbum.ifBlank { parsed.album } + ) cachedGroupId = entry.groupId - cachedLines = parsed.lines + cachedLines = bound.lines removeUncertainCooldown(songKey, "qrc parsed") songGroupCache[songKey] = entry.groupId if (parsed.lines.isNotEmpty()) { @@ -1003,12 +1035,12 @@ class QrcLyricManager( cacheManager.save( ParsedLyric( songKey = songKey, - title = parsed.title, - artist = parsed.artist, - album = parsed.album, + title = bound.title, + artist = bound.artist, + album = bound.album, groupId = entry.groupId, qrcLastModified = entry.qrcFile?.lastModified() ?: 0L, - lines = parsed.lines.map { + lines = bound.lines.map { QrcLyricLine( timeMs = it.timeMs, text = it.text, @@ -1017,29 +1049,31 @@ class QrcLyricManager( translation = it.translation, romanization = it.romanization ) - } + }, + qrcPath = entry.qrcFile?.absolutePath.orEmpty(), + groupFingerprint = QrcLyricUtils.buildFingerprint(entry.toFileGroup()) ) ) val parsedSongKey = QrcLyricUtils.buildSongKey( - parsed.title, - parsed.artist, - parsed.album + bound.title, + bound.artist, + bound.album ) if (shouldSaveAlias( sourceSongKey = songKey, targetSongKey = parsedSongKey, - parsed = parsed + parsed = bound ) ) { cacheManager.save( ParsedLyric( songKey = parsedSongKey, - title = parsed.title, - artist = parsed.artist, - album = parsed.album, + title = bound.title, + artist = bound.artist, + album = bound.album, groupId = entry.groupId, qrcLastModified = entry.qrcFile?.lastModified() ?: 0L, - lines = parsed.lines.map { + lines = bound.lines.map { QrcLyricLine( timeMs = it.timeMs, text = it.text, @@ -1053,6 +1087,29 @@ class QrcLyricManager( ) cacheManager.saveAlias(songKey, parsedSongKey) } + persistentIndexManager.updateParsedMetadata( + entry.groupId, + ParsedLyric( + songKey = songKey, + title = bound.title, + artist = bound.artist, + album = bound.album, + groupId = entry.groupId, + qrcLastModified = entry.qrcFile?.lastModified() ?: 0L, + lines = bound.lines.map { + QrcLyricLine( + timeMs = it.timeMs, + text = it.text, + durationMs = it.durationMs, + words = it.words, + translation = it.translation, + romanization = it.romanization + ) + }, + qrcPath = entry.qrcFile?.absolutePath.orEmpty(), + groupFingerprint = QrcLyricUtils.buildFingerprint(entry.toFileGroup()) + ) + ) trimSongCaches() } } @@ -1170,12 +1227,15 @@ class QrcLyricManager( ) } - return ParsedQrc( - title = QrcLyricUtils.sanitizeMetadataTitle( + val metadataTitle = QrcLyricUtils.sanitizeMetadataTitle( metadata["ti"].orEmpty(), entry.groupId, logger - ), + ) + return ParsedQrc( + title = metadataTitle.ifBlank { + QrcCurrentTrackMatchPolicy.extractTitleCandidate(lines.map(LyricLine::text)) + }, artist = metadata["ar"].orEmpty(), album = metadata["al"].orEmpty(), lines = lines, @@ -1383,12 +1443,11 @@ class QrcLyricManager( private const val MIN_MATCH_SCORE = 100 private const val DECRYPT_CONFIRM_CANDIDATES = 3 private const val FALLBACK_DECRYPT_CANDIDATES = 3 - private const val RECENT_FALLBACK_DECRYPT_CANDIDATES = 10 private const val MAX_PARSED_QRC_CACHE_SIZE = 80 private const val MAX_SONG_CACHE_SIZE = 80 private const val MIN_HEX_LENGTH = 128 private const val UNCERTAIN_MISS_COOLDOWN_MS = 10 * 60_000L - private const val ACTIVE_SONG_UNCERTAIN_MISS_COOLDOWN_MS = 2 * 60_000L + private const val ACTIVE_SONG_UNCERTAIN_MISS_COOLDOWN_MS = 3_000L private val QRC_LYRIC_CONTENT_REGEX = Regex("""LyricContent\s*=\s*"([\s\S]*?)"""") diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt index fd7c72d..dd3c04b 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt @@ -3,6 +3,7 @@ package com.example.playeragent.media import android.content.Context import android.os.Environment import io.github.proify.qrckit.decrypt.QrcDecrypter +import org.json.JSONObject import java.io.File import java.nio.charset.Charset import java.security.MessageDigest @@ -103,7 +104,7 @@ enum class QrcWordTimingStatus { const val QRC_CACHE_SCHEMA_V1 = 1 const val QRC_CACHE_SCHEMA_V2 = 2 -const val QRC_CACHE_BUILD_VERSION = 3 +const val QRC_CACHE_BUILD_VERSION = 4 data class LyricCacheStats( val l1Hit: Long = 0, @@ -148,7 +149,13 @@ data class QrcGroupIndexEntry( val hasQrc: Boolean, val hasProducer: Boolean, val hasTranslrc: Boolean, - val hasRomaqrc: Boolean + val hasRomaqrc: Boolean, + val exSavingTime: Long = 0L, + val sidecarLastModified: Long = 0L, + val firstLyricTitleCandidate: String = "", + val lastLyricEndMs: Long = 0L, + val metadataComplete: Boolean = false, + val fingerprint: String = "" ) { fun toGroup(): QrcLyricManager.QrcGroup { return QrcLyricManager.QrcGroup( @@ -161,6 +168,20 @@ data class QrcGroupIndexEntry( lastModified = lastModified ) } + + fun toFileGroup(): QrcFileGroup { + return QrcFileGroup( + groupId = groupId, + qrcFile = qrcFile, + producerFile = producerFile, + exFile = exFile, + translrcFile = translrcFile, + romaqrcFile = romaqrcFile, + lastModified = lastModified + ) + } + + fun effectiveModifiedAt(): Long = maxOf(lastModified, sidecarLastModified, exSavingTime) } data class QrcPersistentIndexStatus( @@ -187,8 +208,10 @@ data class CurrentTrackSnapshot( val trackChangedAtMs: Long, val hasLyrics: Boolean, val positionMs: Long = 0L, + val positionAnchorElapsedMs: Long = 0L, val durationMs: Long = 0L, val isPlaying: Boolean = false, + val playbackSpeed: Float = 1f, val albumArtId: String? = null, val lyricSource: String = "NONE", val lyricLines: List = emptyList(), @@ -396,6 +419,37 @@ object QrcLyricUtils { return Triple(title, artist, "") } + fun readExSavingTime(file: File?): Long { + if (file == null || !file.canRead() || file.length() > MAX_EX_BYTES) { + return 0L + } + return try { + JSONObject(file.readText(Charsets.UTF_8)).optLong("saving_t", 0L) + } catch (_: Exception) { + 0L + } + } + + fun sidecarLastModified(group: QrcFileGroup): Long { + return listOfNotNull( + group.producerFile, + group.exFile, + group.translrcFile, + group.romaqrcFile + ).maxOfOrNull(File::lastModified) ?: 0L + } + + fun fingerprintKey(group: QrcFileGroup): String { + val value = buildFingerprint(group) + return listOf( + value.qrcLastModified, value.qrcSize, + value.producerLastModified, value.producerSize, + value.exLastModified, value.exSize, + value.translrcLastModified, value.translrcSize, + value.romaqrcLastModified, value.romaqrcSize + ).joinToString(":") + } + fun parseAuxiliaryLyricFile( file: File?, groupId: String, @@ -441,16 +495,26 @@ object QrcLyricUtils { return emptyMap() } val matched = mutableMapOf>() - auxiliaryLines.forEach { auxiliary -> - var bestIndex = -1 - var bestDelta = Long.MAX_VALUE - lines.forEachIndexed { index, line -> - val delta = kotlin.math.abs(line.timeMs - auxiliary.timeMs) - if (delta < bestDelta) { - bestDelta = delta - bestIndex = index - } + val sortedLines = lines.withIndex().sortedBy { it.value.timeMs } + var cursor = 0 + auxiliaryLines.sortedBy(QrcAuxiliaryLyricLine::timeMs).forEach { auxiliary -> + while (cursor + 1 < sortedLines.size && + sortedLines[cursor + 1].value.timeMs <= auxiliary.timeMs + ) { + cursor += 1 } + val nearby = listOfNotNull( + sortedLines.getOrNull(cursor), + sortedLines.getOrNull(cursor + 1), + sortedLines.getOrNull(cursor - 1) + ) + val best = nearby.minByOrNull { + kotlin.math.abs(it.value.timeMs - auxiliary.timeMs) + } + val bestIndex = best?.index ?: -1 + val bestDelta = best?.let { + kotlin.math.abs(it.value.timeMs - auxiliary.timeMs) + } ?: Long.MAX_VALUE if (bestIndex >= 0 && bestDelta <= AUXILIARY_ALIGN_TOLERANCE_MS) { val existing = matched[bestIndex] if (existing == null || bestDelta < existing.first) { @@ -473,7 +537,7 @@ object QrcLyricUtils { match.groupValues[2].trim() } val producerMetadata = parseProducerMetadata(group.producerFile) - val title = sanitizeMetadataTitle( + val metadataTitle = sanitizeMetadataTitle( metadata["ti"].orEmpty().ifBlank { producerMetadata.first }, group.groupId, logger @@ -505,6 +569,10 @@ object QrcLyricUtils { } }.distinctBy { it.timeMs to it.text } .sortedBy(QrcLyricLine::timeMs) + val inferredTitle = QrcCurrentTrackMatchPolicy.extractTitleCandidate( + baseLines.map(QrcLyricLine::text) + ) + val title = metadataTitle.ifBlank { inferredTitle } val auxiliary = applyAuxiliaryLyricsDetailed( groupId = group.groupId, lines = baseLines, @@ -748,19 +816,26 @@ object QrcLyricUtils { } val words = matches.mapIndexedNotNull { index, match -> - val relativeStartMs = match.groupValues.getOrNull(1)?.toLongOrNull() + val encodedStartMs = match.groupValues.getOrNull(1)?.toLongOrNull() ?: return@mapIndexedNotNull null val durationMs = match.groupValues.getOrNull(2)?.toLongOrNull() ?: return@mapIndexedNotNull null - val textStart = match.range.last + 1 - val textEnd = matches.getOrNull(index + 1)?.range?.first ?: body.length + // QQ QRC places the timing marker after the word it describes. The encoded start + // is normally an absolute playback position, although a few older files use a + // line-relative value. + val textStart = matches.getOrNull(index - 1)?.range?.last?.plus(1) ?: 0 + val textEnd = match.range.first val wordText = body.substring(textStart, textEnd) .replace(QRC_WORD_TIME_REGEX, "") if (wordText.isBlank()) { null } else { QrcLyricWord( - startMs = lineStartMs + relativeStartMs, + startMs = if (encodedStartMs < lineStartMs) { + lineStartMs + encodedStartMs + } else { + encodedStartMs + }, durationMs = durationMs, text = wordText ) @@ -832,6 +907,7 @@ object QrcLyricUtils { } private const val MAX_PRODUCER_BYTES = 8 * 1024 + private const val MAX_EX_BYTES = 16 * 1024 private const val MIN_HEX_LENGTH = 128 private const val AUXILIARY_ALIGN_TOLERANCE_MS = 800L private val GROUP_FILE_REGEX = diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcPersistentIndexManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcPersistentIndexManager.kt index 837d372..061a6e5 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcPersistentIndexManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcPersistentIndexManager.kt @@ -25,12 +25,16 @@ class QrcPersistentIndexManager( } } private val rebuilding = AtomicBoolean(false) + private val deferredRebuildScheduled = AtomicBoolean(false) private val lock = Any() + @Volatile + private var stopped = false private var entries: List = emptyList() private var metadata: IndexMetadata? = null private var dirty = false fun shutdown() { + stopped = true rebuildExecutor.shutdownNow() } @@ -137,6 +141,45 @@ class QrcPersistentIndexManager( logger("[QrcIndex] marked dirty groupId=${groupId.orEmpty()}") } + /** Incrementally enrich a group after its QRC has already been decrypted. */ + fun updateParsedMetadata(groupId: String, parsed: ParsedLyric) { + val currentEntries = getIndexForForeground() + val currentMetadata = synchronized(lock) { metadata } + if (currentEntries.isEmpty() || currentMetadata == null) { + markDirty(groupId) + rebuildAsync("parsed metadata unavailable groupId=$groupId") + return + } + val updatedEntries = currentEntries.map { entry -> + if (entry.groupId != groupId) { + entry + } else { + val group = entry.toFileGroup() + entry.copy( + title = parsed.title, + artist = parsed.artist, + album = parsed.album, + normalizedTitle = QrcLyricUtils.normalizeForMatch(parsed.title), + normalizedArtist = QrcLyricUtils.normalizeForMatch(parsed.artist), + normalizedAlbum = QrcLyricUtils.normalizeForMatch(parsed.album), + firstLyricTitleCandidate = QrcCurrentTrackMatchPolicy.extractTitleCandidate( + parsed.lines.map(QrcLyricLine::text) + ), + lastLyricEndMs = QrcCurrentTrackMatchPolicy.lastLineEndMs(parsed.lines), + metadataComplete = parsed.title.isNotBlank() && parsed.artist.isNotBlank(), + fingerprint = QrcLyricUtils.fingerprintKey(group), + exSavingTime = QrcLyricUtils.readExSavingTime(group.exFile), + sidecarLastModified = QrcLyricUtils.sidecarLastModified(group) + ) + } + } + synchronized(lock) { + entries = updatedEntries + } + saveIndex(currentMetadata, updatedEntries) + logger("[QrcIndex] incremental metadata updated groupId=$groupId lines=${parsed.lines.size}") + } + fun rebuildAsync() { rebuildAsync("requested") } @@ -152,6 +195,7 @@ class QrcPersistentIndexManager( ) if (token == null) { rebuilding.set(false) + scheduleDeferredRebuild(reason) return } logger("[QrcIndex] rebuild scheduled background reason=$reason") @@ -170,6 +214,32 @@ class QrcPersistentIndexManager( } } + private fun scheduleDeferredRebuild(reason: String) { + if (stopped || !deferredRebuildScheduled.compareAndSet(false, true)) return + val guard = MaintenanceGuard.snapshot() + val now = System.currentTimeMillis() + val delayMs = if (guard.realtimeWindowActive) { + (guard.protectedUntil - now + DEFERRED_REBUILD_GRACE_MS) + .coerceIn(DEFERRED_REBUILD_MIN_MS, DEFERRED_REBUILD_MAX_MS) + } else { + DEFERRED_REBUILD_MIN_MS + } + logger("[QrcIndex] rebuild deferred delayMs=$delayMs reason=$reason") + rebuildExecutor.execute { + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return@execute + } finally { + deferredRebuildScheduled.set(false) + } + if (!stopped) { + rebuildAsync("deferred $reason") + } + } + } + fun status(): QrcPersistentIndexStatus { synchronized(lock) { if (entries.isEmpty() && indexFile.exists()) { @@ -354,6 +424,7 @@ class QrcPersistentIndexManager( .toByteArray(Charsets.UTF_8) .size val metadata = QrcLyricUtils.parseProducerMetadata(group.producerFile) + val exSavingTime = QrcLyricUtils.readExSavingTime(group.exFile) QrcGroupIndexEntry( groupId = group.groupId, qrcFile = group.qrcFile, @@ -373,7 +444,11 @@ class QrcPersistentIndexManager( hasQrc = group.qrcFile != null, hasProducer = group.producerFile != null, hasTranslrc = group.translrcFile != null, - hasRomaqrc = group.romaqrcFile != null + hasRomaqrc = group.romaqrcFile != null, + exSavingTime = exSavingTime, + sidecarLastModified = QrcLyricUtils.sidecarLastModified(group), + metadataComplete = metadata.first.isNotBlank() && metadata.second.isNotBlank(), + fingerprint = QrcLyricUtils.fingerprintKey(group) ) } val metadata = IndexMetadata( @@ -404,7 +479,8 @@ class QrcPersistentIndexManager( metadata: IndexMetadata, entries: List ) { - try { + synchronized(indexWriteLock) { + try { val objectValue = JSONObject() .put("version", INDEX_VERSION) .put("dirPath", metadata.dirPath) @@ -417,9 +493,15 @@ class QrcPersistentIndexManager( array.put(entry.toJson()) } }) - indexFile.writeText(objectValue.toString(), Charsets.UTF_8) - } catch (exception: Exception) { - logger("[QrcIndex] save failed error=${exception.message}") + val temporary = File(indexFile.parentFile, "$INDEX_FILE_NAME.tmp") + temporary.writeText(objectValue.toString(), Charsets.UTF_8) + if (!temporary.renameTo(indexFile)) { + temporary.copyTo(indexFile, overwrite = true) + temporary.delete() + } + } catch (exception: Exception) { + logger("[QrcIndex] save failed error=${exception.message}") + } } } @@ -476,7 +558,13 @@ class QrcPersistentIndexManager( hasQrc = optBoolean("hasQrc"), hasProducer = optBoolean("hasProducer"), hasTranslrc = optBoolean("hasTranslrc"), - hasRomaqrc = optBoolean("hasRomaqrc") + hasRomaqrc = optBoolean("hasRomaqrc"), + exSavingTime = optLong("exSavingTime"), + sidecarLastModified = optLong("sidecarLastModified"), + firstLyricTitleCandidate = optString("firstLyricTitleCandidate"), + lastLyricEndMs = optLong("lastLyricEndMs"), + metadataComplete = optBoolean("metadataComplete"), + fingerprint = optString("fingerprint") ) } @@ -501,6 +589,12 @@ class QrcPersistentIndexManager( .put("normalizedAlbum", normalizedAlbum) .put("normalizedProducerText", normalizedProducerText) .put("producerTextLoaded", producerTextLoaded) + .put("exSavingTime", exSavingTime) + .put("sidecarLastModified", sidecarLastModified) + .put("firstLyricTitleCandidate", firstLyricTitleCandidate) + .put("lastLyricEndMs", lastLyricEndMs) + .put("metadataComplete", metadataComplete) + .put("fingerprint", fingerprint) } private fun String.toFileOrNull(): File? { @@ -576,11 +670,15 @@ class QrcPersistentIndexManager( } companion object { - private const val INDEX_VERSION = 1 + private const val INDEX_VERSION = 2 private const val INDEX_FILE_NAME = "QrcIndex.json" + private const val DEFERRED_REBUILD_MIN_MS = 500L + private const val DEFERRED_REBUILD_GRACE_MS = 150L + private const val DEFERRED_REBUILD_MAX_MS = 10_000L private const val MAX_PRODUCER_BYTES = 8 * 1024 private const val MAX_TOTAL_PRODUCER_TEXT_BYTES = 3 * 1024 * 1024 private val globalDirty = AtomicBoolean(false) + private val indexWriteLock = Any() private val GROUP_FILE_REGEX = Regex("""^(-?\d+)\.(qrc|producer|ex|translrc|romaqrc)$""") } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/TrackCapabilityTracker.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/TrackCapabilityTracker.kt index 09c5d73..5fc46d8 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/TrackCapabilityTracker.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/TrackCapabilityTracker.kt @@ -168,17 +168,25 @@ object TrackCapabilityTracker { hasIconUri: Boolean, hasAlbumArtUri: Boolean ) { + var changed = false update(key(trackId, protocolId)) { previous -> + changed = previous != null && ( + previous.metadataHasBitmap != hasBitmap || + previous.metadataHasIconUri != hasIconUri || + previous.metadataHasAlbumArtUri != hasAlbumArtUri + ) previous?.copy( metadataHasBitmap = hasBitmap, metadataHasIconUri = hasIconUri, metadataHasAlbumArtUri = hasAlbumArtUri ) } - log( - "[TrackCapability] media metadata bitmap=$hasBitmap " + - "iconUri=$hasIconUri albumArtUri=$hasAlbumArtUri" - ) + if (changed) { + log( + "[TrackCapability] media metadata bitmap=$hasBitmap " + + "iconUri=$hasIconUri albumArtUri=$hasAlbumArtUri" + ) + } } fun onLyricLookupStart(songKey: String, trackId: String = "") { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt index 3c12cb9..74cbdaf 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt @@ -20,10 +20,12 @@ import android.os.Looper import android.os.SystemClock import android.util.Log import com.example.playeragent.MainActivity +import com.example.playeragent.R import com.example.playeragent.StartupGuard import com.example.playeragent.ble.BleAdvertiserManager import com.example.playeragent.ble.BleGattServerManager import com.example.playeragent.ble.BleHealthSnapshot +import com.example.playeragent.ble.MultiControllerPolicy import com.example.playeragent.ble.BleHealthState import com.example.playeragent.history.PlaybackHistoryMonitor import com.example.playeragent.logging.LogConfig @@ -33,6 +35,10 @@ import com.example.playeragent.media.QrcIncrementalPrebuildManager import com.example.playeragent.media.QrcMaintenanceCoordinator import com.example.playeragent.media.QrcWatcherStatus import com.example.playeragent.media.MaintenanceTaskType +import com.example.playeragent.media.PlayerAgentExecutionHub +import com.example.playeragent.ui.ForegroundNotificationUiState +import com.example.playeragent.ui.PlayerAgentUiStateMapper +import com.example.playeragent.ui.resolve class PlayerAgentForegroundService : Service() { @@ -48,6 +54,7 @@ class PlayerAgentForegroundService : Service() { private var playbackHistoryMonitor: PlaybackHistoryMonitor? = null private var qrcIncrementalPrebuildManager: QrcIncrementalPrebuildManager? = null private var qrcDirectoryWatcher: QrcDirectoryWatcher? = null + private var executionHub: PlayerAgentExecutionHub? = null @Volatile private var serviceStopping = false @Volatile @@ -62,6 +69,7 @@ class PlayerAgentForegroundService : Service() { private var bleRecoveryCount = 0 @Volatile private var lastPublishedBleHealthState: BleHealthState? = null + private var lastNotificationUiState: ForegroundNotificationUiState? = null private val bluetoothStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) { @@ -80,8 +88,9 @@ class PlayerAgentForegroundService : Service() { StartupGuard.markServiceOnCreate(::log) running = true serviceStopping = false + executionHub = PlayerAgentExecutionHub("PlayerAgent") createNotificationChannel() - startForeground(NOTIFICATION_ID, createNotification("PlayerAgent is running")) + startForeground(NOTIFICATION_ID, createNotification(BleHealthState.STARTING)) registerReceiver( bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) @@ -120,6 +129,8 @@ class PlayerAgentForegroundService : Service() { advertiserManager = null gattServerManager?.close("service destroyed") gattServerManager = null + executionHub?.close() + executionHub = null publishBleHealthSnapshot("service destroyed") log("Foreground service stopped") flushLogBroadcasts() @@ -128,6 +139,11 @@ class PlayerAgentForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onTrimMemory(level: Int) { + gattServerManager?.onTrimMemory(level) + super.onTrimMemory(level) + } + private fun ensureBleStackStarted(reason: String, forceRebuild: Boolean = false) { log("[BLE-RECOVERY] ensure start reason=$reason") initializeBluetooth(reason = reason, forceRebuild = forceRebuild) @@ -140,7 +156,8 @@ class PlayerAgentForegroundService : Service() { } playbackHistoryMonitor = PlaybackHistoryMonitor( context = this, - logger = ::log + logger = ::log, + executionHub = executionHub ).also { it.start() } @@ -225,7 +242,11 @@ class PlayerAgentForegroundService : Service() { advertiserManager?.getAdvertisingState()?.name ?: "none" }, onAllClientsDisconnected = ::handleAllClientsDisconnected, - onPlaybackUiState = ::publishPlaybackUiState + onControllerConnectionCountChanged = ::handleControllerConnectionCountChanged, + onPlaybackUiState = ::publishPlaybackUiState, + executionHub = executionHub ?: PlayerAgentExecutionHub("PlayerAgent").also { + executionHub = it + } ) if (manager.start()) { gattServerManager = manager @@ -398,24 +419,40 @@ class PlayerAgentForegroundService : Service() { if (snapshot.subscribedCount > 0 && manager.notifyFailureCount() >= NOTIFY_FAILURE_RECOVERY_THRESHOLD ) { - manager.clearStaleSubscribers("notify_failures") - restartAdvertisingFromWatchdog("notify_failures") - recoverBleStack("notify_failures", respectCooldown = true) - return + val failingAddresses = manager.failingSubscriberAddresses( + NOTIFY_FAILURE_RECOVERY_THRESHOLD + ) + if (failingAddresses.isNotEmpty()) { + val isolated = manager.disconnectSubscribers( + failingAddresses, + "notify_failures" + ) + log( + "[BleHealth] watchdog action=isolate_unhealthy_controllers " + + "count=$isolated remaining=${snapshot.subscribedCount - isolated}" + ) + publishBleHealthSnapshot("unhealthy controller isolated") + return + } } - if (snapshot.subscribedCount > 0 && - (manager.hasSuccessHeartbeatOlderThan(SUBSCRIBED_STALE_RECOVERY_MS) || - manager.subscribedWithoutSuccessOlderThan(SUBSCRIBED_STALE_RECOVERY_MS)) - ) { - log("[BleHealth] state=SUSPECT reason=no_success_heartbeat ageMs>=$SUBSCRIBED_STALE_RECOVERY_MS") - recoverBleStack("subscribed_stale", respectCooldown = true) - return + if (snapshot.subscribedCount > 0) { + val probed = manager.probeStaleSubscribers( + staleAfterMs = SUBSCRIBED_STALE_PROBE_MS, + minimumProbeIntervalMs = SUBSCRIBED_PROBE_MIN_INTERVAL_MS + ) + if (probed > 0) { + log( + "[BleHealth] watchdog action=probe_silent_subscribers " + + "count=$probed ageMs>=$SUBSCRIBED_STALE_PROBE_MS" + ) + publishBleHealthSnapshot("silent subscribers probed") + return + } } if (snapshot.subscribedCount > 0 && - (manager.hasSuccessHeartbeatOlderThan(SUBSCRIBED_SUSPECT_MS) || - manager.subscribedWithoutSuccessOlderThan(SUBSCRIBED_SUSPECT_MS)) + manager.hasAnyStaleSubscriber(SUBSCRIBED_SUSPECT_MS) ) { log("[BleHealth] state=SUSPECT reason=no_success_heartbeat ageMs>=$SUBSCRIBED_SUSPECT_MS") publishBleHealthSnapshot("subscribed suspect") @@ -444,6 +481,7 @@ class PlayerAgentForegroundService : Service() { lastPublishedBleHealthState = snapshot.healthState log("[BleHealth] state=${snapshot.healthState} reason=${snapshot.reason ?: reason}") } + updateForegroundNotification(snapshot.healthState) return snapshot } @@ -547,6 +585,35 @@ class PlayerAgentForegroundService : Service() { ) } + private fun handleControllerConnectionCountChanged(connectedCount: Int) { + mainHandler.post { + if (serviceStopping || !bluetoothAvailable || bluetoothAdapter?.isEnabled != true) { + return@post + } + val advertiser = advertiserManager ?: return@post + when { + !MultiControllerPolicy.hasConnectionCapacity(connectedCount) -> { + log( + "[BLE-ADV] stop reason=controller capacity full " + + "connected=$connectedCount" + ) + advertiser.stopAdvertising() + } + connectedCount > 0 -> { + log( + "[BLE-ADV] refresh reason=controller capacity available " + + "connected=$connectedCount" + ) + // Some vendor stacks silently stop legacy connectable + // advertising after the first central connects. Refreshing + // only at the 0 -> 1 / 2 -> 1 boundary keeps the remaining + // slot discoverable without touching the GATT connection. + advertiser.restartAdvertising("controller capacity available") + } + } + } + } + private fun startQrcWatcher() { val manager = qrcIncrementalPrebuildManager ?: QrcIncrementalPrebuildManager( context = this, @@ -570,14 +637,17 @@ class PlayerAgentForegroundService : Service() { log("[LyricRetry] watcher retry skipped groups=${groups.size}") } } - } + }, + executionHub = executionHub ).also { qrcIncrementalPrebuildManager = it } val watcher = qrcDirectoryWatcher ?: QrcDirectoryWatcher( + context = this, incrementalPrebuildManager = manager, logger = ::log, - statusListener = ::publishQrcWatcherStatus + statusListener = ::publishQrcWatcherStatus, + executionHub = executionHub ).also { qrcDirectoryWatcher = it } @@ -607,7 +677,7 @@ class PlayerAgentForegroundService : Service() { } private fun refreshCurrentLyric() { - val handled = gattServerManager?.manualRefreshCurrentLyric() == true + val handled = gattServerManager?.refreshCurrentPlaybackAndLyric() == true if (handled) { log("[LyricRetry] manual refresh requested from service") } else { @@ -639,7 +709,8 @@ class PlayerAgentForegroundService : Service() { } } - private fun createNotification(contentText: String): Notification { + private fun createNotification(healthState: BleHealthState): Notification { + val presentation = PlayerAgentUiStateMapper.notification(healthState) val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { @@ -651,6 +722,14 @@ class PlayerAgentForegroundService : Service() { Intent(this, MainActivity::class.java), pendingIntentFlags ) + val recoverIntent = PendingIntent.getService( + this, + 1, + Intent(this, PlayerAgentForegroundService::class.java).apply { + action = ACTION_RECOVER_BLE_STACK + }, + pendingIntentFlags + ) val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Notification.Builder(this, CHANNEL_ID) @@ -660,13 +739,32 @@ class PlayerAgentForegroundService : Service() { return builder .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) - .setContentTitle("PlayerAgent") - .setContentText(contentText) + .setContentTitle(presentation.title.resolve(this)) + .setContentText(presentation.detail.resolve(this)) .setContentIntent(pendingIntent) + .addAction( + android.R.drawable.ic_popup_sync, + getString(R.string.notification_check_connection), + recoverIntent + ) + .addAction( + android.R.drawable.ic_menu_view, + getString(R.string.notification_open_app), + pendingIntent + ) .setOngoing(true) .build() } + private fun updateForegroundNotification(healthState: BleHealthState) { + if (!running || serviceStopping) return + val presentation = PlayerAgentUiStateMapper.notification(healthState) + if (presentation == lastNotificationUiState) return + lastNotificationUiState = presentation + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, createNotification(healthState)) + } + private fun log(message: String) { val fullMessage = "[PlayerAgent] $message" LogBuffer.append(fullMessage) @@ -797,7 +895,8 @@ class PlayerAgentForegroundService : Service() { private const val BLE_HEALTH_WATCHDOG_INTERVAL_MS = 5_000L private const val BLE_RECOVERY_COOLDOWN_MS = 30_000L private const val SUBSCRIBED_SUSPECT_MS = 30_000L - private const val SUBSCRIBED_STALE_RECOVERY_MS = 45_000L + private const val SUBSCRIBED_STALE_PROBE_MS = 45_000L + private const val SUBSCRIBED_PROBE_MIN_INTERVAL_MS = 30_000L private const val NOTIFY_FAILURE_RECOVERY_THRESHOLD = 3 @Volatile private var running = false diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/service/QQMusicLyricAccessibilityService.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/service/QQMusicLyricAccessibilityService.kt index 54b9035..722d982 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/QQMusicLyricAccessibilityService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/QQMusicLyricAccessibilityService.kt @@ -18,9 +18,27 @@ import com.example.playeragent.logging.LogBuffer import java.util.ArrayDeque import java.util.LinkedHashSet +internal object AccessibilityTraversalPolicy { + fun shouldTraverse( + nowElapsedMs: Long, + lastTraversalAtElapsedMs: Long, + treeDumpRequested: Boolean, + captureUntilElapsedMs: Long, + minimumIntervalMs: Long + ): Boolean { + if (!treeDumpRequested && nowElapsedMs >= captureUntilElapsedMs) { + return false + } + return treeDumpRequested || + nowElapsedMs - lastTraversalAtElapsedMs >= minimumIntervalMs + } +} + class QQMusicLyricAccessibilityService : AccessibilityService() { private var previousExceptionHandler: Thread.UncaughtExceptionHandler? = null + private var cachedCurrentMedia = CurrentMedia() + private var cachedCurrentMediaAtElapsedMs = 0L private val accessibilityExceptionHandler: Thread.UncaughtExceptionHandler = object : Thread.UncaughtExceptionHandler { @@ -80,8 +98,13 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { val now = SystemClock.elapsedRealtime() val shouldDumpTree = treeDumpRequested - if (!shouldDumpTree && - now - lastTraversalAtElapsedMs < MIN_TRAVERSAL_INTERVAL_MS + if (!AccessibilityTraversalPolicy.shouldTraverse( + nowElapsedMs = now, + lastTraversalAtElapsedMs = lastTraversalAtElapsedMs, + treeDumpRequested = shouldDumpTree, + captureUntilElapsedMs = diagnosticCaptureUntilElapsedMs, + minimumIntervalMs = MIN_TRAVERSAL_INTERVAL_MS + ) ) { return } @@ -292,8 +315,12 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { } private fun readCurrentMedia(): CurrentMedia { + val now = SystemClock.elapsedRealtime() + if (now - cachedCurrentMediaAtElapsedMs < MEDIA_CACHE_TTL_MS) { + return cachedCurrentMedia + } val manager = getSystemService(MediaSessionManager::class.java) - ?: return CurrentMedia() + ?: return cachedCurrentMedia val listenerComponent = ComponentName( this, PlayerNotificationListenerService::class.java @@ -305,7 +332,7 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { "[AccessibilityLyric] current media unavailable: " + "${throwable.message}" ) - return CurrentMedia() + return cachedCurrentMedia } val controller = controllers.firstOrNull { @@ -313,7 +340,7 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { it.playbackState?.state == PlaybackState.STATE_PLAYING } ?: controllers.firstOrNull { it.packageName == QQ_MUSIC_PACKAGE - } ?: return CurrentMedia() + } ?: return cachedCurrentMedia val metadata = controller.metadata return CurrentMedia( @@ -326,14 +353,13 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { album = metadata ?.getString(MediaMetadata.METADATA_KEY_ALBUM) .orEmpty() - ) + ).also { + cachedCurrentMedia = it + cachedCurrentMediaAtElapsedMs = now + } } private fun logImportant(message: String) { - try { - Log.i(TAG, message) - } catch (_: Throwable) { - } try { LogBuffer.append(message) } catch (_: Throwable) { @@ -437,6 +463,8 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { fun requestTreeDump(): Boolean { val service = activeInstance ?: return false + diagnosticCaptureUntilElapsedMs = + SystemClock.elapsedRealtime() + DIAGNOSTIC_CAPTURE_DURATION_MS service.requestAccessibilityTreeDump() return true } @@ -464,7 +492,12 @@ class QQMusicLyricAccessibilityService : AccessibilityService() { private const val MAX_CANDIDATE_LENGTH = 80 private const val MAX_TREE_NODE_COUNT = 300 private const val MAX_TREE_FIELD_LENGTH = 100 - private const val MIN_TRAVERSAL_INTERVAL_MS = 800L + @Volatile + private var diagnosticCaptureUntilElapsedMs = 0L + + private const val MIN_TRAVERSAL_INTERVAL_MS = 1_500L + private const val DIAGNOSTIC_CAPTURE_DURATION_MS = 15_000L + private const val MEDIA_CACHE_TTL_MS = 2_000L private const val TRUNCATED_SUFFIX = "..." private const val CRASH_LOG_CHUNK_LENGTH = 220 private val TIME_PATTERN = Regex("^\\d{1,2}:\\d{2}$") diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentNavigationView.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentNavigationView.kt new file mode 100644 index 0000000..cf30ba4 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentNavigationView.kt @@ -0,0 +1,438 @@ +package com.example.playeragent.ui + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import com.example.playeragent.R + +class PlayerAgentNavigationView( + private val context: Context, + private val onSetupAction: (SetupAction) -> Unit, + private val onSafeRepair: () -> Unit, + private val onRelinkLyrics: () -> Unit +) { + data class Bindings( + val root: View, + val statusTitle: TextView, + val statusDetail: TextView, + val serviceSummary: TextView, + val bleSummary: TextView, + val iPhoneSummary: TextView, + val currentAlbumArt: ImageView, + val currentAlbumArtStatus: TextView, + val currentSong: TextView, + val currentArtistAlbum: TextView, + val currentLyric: TextView, + val suggestedAction: Button, + val setupProgress: TextView, + val accessibilityStatus: TextView, + val linkHost: LinearLayout, + val lyricsHost: LinearLayout, + val artworkHost: LinearLayout, + val logsHost: LinearLayout + ) + + private val canvas = Color.rgb(6, 9, 14) + private val surface1 = Color.rgb(15, 21, 31) + private val surface2 = Color.rgb(24, 34, 48) + private val primary = Color.rgb(245, 247, 251) + private val secondary = Color.rgb(167, 176, 189) + private val quiet = Color.rgb(105, 116, 132) + private val healthy = Color.rgb(100, 217, 140) + private val warning = Color.rgb(242, 181, 109) + private val accent = Color.rgb(120, 219, 195) + + private val pageHost = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(canvas) + } + private val homePage = pageScroll() + private val setupPage = pageScroll() + private val diagnosticsPage = pageScroll() + private val setupRows = mutableListOf() + private val diagnosticHosts = mutableListOf() + + private val statusTitle = titleText(30f) + private val statusDetail = bodyText() + private val serviceSummary = summaryValue() + private val bleSummary = summaryValue() + private val iPhoneSummary = summaryValue() + private val setupProgress = bodyText() + private val accessibilityStatus = bodyText() + private val lyricReadinessSummary = summaryValue() + private val artworkReadinessSummary = summaryValue() + private val currentAlbumArt = ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + background = background(surface2, 18) + setImageResource(android.R.drawable.ic_media_play) + contentDescription = context.getString(R.string.home_current_playback) + } + private val currentAlbumArtStatus = bodyText() + private val currentSong = titleText(22f).apply { + text = context.getString(R.string.home_no_playback) + maxLines = 2 + ellipsize = android.text.TextUtils.TruncateAt.END + } + private val currentArtistAlbum = bodyText().apply { + maxLines = 2 + ellipsize = android.text.TextUtils.TruncateAt.END + } + private val currentLyric = bodyText().apply { + text = context.getString(R.string.home_no_lyric) + maxLines = 3 + } + private val suggestedAction = actionButton( + context.getString(R.string.setup_action), + onRelinkLyrics + ).apply { visibility = View.GONE } + private val linkHost = verticalHost() + private val lyricsHost = verticalHost() + private val artworkHost = verticalHost() + private val logsHost = verticalHost() + + fun build(): Bindings { + buildHome() + buildSetup() + buildDiagnostics() + + val navigation = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(dp(8), dp(6), dp(8), dp(8)) + setBackgroundColor(surface1) + } + navigation.addView(navButton(R.string.nav_home) { showPage(homePage) }, weighted()) + navigation.addView(navButton(R.string.nav_setup) { showPage(setupPage) }, weighted()) + navigation.addView(navButton(R.string.nav_diagnostics) { showPage(diagnosticsPage) }, weighted()) + + val root = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(canvas) + addView(pageHost, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f + )) + addView(navigation, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )) + } + showPage(homePage) + return Bindings( + root = root, + statusTitle = statusTitle, + statusDetail = statusDetail, + serviceSummary = serviceSummary, + bleSummary = bleSummary, + iPhoneSummary = iPhoneSummary, + currentAlbumArt = currentAlbumArt, + currentAlbumArtStatus = currentAlbumArtStatus, + currentSong = currentSong, + currentArtistAlbum = currentArtistAlbum, + currentLyric = currentLyric, + suggestedAction = suggestedAction, + setupProgress = setupProgress, + accessibilityStatus = accessibilityStatus, + linkHost = linkHost, + lyricsHost = lyricsHost, + artworkHost = artworkHost, + logsHost = logsHost + ) + } + + fun render(state: PlayerAgentProductUiState) { + statusTitle.text = state.statusTitle.resolve(context) + statusDetail.text = state.statusDetail.resolve(context) + serviceSummary.text = state.serviceSummary.resolve(context) + bleSummary.text = state.bleSummary.resolve(context) + iPhoneSummary.text = state.iPhoneSummary.resolve(context) + lyricReadinessSummary.text = state.lyricSummary.ifBlank { + context.getString(R.string.summary_unavailable) + } + artworkReadinessSummary.text = state.artworkSummary.resolve(context) + currentAlbumArtStatus.text = context.getString( + R.string.home_artwork_status, + state.artworkSummary.resolve(context) + ) + setupProgress.text = context.getString( + R.string.home_setup_progress, + state.completedSetupSteps, + state.setupSteps.size + ) + setupRows.zip(state.setupSteps).forEach { (row, step) -> + row.title.text = step.title.resolve(context) + row.detail.text = step.detail.resolve(context) + row.status.text = context.getString( + if (step.complete) R.string.setup_complete else R.string.setup_pending + ) + row.status.setTextColor(if (step.complete) healthy else warning) + row.action.visibility = if (step.complete) View.GONE else View.VISIBLE + row.action.setOnClickListener { onSetupAction(step.action) } + } + } + + fun showSetup() = showPage(setupPage) + + private fun buildHome() { + val content = homePage.getChildAt(0) as LinearLayout + addBrand(content) + content.addView(card().apply { + addView(statusTitle) + addView(statusDetail) + }) + + content.addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(summaryCard(R.string.home_service, serviceSummary), weighted(4)) + addView(summaryCard(R.string.home_ble, bleSummary), weighted(4)) + addView(summaryCard(R.string.home_iphone, iPhoneSummary), weighted()) + }) + + content.addView(card().apply { + addView(sectionTitle(R.string.home_current_playback)) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(currentAlbumArt, LinearLayout.LayoutParams(dp(112), dp(112)).apply { + marginEnd = dp(14) + }) + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(currentSong) + addView(currentArtistAlbum) + addView(currentLyric) + addView(currentAlbumArtStatus) + addView(suggestedAction) + }, weighted()) + }) + }) + + content.addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(summaryCard(R.string.home_lyrics, lyricReadinessSummary), weighted(4)) + addView(summaryCard(R.string.home_artwork, artworkReadinessSummary), weighted()) + }) + + content.addView(card().apply { + addView(setupProgress) + addView(actionButton(context.getString(R.string.home_open_setup), ::showSetup)) + }) + content.addView(actionButton(context.getString(R.string.home_check_repair), onSafeRepair).apply { + minHeight = dp(52) + background = background(accent, 14) + setTextColor(Color.rgb(6, 20, 18)) + }) + } + + private fun buildSetup() { + val content = setupPage.getChildAt(0) as LinearLayout + addBrand(content) + content.addView(titleText(24f).apply { text = context.getString(R.string.setup_title) }) + content.addView(bodyText().apply { text = context.getString(R.string.setup_subtitle) }) + repeat(4) { + val row = SetupRow( + title = titleText(17f), + detail = bodyText(), + status = bodyText(), + action = actionButton(context.getString(R.string.setup_action)) {} + ) + setupRows += row + content.addView(card().apply { + addView(row.title) + addView(row.detail) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(row.status, weighted()) + addView(row.action) + }) + }) + } + content.addView(card().apply { + addView(titleText(17f).apply { text = context.getString(R.string.setup_background_title) }) + addView(bodyText().apply { text = context.getString(R.string.setup_background_detail) }) + addView(accessibilityStatus) + }) + } + + private fun buildDiagnostics() { + val content = diagnosticsPage.getChildAt(0) as LinearLayout + addBrand(content) + content.addView(titleText(24f).apply { text = context.getString(R.string.diagnostics_title) }) + val tabs = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + listOf( + R.string.diagnostics_link to linkHost, + R.string.diagnostics_lyrics to lyricsHost, + R.string.diagnostics_artwork to artworkHost, + R.string.diagnostics_logs to logsHost + ).forEach { (label, host) -> + diagnosticHosts += host + tabs.addView(navButton(label) { showDiagnosticHost(host) }, weighted()) + } + content.addView(tabs) + diagnosticHosts.forEach { content.addView(it) } + showDiagnosticHost(linkHost) + } + + private fun showPage(page: ScrollView) { + pageHost.removeAllViews() + (page.parent as? LinearLayout)?.removeView(page) + pageHost.addView(page, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + )) + } + + private fun showDiagnosticHost(selected: LinearLayout) { + diagnosticHosts.forEach { it.visibility = if (it === selected) View.VISIBLE else View.GONE } + } + + private fun addBrand(parent: LinearLayout) { + parent.addView(titleText(26f).apply { text = context.getString(R.string.app_name) }) + parent.addView(bodyText().apply { text = context.getString(R.string.app_subtitle) }) + } + + private fun pageScroll(): ScrollView { + return ScrollView(context).apply { + setBackgroundColor(canvas) + isFillViewport = true + addView(verticalHost().apply { setPadding(dp(18), dp(20), dp(18), dp(30)) }) + } + } + + private fun verticalHost() = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + private fun card() = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + background = background(surface1, 18) + setPadding(dp(16), dp(15), dp(16), dp(15)) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { setMargins(0, dp(7), 0, dp(7)) } + } + + private fun summaryCard(label: Int, value: TextView) = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + background = background(surface1, 14) + setPadding(dp(10), dp(12), dp(10), dp(12)) + addView(TextView(context).apply { + text = context.getString(label) + textSize = 12f + setTextColor(quiet) + }) + addView(value) + } + + private fun titleText(size: Float) = TextView(context).apply { + textSize = size + setTextColor(primary) + setPadding(0, dp(3), 0, dp(5)) + } + + private fun bodyText() = TextView(context).apply { + textSize = 14f + setTextColor(secondary) + setPadding(0, dp(3), 0, dp(6)) + } + + private fun summaryValue() = TextView(context).apply { + textSize = 15f + setTextColor(primary) + maxLines = 2 + } + + private fun sectionTitle(label: Int) = titleText(18f).apply { text = context.getString(label) } + + private fun actionButton(label: String, action: () -> Unit) = Button(context).apply { + text = label + isAllCaps = false + minHeight = dp(48) + setTextColor(primary) + background = background(surface2, 12) + setOnClickListener { action() } + } + + private fun navButton(label: Int, action: () -> Unit) = actionButton( + context.getString(label), + action + ).apply { background = background(surface1, 12) } + + private fun weighted(marginEnd: Int = 0) = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { + this.marginEnd = dp(marginEnd) + } + + private fun background(color: Int, radiusDp: Int) = GradientDrawable().apply { + setColor(color) + cornerRadius = dp(radiusDp).toFloat() + } + + private fun dp(value: Int): Int = (value * context.resources.displayMetrics.density).toInt() + + private data class SetupRow( + val title: TextView, + val detail: TextView, + val status: TextView, + val action: Button + ) +} + +fun UiTextKey.resolve(context: Context): String = context.getString( + when (this) { + UiTextKey.STATUS_CONTROLLABLE -> R.string.status_controllable + UiTextKey.STATUS_CONTROLLABLE_DETAIL -> R.string.status_controllable_detail + UiTextKey.STATUS_RECOVERING -> R.string.status_recovering + UiTextKey.STATUS_RECOVERING_DETAIL -> R.string.status_recovering_detail + UiTextKey.STATUS_SETUP_REQUIRED -> R.string.status_setup_required + UiTextKey.STATUS_SETUP_REQUIRED_DETAIL -> R.string.status_setup_required_detail + UiTextKey.STATUS_ACTION_REQUIRED -> R.string.status_action_required + UiTextKey.STATUS_SERVICE_STOPPED_DETAIL -> R.string.status_service_stopped_detail + UiTextKey.STATUS_BLE_ERROR_DETAIL -> R.string.status_ble_error_detail + UiTextKey.STATUS_WAITING_IPHONE -> R.string.status_waiting_iphone + UiTextKey.STATUS_WAITING_IPHONE_DETAIL -> R.string.status_waiting_iphone_detail + UiTextKey.SUMMARY_RUNNING -> R.string.summary_running + UiTextKey.SUMMARY_NOT_RUNNING -> R.string.summary_not_running + UiTextKey.SUMMARY_NOT_STARTED -> R.string.summary_not_started + UiTextKey.SUMMARY_STARTING -> R.string.summary_starting + UiTextKey.SUMMARY_ADVERTISING -> R.string.summary_advertising + UiTextKey.SUMMARY_CONNECTED -> R.string.summary_connected + UiTextKey.SUMMARY_SUBSCRIBED -> R.string.summary_subscribed + UiTextKey.SUMMARY_HEALTHY -> R.string.summary_healthy + UiTextKey.SUMMARY_SUSPECT -> R.string.summary_suspect + UiTextKey.SUMMARY_RECOVERING -> R.string.summary_recovering + UiTextKey.SUMMARY_ERROR -> R.string.summary_error + UiTextKey.SUMMARY_CONTROLLABLE -> R.string.summary_controllable + UiTextKey.SUMMARY_CONNECTING -> R.string.summary_connecting + UiTextKey.SUMMARY_NOT_CONNECTED -> R.string.summary_not_connected + UiTextKey.ARTWORK_READY -> R.string.artwork_ready + UiTextKey.ARTWORK_LOADING -> R.string.artwork_loading + UiTextKey.ARTWORK_UNAVAILABLE -> R.string.summary_unavailable + UiTextKey.SETUP_NOTIFICATION_TITLE -> R.string.setup_notification_title + UiTextKey.SETUP_NOTIFICATION_DETAIL -> R.string.setup_notification_detail + UiTextKey.SETUP_FILE_TITLE -> R.string.setup_file_title + UiTextKey.SETUP_FILE_DETAIL -> R.string.setup_file_detail + UiTextKey.SETUP_ACCESSIBILITY_TITLE -> R.string.setup_accessibility_title + UiTextKey.SETUP_ACCESSIBILITY_DETAIL -> R.string.setup_accessibility_detail + UiTextKey.SETUP_RUNTIME_TITLE -> R.string.setup_runtime_title + UiTextKey.SETUP_RUNTIME_DETAIL -> R.string.setup_runtime_detail + UiTextKey.NOTIFICATION_CONTROLLABLE_TITLE -> R.string.notification_controllable_title + UiTextKey.NOTIFICATION_CONTROLLABLE_DETAIL -> R.string.notification_controllable_detail + UiTextKey.NOTIFICATION_RECOVERING_TITLE -> R.string.notification_recovering_title + UiTextKey.NOTIFICATION_RECOVERING_DETAIL -> R.string.notification_recovering_detail + UiTextKey.NOTIFICATION_STOPPED_TITLE -> R.string.notification_stopped_title + UiTextKey.NOTIFICATION_STOPPED_DETAIL -> R.string.notification_stopped_detail + UiTextKey.NOTIFICATION_ACTION_TITLE -> R.string.notification_action_title + UiTextKey.NOTIFICATION_ACTION_DETAIL -> R.string.notification_action_detail + UiTextKey.NOTIFICATION_WAITING_TITLE -> R.string.notification_waiting_title + UiTextKey.NOTIFICATION_WAITING_DETAIL -> R.string.notification_waiting_detail + } +) diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentUiStateMapper.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentUiStateMapper.kt new file mode 100644 index 0000000..acc70f5 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerAgentUiStateMapper.kt @@ -0,0 +1,232 @@ +package com.example.playeragent.ui + +import com.example.playeragent.ble.BleHealthState + +enum class SetupAction { + NONE, + OPEN_NOTIFICATION_ACCESS, + OPEN_FILE_ACCESS, + OPEN_ACCESSIBILITY, + REQUEST_RUNTIME_PERMISSIONS, + START_SERVICE +} + +enum class SafeRepairAction { + OPEN_NOTIFICATION_ACCESS, + OPEN_FILE_ACCESS, + OPEN_ACCESSIBILITY, + REQUEST_RUNTIME_PERMISSIONS, + START_SERVICE, + RECOVER_BLE +} + +data class SetupStepUiState( + val title: UiTextKey, + val detail: UiTextKey, + val complete: Boolean, + val action: SetupAction +) + +data class PlayerAgentProductUiState( + val statusTitle: UiTextKey, + val statusDetail: UiTextKey, + val serviceSummary: UiTextKey, + val bleSummary: UiTextKey, + val iPhoneSummary: UiTextKey, + val lyricSummary: String, + val artworkSummary: UiTextKey, + val setupSteps: List, + val safeRepairActions: List +) { + val completedSetupSteps: Int get() = setupSteps.count { it.complete } +} + +data class PlayerAgentUiInputs( + val healthState: BleHealthState, + val serviceRunning: Boolean, + val notificationAccess: Boolean, + val fileAccess: Boolean, + val accessibilityEnabled: Boolean, + val runtimePermissions: Boolean, + val hasPlayback: Boolean, + val lyricStatus: String, + val artworkStatus: String +) + +enum class UiTextKey { + STATUS_CONTROLLABLE, + STATUS_CONTROLLABLE_DETAIL, + STATUS_RECOVERING, + STATUS_RECOVERING_DETAIL, + STATUS_SETUP_REQUIRED, + STATUS_SETUP_REQUIRED_DETAIL, + STATUS_ACTION_REQUIRED, + STATUS_SERVICE_STOPPED_DETAIL, + STATUS_BLE_ERROR_DETAIL, + STATUS_WAITING_IPHONE, + STATUS_WAITING_IPHONE_DETAIL, + SUMMARY_RUNNING, + SUMMARY_NOT_RUNNING, + SUMMARY_NOT_STARTED, + SUMMARY_STARTING, + SUMMARY_ADVERTISING, + SUMMARY_CONNECTED, + SUMMARY_SUBSCRIBED, + SUMMARY_HEALTHY, + SUMMARY_SUSPECT, + SUMMARY_RECOVERING, + SUMMARY_ERROR, + SUMMARY_CONTROLLABLE, + SUMMARY_CONNECTING, + SUMMARY_NOT_CONNECTED, + ARTWORK_READY, + ARTWORK_LOADING, + ARTWORK_UNAVAILABLE, + SETUP_NOTIFICATION_TITLE, + SETUP_NOTIFICATION_DETAIL, + SETUP_FILE_TITLE, + SETUP_FILE_DETAIL, + SETUP_ACCESSIBILITY_TITLE, + SETUP_ACCESSIBILITY_DETAIL, + SETUP_RUNTIME_TITLE, + SETUP_RUNTIME_DETAIL, + NOTIFICATION_CONTROLLABLE_TITLE, + NOTIFICATION_CONTROLLABLE_DETAIL, + NOTIFICATION_RECOVERING_TITLE, + NOTIFICATION_RECOVERING_DETAIL, + NOTIFICATION_STOPPED_TITLE, + NOTIFICATION_STOPPED_DETAIL, + NOTIFICATION_ACTION_TITLE, + NOTIFICATION_ACTION_DETAIL, + NOTIFICATION_WAITING_TITLE, + NOTIFICATION_WAITING_DETAIL +} + +object PlayerAgentUiStateMapper { + fun map(inputs: PlayerAgentUiInputs): PlayerAgentProductUiState { + val steps = listOf( + SetupStepUiState( + title = UiTextKey.SETUP_NOTIFICATION_TITLE, + detail = UiTextKey.SETUP_NOTIFICATION_DETAIL, + complete = inputs.notificationAccess, + action = actionWhenMissing(inputs.notificationAccess, SetupAction.OPEN_NOTIFICATION_ACCESS) + ), + SetupStepUiState( + title = UiTextKey.SETUP_FILE_TITLE, + detail = UiTextKey.SETUP_FILE_DETAIL, + complete = inputs.fileAccess, + action = actionWhenMissing(inputs.fileAccess, SetupAction.OPEN_FILE_ACCESS) + ), + SetupStepUiState( + title = UiTextKey.SETUP_ACCESSIBILITY_TITLE, + detail = UiTextKey.SETUP_ACCESSIBILITY_DETAIL, + complete = inputs.accessibilityEnabled, + action = actionWhenMissing(inputs.accessibilityEnabled, SetupAction.OPEN_ACCESSIBILITY) + ), + SetupStepUiState( + title = UiTextKey.SETUP_RUNTIME_TITLE, + detail = UiTextKey.SETUP_RUNTIME_DETAIL, + complete = inputs.runtimePermissions, + action = actionWhenMissing(inputs.runtimePermissions, SetupAction.REQUEST_RUNTIME_PERMISSIONS) + ) + ) + + val status = when { + inputs.healthState == BleHealthState.CONTROLLABLE -> + UiTextKey.STATUS_CONTROLLABLE to UiTextKey.STATUS_CONTROLLABLE_DETAIL + inputs.healthState == BleHealthState.SUSPECT || + inputs.healthState == BleHealthState.RECOVERING -> + UiTextKey.STATUS_RECOVERING to UiTextKey.STATUS_RECOVERING_DETAIL + !inputs.runtimePermissions || !inputs.notificationAccess -> + UiTextKey.STATUS_SETUP_REQUIRED to UiTextKey.STATUS_SETUP_REQUIRED_DETAIL + !inputs.serviceRunning -> + UiTextKey.STATUS_ACTION_REQUIRED to UiTextKey.STATUS_SERVICE_STOPPED_DETAIL + inputs.healthState == BleHealthState.ERROR -> + UiTextKey.STATUS_ACTION_REQUIRED to UiTextKey.STATUS_BLE_ERROR_DETAIL + else -> + UiTextKey.STATUS_WAITING_IPHONE to UiTextKey.STATUS_WAITING_IPHONE_DETAIL + } + + return PlayerAgentProductUiState( + statusTitle = status.first, + statusDetail = status.second, + serviceSummary = if (inputs.serviceRunning) UiTextKey.SUMMARY_RUNNING else UiTextKey.SUMMARY_NOT_RUNNING, + bleSummary = bleSummary(inputs.healthState), + iPhoneSummary = when (inputs.healthState) { + BleHealthState.CONTROLLABLE -> UiTextKey.SUMMARY_CONTROLLABLE + BleHealthState.CONNECTED, BleHealthState.SUBSCRIBED -> UiTextKey.SUMMARY_CONNECTING + else -> UiTextKey.SUMMARY_NOT_CONNECTED + }, + lyricSummary = SonyPlayerUiState.lyricStatusText(inputs.lyricStatus), + artworkSummary = artworkSummary(inputs.artworkStatus), + setupSteps = steps, + safeRepairActions = safeRepairActions(inputs) + ) + } + + fun notification(healthState: BleHealthState): ForegroundNotificationUiState { + return when (healthState) { + BleHealthState.CONTROLLABLE -> ForegroundNotificationUiState( + UiTextKey.NOTIFICATION_CONTROLLABLE_TITLE, UiTextKey.NOTIFICATION_CONTROLLABLE_DETAIL + ) + BleHealthState.SUSPECT, BleHealthState.RECOVERING -> ForegroundNotificationUiState( + UiTextKey.NOTIFICATION_RECOVERING_TITLE, UiTextKey.NOTIFICATION_RECOVERING_DETAIL + ) + BleHealthState.SERVICE_STOPPED -> ForegroundNotificationUiState( + UiTextKey.NOTIFICATION_STOPPED_TITLE, UiTextKey.NOTIFICATION_STOPPED_DETAIL + ) + BleHealthState.ERROR -> ForegroundNotificationUiState( + UiTextKey.NOTIFICATION_ACTION_TITLE, UiTextKey.NOTIFICATION_ACTION_DETAIL + ) + else -> ForegroundNotificationUiState( + UiTextKey.NOTIFICATION_WAITING_TITLE, UiTextKey.NOTIFICATION_WAITING_DETAIL + ) + } + } + + private fun safeRepairActions(inputs: PlayerAgentUiInputs): List { + return buildList { + if (!inputs.runtimePermissions) add(SafeRepairAction.REQUEST_RUNTIME_PERMISSIONS) + if (!inputs.notificationAccess) add(SafeRepairAction.OPEN_NOTIFICATION_ACCESS) + if (!inputs.fileAccess) add(SafeRepairAction.OPEN_FILE_ACCESS) + if (!inputs.accessibilityEnabled) add(SafeRepairAction.OPEN_ACCESSIBILITY) + if (!inputs.serviceRunning) add(SafeRepairAction.START_SERVICE) + if (inputs.serviceRunning && inputs.healthState != BleHealthState.CONTROLLABLE) { + add(SafeRepairAction.RECOVER_BLE) + } + } + } + + private fun actionWhenMissing(complete: Boolean, action: SetupAction): SetupAction { + return if (complete) SetupAction.NONE else action + } + + private fun bleSummary(state: BleHealthState): UiTextKey { + return when (state) { + BleHealthState.SERVICE_STOPPED -> UiTextKey.SUMMARY_NOT_STARTED + BleHealthState.STARTING -> UiTextKey.SUMMARY_STARTING + BleHealthState.ADVERTISING -> UiTextKey.SUMMARY_ADVERTISING + BleHealthState.CONNECTED -> UiTextKey.SUMMARY_CONNECTED + BleHealthState.SUBSCRIBED -> UiTextKey.SUMMARY_SUBSCRIBED + BleHealthState.CONTROLLABLE -> UiTextKey.SUMMARY_HEALTHY + BleHealthState.SUSPECT -> UiTextKey.SUMMARY_SUSPECT + BleHealthState.RECOVERING -> UiTextKey.SUMMARY_RECOVERING + BleHealthState.ERROR -> UiTextKey.SUMMARY_ERROR + } + } + + private fun artworkSummary(status: String): UiTextKey { + val normalized = status.uppercase() + return when { + normalized == "LOADING" -> UiTextKey.ARTWORK_LOADING + normalized.startsWith("READY") || normalized == "PREVIEW" || normalized == "HQ" -> + UiTextKey.ARTWORK_READY + else -> UiTextKey.ARTWORK_UNAVAILABLE + } + } +} + +data class ForegroundNotificationUiState( + val title: UiTextKey, + val detail: UiTextKey +) diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerLogAdapter.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerLogAdapter.kt new file mode 100644 index 0000000..8beb7d1 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerLogAdapter.kt @@ -0,0 +1,38 @@ +package com.example.playeragent.ui + +import android.graphics.Color +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class PlayerLogAdapter : RecyclerView.Adapter() { + private val items = ArrayList(MAX_ROWS) + + fun replace(values: List) { + items.clear() + items.addAll(values.takeLast(MAX_ROWS)) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val density = parent.resources.displayMetrics.density + return LogViewHolder(TextView(parent.context).apply { + textSize = 11f + setTextColor(Color.rgb(215, 218, 224)) + setPadding((8 * density).toInt(), (3 * density).toInt(), (8 * density).toInt(), (3 * density).toInt()) + setTextIsSelectable(true) + }) + } + + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + holder.textView.text = items[position] + } + + override fun getItemCount(): Int = items.size + + class LogViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView) + + companion object { + const val MAX_ROWS = 300 + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ui/SonyPlayerUiState.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/SonyPlayerUiState.kt new file mode 100644 index 0000000..8b8b1fa --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ui/SonyPlayerUiState.kt @@ -0,0 +1,56 @@ +package com.example.playeragent.ui + +data class SonyPlayerUiState( + val service: ServiceState = ServiceState(), + val playback: PlaybackState = PlaybackState(), + val lyrics: LyricsState = LyricsState(), + val maintenance: MaintenanceState = MaintenanceState() +) { + data class ServiceState( + val running: Boolean = false, + val bleHealthy: Boolean = false, + val connectedDevices: Int = 0 + ) + + data class PlaybackState( + val title: String = "", + val artist: String = "", + val album: String = "", + val playing: Boolean = false + ) + + data class LyricsState( + val text: String = "", + val rawStatus: String = "", + val displayStatus: String = lyricStatusText(rawStatus), + val needsRelink: Boolean = lyricNeedsRelink(rawStatus) + ) + + data class MaintenanceState( + val busy: Boolean = false, + val label: String = "" + ) + + companion object { + fun lyricStatusText(status: String): String { + val value = status.lowercase() + return when { + value.isBlank() || value == "unknown" -> "等待播放状态" + "ready" in value || "loaded" in value || "sent" in value -> "歌词已就绪" + "loading" in value || "parsing" in value -> "正在解析歌词" + "waiting" in value || "cache" in value || "cooldown" in value -> + "等待 QQ 音乐生成歌词缓存" + "ambiguous" in value || "no safe" in value -> + "发现歌词文件,但无法安全关联" + "not found" in value || "no lyric" in value -> "当前歌曲暂无歌词" + "failed" in value || "error" in value || "decrypt" in value -> "歌词解析失败" + else -> "歌词状态更新中" + } + } + + fun lyricNeedsRelink(status: String): Boolean { + val value = status.lowercase() + return "ambiguous" in value || "no safe" in value || "failed" in value + } + } +} diff --git a/PlayerAgentApp/src/main/res/values/strings.xml b/PlayerAgentApp/src/main/res/values/strings.xml index 626ff0a..0dbf6df 100644 --- a/PlayerAgentApp/src/main/res/values/strings.xml +++ b/PlayerAgentApp/src/main/res/values/strings.xml @@ -1,4 +1,82 @@ Detect visible QQ Music lyric text for diagnostics. + PlayerAgent + Sony 音乐控制与同步 + 主页 + 设置 + 诊断 + Service + BLE + iPhone + 当前播放 + 等待 QQ 音乐播放 + %1$s · %2$s + 当前歌词:暂无歌词 + 歌词 + 封面 + 封面:%1$s + 设置完成度:%1$d / %2$d + 一键检查并修复 + 打开设置向导 + 设置向导 + 每一步只处理一项权限或系统能力。 + 已完成 + 待处理 + 去设置 + 后台运行 + 前台服务负责持续提供 BLE 控制;系统可能根据电池策略限制后台运行。 + 系统诊断 + 链路 + 歌词 + 封面 + 日志 + 可控制 + iPhone 已连接,可以控制当前播放。 + 正在恢复 BLE + 保留当前媒体状态,正在恢复控制链路。 + 需要完成设置 + 完成必要权限后即可稳定同步和控制。 + 需要处理 + 前台服务未运行,请启动服务。 + 蓝牙服务异常,请检查并恢复连接。 + 等待 iPhone 连接 + PlayerAgent 正在广播,等待 iPhone 控制端。 + 运行中 + 未运行 + 未启动 + 启动中 + 广播中 + 已连接 + 已订阅 + 健康 + 疑似断连 + 恢复中 + 异常 + 可控制 + 连接中 + 未连接 + 不可用 + 已就绪 + 正在加载 + 通知读取权限 + 用于获取 QQ 音乐播放状态和通知封面。 + 文件访问权限 + 用于读取 QQ 音乐本地 QRC 歌词缓存。 + 歌词辅助功能 + 用于诊断 QQ 音乐歌词页面,不替代播放状态。 + 蓝牙和通知权限 + 用于 BLE 广播、iPhone 控制和前台服务通知。 + PlayerAgent · 可控制 + iPhone 已连接 + PlayerAgent · 正在恢复连接 + 正在检查 BLE 控制链路 + PlayerAgent · 服务已停止 + 打开应用以重新启动 + PlayerAgent · 需要处理 + 检查蓝牙和应用权限 + PlayerAgent · 等待 iPhone + 服务运行中,等待控制端连接 + 检查连接 + 打开应用 diff --git a/PlayerAgentApp/src/main/res/values/styles.xml b/PlayerAgentApp/src/main/res/values/styles.xml index 9cf05c6..fbe4c98 100644 --- a/PlayerAgentApp/src/main/res/values/styles.xml +++ b/PlayerAgentApp/src/main/res/values/styles.xml @@ -1,8 +1,11 @@ - diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattPayloadPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattPayloadPolicyTest.kt new file mode 100644 index 0000000..5938082 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattPayloadPolicyTest.kt @@ -0,0 +1,22 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BleGattPayloadPolicyTest { + @Test + fun maximumAndroidMtuDoesNotExceedGattAttributeLimit() { + assertEquals(512, BleGattPayloadPolicy.maximumNotificationPayload(517)) + } + + @Test + fun regularMtuKeepsAttPayloadSize() { + assertEquals(244, BleGattPayloadPolicy.maximumNotificationPayload(247)) + assertEquals(20, BleGattPayloadPolicy.maximumNotificationPayload(23)) + } + + @Test + fun invalidMtuCannotProduceNegativePayload() { + assertEquals(0, BleGattPayloadPolicy.maximumNotificationPayload(2)) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt new file mode 100644 index 0000000..1fbc5f1 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt @@ -0,0 +1,163 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BleGattServerManagerPolicyTest { + @Test + fun playbackPollingSlowsOnlyAfterPausedStateIsKnown() { + assertEquals(1_000L, BleGattServerManager.autoPushPollIntervalMs(null)) + assertEquals(1_000L, BleGattServerManager.autoPushPollIntervalMs(true)) + assertEquals(5_000L, BleGattServerManager.autoPushPollIntervalMs(false)) + } + + @Test + fun completedAlbumArtCanBeRetransmittedAfterCooldownOrForced() { + assertTrue( + AlbumArtRequestPolicy.shouldAllowCompletedRequest( + lastCompletedAtMs = null, + nowMs = 100L, + forceRefresh = false + ) + ) + assertFalse( + AlbumArtRequestPolicy.shouldAllowCompletedRequest( + lastCompletedAtMs = 100L, + nowMs = 200L, + forceRefresh = false + ) + ) + assertTrue( + AlbumArtRequestPolicy.shouldAllowCompletedRequest( + lastCompletedAtMs = 100L, + nowMs = 200L, + forceRefresh = true + ) + ) + assertTrue( + AlbumArtRequestPolicy.shouldAllowCompletedRequest( + lastCompletedAtMs = 100L, + nowMs = 100L + AlbumArtRequestPolicy.COMPLETED_REQUEST_COOLDOWN_MS, + forceRefresh = false + ) + ) + assertTrue( + AlbumArtRequestPolicy.shouldResendOfferAfterRecovery( + recoveredFromUnavailable = true, + hadWaitingClientRequest = false + ) + ) + assertFalse( + AlbumArtRequestPolicy.shouldResendOfferAfterRecovery( + recoveredFromUnavailable = true, + hadWaitingClientRequest = true + ) + ) + assertFalse( + AlbumArtRequestPolicy.shouldResendOfferAfterRecovery( + recoveredFromUnavailable = false, + hadWaitingClientRequest = false + ) + ) + } + + @Test + fun subscribedLinkSilenceRequestsRateLimitedProbeInsteadOfRecovery() { + assertFalse( + BleSubscribedLinkPolicy.isActivityStale( + subscribed = true, + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + nowMs = 46_200L, + maxAgeMs = 45_000L + ) + ) + assertTrue( + BleSubscribedLinkPolicy.isActivityStale( + subscribed = true, + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + nowMs = 46_201L, + maxAgeMs = 45_000L + ) + ) + assertFalse( + BleSubscribedLinkPolicy.isActivityStale( + subscribed = false, + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + nowMs = 100_000L, + maxAgeMs = 45_000L + ) + ) + assertTrue( + BleSubscribedLinkPolicy.isWithoutSuccessStale( + subscribed = true, + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 0L, + lastNotifySuccessAtMs = 0L, + nowMs = 46_001L, + maxAgeMs = 45_000L + ) + ) + assertTrue( + BleSubscribedLinkPolicy.shouldSendProbe( + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + lastProbeAtMs = 0L, + nowMs = 46_201L, + staleAfterMs = 45_000L, + minimumProbeIntervalMs = 30_000L + ) + ) + assertFalse( + BleSubscribedLinkPolicy.shouldSendProbe( + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + lastProbeAtMs = 46_201L, + nowMs = 51_201L, + staleAfterMs = 45_000L, + minimumProbeIntervalMs = 30_000L + ) + ) + assertTrue( + BleSubscribedLinkPolicy.shouldSendProbe( + subscribedAtMs = 1_000L, + lastCommandSuccessAtMs = 1_100L, + lastNotifySuccessAtMs = 1_200L, + lastProbeAtMs = 46_201L, + nowMs = 76_201L, + staleAfterMs = 45_000L, + minimumProbeIntervalMs = 30_000L + ) + ) + } + + @Test + fun activeControllerDoesNotMaskAnotherSilentController() { + val nowMs = 80_000L + val lastNotifyByAddress = mapOf( + "ios" to 1_000L, + "android" to 79_500L + ) + val staleAddresses = lastNotifyByAddress.filter { (_, lastNotifyAtMs) -> + BleSubscribedLinkPolicy.isActivityStale( + subscribed = true, + subscribedAtMs = 500L, + lastCommandSuccessAtMs = 0L, + lastNotifySuccessAtMs = lastNotifyAtMs, + nowMs = nowMs, + maxAgeMs = 45_000L + ) + }.keys + + assertEquals(setOf("ios"), staleAddresses) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt new file mode 100644 index 0000000..346358e --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt @@ -0,0 +1,54 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BleLinkProfileTest { + @Test + fun failureAndSlowCallbackBackOffOnlyTheirPayloadKind() { + val profile = BleLinkProfile(initialMtu = 185) + profile.recordFailure(BleLinkProfile.PayloadKind.BINARY_LYRIC) + assertEquals(7L, profile.binaryDelayMs) + assertEquals(5L, profile.jsonDelayMs) + assertEquals(3L, profile.artworkDelayMs) + + profile.recordFailure(BleLinkProfile.PayloadKind.BINARY_ARTWORK) + assertEquals(8L, profile.artworkDelayMs) + repeat(20) { + profile.recordSuccess( + BleLinkProfile.PayloadKind.BINARY_ARTWORK, + callbackRttMs = 20 + ) + } + assertEquals(7L, profile.artworkDelayMs) + + profile.recordSuccess( + BleLinkProfile.PayloadKind.JSON_LYRIC, + callbackRttMs = 180 + ) + assertEquals(10L, profile.jsonDelayMs) + assertTrue(profile.ewmaCallbackRttMs > 20.0) + } + + @Test + fun fastSuccessWindowReducesDelayAndMtuChangeResetsProfile() { + val profile = BleLinkProfile(initialMtu = 23) + profile.recordFailure(BleLinkProfile.PayloadKind.JSON_LYRIC) + repeat(20) { + profile.recordSuccess( + BleLinkProfile.PayloadKind.JSON_LYRIC, + callbackRttMs = 20 + ) + } + assertEquals(9L, profile.jsonDelayMs) + + profile.updateMtu(247) + assertEquals(247, profile.mtu) + assertEquals(5L, profile.jsonDelayMs) + assertEquals(2L, profile.binaryDelayMs) + assertEquals(3L, profile.artworkDelayMs) + assertEquals(0L, profile.successCount) + assertEquals(0L, profile.failureCount) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt index c139741..d990f98 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt @@ -25,7 +25,7 @@ class BleNotifyQueuePolicyTest { } @Test - fun bulkYieldsToInteractiveEveryFourPacketsAndKeepsFifoPeers() { + fun bulkYieldsToInteractiveEveryFourPackets() { assertFalse( BleNotifyQueue.shouldYieldForPriorities( BleNotifyQueue.Priority.P2_BULK, @@ -49,10 +49,115 @@ class BleNotifyQueuePolicyTest { ) } + @Test + fun equalPriorityBulkTransfersRotateBetweenControllers() { + assertFalse( + BleNotifyQueue.shouldYieldToPeerAtSamePriority( + BleNotifyQueue.Priority.P2_BULK, + packetsSinceYield = 3, + anotherDeviceWaiting = true + ) + ) + assertTrue( + BleNotifyQueue.shouldYieldToPeerAtSamePriority( + BleNotifyQueue.Priority.P2_BULK, + packetsSinceYield = 4, + anotherDeviceWaiting = true + ) + ) + assertTrue( + BleNotifyQueue.shouldYieldToPeerAtSamePriority( + BleNotifyQueue.Priority.P3_BACKGROUND, + packetsSinceYield = 1, + anotherDeviceWaiting = true + ) + ) + assertFalse( + BleNotifyQueue.shouldYieldToPeerAtSamePriority( + BleNotifyQueue.Priority.P0_REALTIME, + packetsSinceYield = 10, + anotherDeviceWaiting = true + ) + ) + } + + @Test + fun interleavedStateCoalescingIsScopedByController() { + val iosKey = BleNotifyQueue.interleavedPacketKey("ios", "currentWord") + val androidKey = BleNotifyQueue.interleavedPacketKey("android", "currentWord") + + assertEquals("ios|currentWord", iosKey) + assertEquals("android|currentWord", androidKey) + assertFalse(iosKey == androidKey) + } + @Test fun commandResponseQuietWindowNeverReturnsNegativeDelay() { assertEquals(25L, BleNotifyQueue.remainingQuietDelayMs(1_025L, 1_000L)) assertEquals(0L, BleNotifyQueue.remainingQuietDelayMs(1_000L, 1_000L)) assertEquals(0L, BleNotifyQueue.remainingQuietDelayMs(999L, 1_000L)) } + + @Test + fun everyLongTransferChecksRealtimePacketsAfterEachChunk() { + listOf( + "albumArt", + "fullLyrics", + "lyricSecondary", + "remoteLog", + "mediaFieldDump", + "qrcDump", + "playHistory", + "playStats" + ).forEach { type -> + assertEquals(1, BleNotifyQueue.realtimeInterleaveIntervalFor(type)) + } + assertEquals(0, BleNotifyQueue.realtimeInterleaveIntervalFor("playbackState")) + } + + @Test + fun onlyReplaceableStateMessagesAreCoalesced() { + listOf( + "playbackState", + "currentWord", + "volumeState", + "albumArtOffer" + ).forEach { type -> + assertTrue(BleNotifyQueue.shouldCoalesceShortType(type)) + } + listOf( + "trackInfo", + "controlResponse", + "pong", + "fullLyricsBinaryStart", + "albumArtBinaryStart" + ).forEach { type -> + assertFalse(BleNotifyQueue.shouldCoalesceShortType(type)) + } + } + + @Test + fun terminalCallbackIsDeferredAndDispatchedExactlyOnce() { + val gate = BleQueueTerminalCallbackGate() + val pending = mutableListOf<() -> Unit>() + var callbackCount = 0 + + assertTrue( + gate.dispatch( + post = { pending += it }, + callback = { callbackCount += 1 } + ) + ) + assertEquals(0, callbackCount) + assertEquals(1, pending.size) + assertFalse( + gate.dispatch( + post = { pending += it }, + callback = { callbackCount += 100 } + ) + ) + + pending.single().invoke() + assertEquals(1, callbackCount) + } } diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleProtocolChaosTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleProtocolChaosTest.kt new file mode 100644 index 0000000..bdc82e7 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleProtocolChaosTest.kt @@ -0,0 +1,100 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class BleProtocolChaosTest { + @Test + fun randomLossDuplicateReorderAndRetryAlwaysReassembles() { + val random = Random(0x51514cL) + repeat(250) { iteration -> + val body = ByteArray(64 + random.nextInt(4_096)) + random.nextBytes(body) + val mtuPayload = 20 + random.nextInt(220) + val packets = BleTransferCodec.binaryChunks( + magic = 0xA2, + version = 1, + body = body, + maximumPayload = mtuPayload + ) + val order = packets.indices.shuffled(random) + val received = linkedMapOf() + order.forEach { index -> + if (random.nextDouble() >= 0.18) { + received[index] = payload(packets[index]) + if (random.nextDouble() < 0.20) { + received[index] = payload(packets[index]) + } + } + } + val missing = packets.indices.filter { received[it] == null } + val selected = BleTransferCodec.retryChunkIndexes( + totalChunks = packets.size, + missing = missing, + retryAll = missing.size > 32 + ) + selected.forEach { index -> + received[index] = payload(packets[index]) + } + + assertEquals( + "iteration=$iteration", + packets.size, + received.size + ) + val assembled = packets.indices + .flatMap { received.getValue(it).asIterable() } + .toByteArray() + assertArrayEquals("iteration=$iteration", body, assembled) + assertEquals( + BleTransferCodec.crc32(body), + BleTransferCodec.crc32(assembled) + ) + } + } + + @Test + fun crcCorruptionAndRapidGenerationChangesAreRejected() { + val original = ByteArray(512) { it.toByte() } + val corrupt = original.copyOf().also { it[211] = (it[211].toInt() xor 0x5a).toByte() } + assertFalse(BleTransferCodec.crc32(original) == BleTransferCodec.crc32(corrupt)) + + repeat(100) { generation -> + assertTrue( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-$generation", + transferGeneration = generation.toLong(), + currentTrackId = "qq-$generation", + currentGeneration = generation.toLong() + ) + ) + assertFalse( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-$generation", + transferGeneration = generation.toLong(), + currentTrackId = "qq-${generation + 1}", + currentGeneration = generation.toLong() + 1 + ) + ) + } + } + + private fun payload(packet: ByteArray): ByteArray { + return packet.copyOfRange(BleTransferCodec.HEADER_BYTES, packet.size) + } + + private fun IntRange.shuffled(random: Random): List { + return toMutableList().also { values -> + for (index in values.lastIndex downTo 1) { + val swap = random.nextInt(index + 1) + val value = values[index] + values[index] = values[swap] + values[swap] = value + } + } + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleTransferCodecTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleTransferCodecTest.kt index e10e26a..18fac8c 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleTransferCodecTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleTransferCodecTest.kt @@ -2,6 +2,7 @@ package com.example.playeragent.ble import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayOutputStream @@ -57,6 +58,42 @@ class BleTransferCodecTest { ) } + @Test + fun lyricRetryIdentityDoesNotDependOnArtworkAvailability() { + assertTrue( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-track-1", + transferGeneration = 7, + currentTrackId = "qq-track-1", + currentGeneration = 7 + ) + ) + assertTrue( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-track-1", + transferGeneration = 0, + currentTrackId = "qq-track-1", + currentGeneration = 9 + ) + ) + assertFalse( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-track-1", + transferGeneration = 7, + currentTrackId = "qq-track-2", + currentGeneration = 8 + ) + ) + assertFalse( + BleTransferCodec.isCurrentTransfer( + transferTrackId = "qq-track-1", + transferGeneration = 7, + currentTrackId = "qq-track-1", + currentGeneration = 8 + ) + ) + } + private fun inflate(input: ByteArray): ByteArray { val inflater = Inflater(false) return try { diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleV3SessionCoordinatorTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleV3SessionCoordinatorTest.kt new file mode 100644 index 0000000..73b19cd --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleV3SessionCoordinatorTest.kt @@ -0,0 +1,101 @@ +package com.example.playeragent.ble + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BleV3SessionCoordinatorTest { + + @Test + fun capabilityBitsAreNegotiatedAndStatusMetaRequiresLargePayload() { + assertEquals( + BleV3Features.ALL, + BleV3CapabilityPolicy.negotiateF3(3, BleV3Features.ALL, 247) + ) + assertEquals( + BleV3Features.STRUCTURED_ERROR_V1 or BleV3Features.MEDIA_LOAD_STATE_V1, + BleV3CapabilityPolicy.negotiateF3(3, BleV3Features.ALL, 246) + ) + assertEquals(0, BleV3CapabilityPolicy.negotiateF3(2, BleV3Features.ALL, 512)) + assertEquals(0, BleV3CapabilityPolicy.negotiateF3(3, null, 512)) + assertEquals(63, BleV3CapabilityPolicy.f2(true, true, true, true, true, true)) + } + + @Test + fun v2AckRemainsVerboseAndV3AckIsCompact() { + val v2 = BleCapabilitiesAckPolicy.build( + ConnectionCommandCoordinator.Capabilities(protocolVersion = 2, negotiated = true), + requestedF3Present = false, + sessionId = "1234abcd" + ) + assertEquals(2, v2.getInt("protocolVersion")) + assertTrue(v2.getBoolean("albumArtBinary")) + assertTrue(v2.getBoolean("fullLyricsZlib")) + assertTrue(v2.getBoolean("lyricWindow")) + assertTrue(v2.getBoolean("ping")) + assertTrue(v2.getBoolean("clockSyncV1")) + assertTrue(v2.getBoolean("transferRetry")) + assertFalse(v2.has("f2")) + assertFalse(v2.has("f3")) + assertFalse(v2.has("sid")) + assertFalse(v2.has("es")) + + val v3 = BleCapabilitiesAckPolicy.build( + ConnectionCommandCoordinator.Capabilities( + protocolVersion = 3, + f2 = 63, + f3 = 7, + negotiated = true + ), + requestedF3Present = true, + sessionId = "1234abcd" + ) + assertEquals(3, v3.getInt("protocolVersion")) + assertEquals(63, v3.getInt("f2")) + assertEquals(7, v3.getInt("f3")) + assertEquals("1234abcd", v3.getString("sid")) + assertFalse(v3.has("albumArtBinary")) + } + + @Test + fun stateDedupeAndEnqueueSequenceArePerDevice() { + val coordinator = BleV3SessionCoordinator("1234abcd") + assertTrue(coordinator.shouldSendMediaLoadState("A", "lyrics", "t", 1, "waiting", "qrc_pending")) + assertFalse(coordinator.shouldSendMediaLoadState("A", "lyrics", "t", 1, "waiting", "qrc_pending")) + assertTrue(coordinator.shouldSendMediaLoadState("A", "lyrics", "t", 1, "ready", "transfer_complete")) + assertTrue(coordinator.shouldSendMediaLoadState("B", "lyrics", "t", 1, "waiting", "qrc_pending")) + + val first = coordinator.decorate("A", JSONObject().put("type", "playbackState")) + val second = coordinator.decorate("A", JSONObject().put("type", "currentWord")) + assertEquals(1L, first.getLong("es")) + assertEquals(2L, second.getLong("es")) + assertEquals("1234abcd", second.getString("sid")) + } + + @Test + fun commandErrorKeepsOriginalSequence() { + val error = BleV3PayloadFactory.commandError( + seq = "42", + command = "GET_FULL_LYRICS", + domain = "lyrics", + code = "qrc_pending", + retryable = true + ) + assertEquals("42", error.getString("seq")) + assertEquals("GET_FULL_LYRICS", error.getString("cmd")) + assertTrue(error.getBoolean("retryable")) + + error + .put("retryAfterMs", 1_200) + .put("trackId", "track-1") + .put("generation", 7) + val decorated = BleV3SessionCoordinator("1234abcd").decorate("A", error) + assertEquals(1_200L, decorated.getLong("retryAfterMs")) + assertEquals("track-1", decorated.getString("trackId")) + assertEquals(7L, decorated.getLong("generation")) + assertEquals("1234abcd", decorated.getString("sid")) + assertEquals(1L, decorated.getLong("es")) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CompressedLyricsCacheTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CompressedLyricsCacheTest.kt new file mode 100644 index 0000000..d09594f --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CompressedLyricsCacheTest.kt @@ -0,0 +1,54 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CompressedLyricsCacheTest { + @Test + fun evictsLeastRecentlyUsedEntryByCountAndBytes() { + val cache = CompressedLyricsCache(maximumEntries = 2, maximumBytes = 8) + val first = key("one", generation = 1) + val second = key("two", generation = 1) + val third = key("three", generation = 1) + cache.put(first, entry(4)) + cache.put(second, entry(4)) + cache.get(first) + cache.put(third, entry(4)) + + assertEquals(2, cache.size()) + assertEquals(8, cache.bytes()) + assertNull(cache.get(second)) + assertEquals(4, cache.get(first)?.compressed?.size) + } + + @Test + fun generationAndFingerprintArePartOfIdentity() { + val cache = CompressedLyricsCache() + val original = key("song", generation = 7, fingerprint = 11) + cache.put(original, entry(5)) + + assertNull(cache.get(key("song", generation = 8, fingerprint = 11))) + assertNull(cache.get(key("song", generation = 7, fingerprint = 12))) + assertEquals(5, cache.get(original)?.compressed?.size) + } + + private fun key( + song: String, + generation: Long, + fingerprint: Long = 1 + ) = CompressedLyricsCache.Key( + songKey = song, + trackId = "track-$song", + generation = generation, + format = "zlib-json-v1", + contentFingerprint = fingerprint, + wordLineIndexes = "1,2" + ) + + private fun entry(bytes: Int) = CompressedLyricsCache.Entry( + compressed = ByteArray(bytes), + uncompressedSize = bytes * 2, + crc32 = "00000000" + ) +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt new file mode 100644 index 0000000..ea4e0df --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt @@ -0,0 +1,114 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.CurrentWordState +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CurrentWordPushEngineTest { + @Test + fun blocksSmallPositionRegressionAndAddsOrderingFence() { + var nowMs = 1_000L + var state = word(positionMs = 1_000L, wordIndex = 1) + val payloads = mutableListOf() + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + payloads += JSONObject(it) + true + }, + expectedGeneration = { 7L }, + currentWordState = { state }, + elapsedRealtime = { nowMs } + ) + + // Establish the new-track baseline after its short safety holdoff. + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + state = word(positionMs = 900L, wordIndex = 0) + nowMs += 100L + assertNull(engine.pushCurrentWord()) + + state = word(positionMs = 1_700L, wordIndex = 2) + nowMs += 100L + assertTrue(engine.pushCurrentWord() != null) + + assertEquals(2, payloads.size) + assertEquals(7L, payloads[0].getLong("generation")) + assertEquals(1L, payloads[0].getLong("seq")) + assertEquals(2L, payloads[1].getLong("seq")) + assertEquals(1_700L, payloads[1].getLong("position")) + } + + @Test + fun acceptsLargeBackwardSeekAsNewTimeline() { + var nowMs = 1_000L + var state = word(positionMs = 8_000L, wordIndex = 4) + var sent = 0 + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + sent += 1 + true + }, + expectedGeneration = { 7L }, + currentWordState = { state }, + elapsedRealtime = { nowMs } + ) + + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + state = word(positionMs = 2_000L, wordIndex = 1) + nowMs += 100L + assertTrue(engine.pushCurrentWord() != null) + assertEquals(2, sent) + } + + @Test + fun clockSyncPayloadAddsMonotonicSampleOnlyWhenNegotiated() { + var nowMs = 1_000L + val payloads = mutableListOf() + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + payloads += JSONObject(it) + true + }, + includeClockSyncFields = { true }, + expectedGeneration = { 7L }, + currentWordState = { + word(positionMs = 1_000L, wordIndex = 1).copy(sampleElapsedMs = 88_000L) + }, + elapsedRealtime = { nowMs } + ) + + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + assertEquals(88_000L, payloads.single().getLong("sampleMono")) + assertTrue(payloads.single().has("timestamp")) + assertTrue(payloads.single().toString().toByteArray().size <= 182) + } + + private fun word(positionMs: Long, wordIndex: Int): CurrentWordState { + return CurrentWordState( + trackId = "track-1", + trackGeneration = 7L, + lineIndex = 0, + wordIndex = wordIndex, + wordText = "word-$wordIndex", + wordStartMs = positionMs, + wordEndMs = positionMs + 400L, + hasWordTiming = true, + positionMs = positionMs, + timestampMs = 10_000L + positionMs + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt new file mode 100644 index 0000000..489decf --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt @@ -0,0 +1,208 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaTransferCoordinatorsTest { + @Test + fun advertisingCapacityAllowsExactlyTwoControllers() { + assertTrue(MultiControllerPolicy.hasConnectionCapacity(0)) + assertTrue(MultiControllerPolicy.hasConnectionCapacity(1)) + assertFalse(MultiControllerPolicy.hasConnectionCapacity(2)) + assertFalse(MultiControllerPolicy.hasConnectionCapacity(3)) + } + + @Test + fun oneFailingControllerIsIsolatedWithoutRebuildingHealthyConnection() { + assertTrue( + MultiControllerPolicy.shouldIsolateOnlyFailingControllers( + failingCount = 1, + subscribedCount = 2 + ) + ) + assertFalse( + MultiControllerPolicy.shouldIsolateOnlyFailingControllers( + failingCount = 2, + subscribedCount = 2 + ) + ) + assertFalse( + MultiControllerPolicy.shouldIsolateOnlyFailingControllers( + failingCount = 1, + subscribedCount = 1 + ) + ) + } + + @Test + fun lyricsRetryStateIsIndependentFromAlbumArtState() { + val lyrics = LyricsTransferCoordinator() + val artwork = AlbumArtTransferCoordinator() + val packet = BleNotifyQueue.Packet("chunk", byteArrayOf(1), 0) + val lyricTransfer = FullLyricsBinaryTransfer( + trackId = "track-a", + transferId = "lyrics-a", + generation = 9, + start = packet, + chunks = listOf(packet), + end = packet, + expiresAtMs = 2_000, + ownerAddress = "ios" + ) + lyrics.retain(lyricTransfer) + artwork.reset() + + assertEquals(lyricTransfer, lyrics.retained("ios", "lyrics-a")) + assertNull(lyrics.retained("android", "lyrics-a")) + } + + @Test + fun expiredArtworkEntriesArePrunedWithoutTouchingCurrentTransfer() { + val coordinator = AlbumArtTransferCoordinator() + val packet = BleNotifyQueue.Packet("chunk", byteArrayOf(1), 0) + coordinator.retain( + AlbumArtBinaryTransfer( + trackId = "old", + quality = AlbumArtQuality.PREVIEW, + transferId = "old-transfer", + start = packet, + chunks = listOf(packet), + end = packet, + expiresAtMs = 99, + ownerAddress = "ios" + ), + nowMs = 10 + ) + coordinator.retain( + AlbumArtBinaryTransfer( + trackId = "current", + quality = AlbumArtQuality.HQ, + transferId = "current-transfer", + start = packet, + chunks = listOf(packet), + end = packet, + expiresAtMs = 500, + ownerAddress = "android" + ), + nowMs = 100 + ) + + assertNull(coordinator.retained("ios", "old-transfer")) + assertNull(coordinator.retained("ios", "current-transfer")) + assertEquals( + "current", + coordinator.retained("android", "current-transfer")?.trackId + ) + } + + @Test + fun retryCleanupOnlyRemovesDisconnectedController() { + val lyrics = LyricsTransferCoordinator() + val packet = BleNotifyQueue.Packet("chunk", byteArrayOf(1), 0) + listOf("ios", "android").forEach { owner -> + lyrics.retain( + FullLyricsBinaryTransfer( + trackId = "same-track", + transferId = "shared-id", + generation = 3, + start = packet, + chunks = listOf(packet), + end = packet, + expiresAtMs = 5_000, + ownerAddress = owner + ) + ) + } + + lyrics.resetAddress("android") + + assertNull(lyrics.retained("android", "shared-id")) + assertEquals("same-track", lyrics.retained("ios", "shared-id")?.trackId) + } + + @Test + fun capabilityFallbackOnlyAppliesToCurrentSubscriptionGeneration() { + val coordinator = ConnectionCommandCoordinator() + val first = coordinator.beginNegotiation("ios", nowMs = 1_000L) + val second = coordinator.beginNegotiation("ios", nowMs = 1_100L) + + assertFalse(coordinator.useLegacyIfCurrent("ios", first)) + assertTrue(coordinator.useLegacyIfCurrent("ios", second)) + assertTrue(coordinator.capabilities("ios").negotiated) + + coordinator.accept( + "ios", + ConnectionCommandCoordinator.Capabilities( + protocolVersion = 2, + binaryAlbumArt = true, + fullLyricsZlib = true, + lyricWindow = true, + ping = true, + clockSyncV1 = true, + transferRetry = true, + negotiated = true + ) + ) + assertEquals(2, coordinator.capabilities("ios").protocolVersion) + assertTrue(coordinator.capabilities("ios").transferRetry) + assertTrue(coordinator.capabilities("ios").clockSyncV1) + assertFalse(coordinator.capabilities("android").negotiated) + } + + @Test + fun receivedCapabilitiesPreventFallbackWhileAckIsWaitingInWorkerQueue() { + val coordinator = ConnectionCommandCoordinator() + val generation = coordinator.beginNegotiation("ios", nowMs = 1_000L) + + coordinator.accept( + "ios", + ConnectionCommandCoordinator.Capabilities( + protocolVersion = 2, + binaryAlbumArt = true, + ping = true, + negotiated = true + ) + ) + + assertFalse(coordinator.useLegacyIfCurrent("ios", generation)) + assertEquals(2, coordinator.capabilities("ios").protocolVersion) + assertTrue(coordinator.capabilities("ios").binaryAlbumArt) + } + + @Test + fun disconnectingOneClientDoesNotResetAnotherClient() { + val coordinator = ConnectionCommandCoordinator() + coordinator.beginNegotiation("ios", nowMs = 1_000L) + coordinator.accept( + "ios", + ConnectionCommandCoordinator.Capabilities(protocolVersion = 2, ping = true) + ) + coordinator.beginNegotiation("android", nowMs = 1_100L) + coordinator.accept( + "android", + ConnectionCommandCoordinator.Capabilities( + protocolVersion = 2, + fullLyricsZlib = true + ) + ) + + coordinator.remove("android") + + assertTrue(coordinator.capabilities("ios").ping) + assertTrue(coordinator.capabilities("ios").negotiated) + assertFalse(coordinator.capabilities("android").negotiated) + } + + @Test + fun duplicateToggleFromAnotherControllerIsSuppressedWithinWindow() { + val gate = MultiControllerCommandGate(duplicateWindowMs = 300L) + + assertTrue(gate.shouldExecute("PLAY_PAUSE", "ios", nowMs = 1_000L)) + assertFalse(gate.shouldExecute("PLAY_PAUSE", "android", nowMs = 1_200L)) + assertTrue(gate.shouldExecute("PLAY_PAUSE", "ios", nowMs = 1_250L)) + assertTrue(gate.shouldExecute("PLAY_PAUSE", "android", nowMs = 1_700L)) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/history/PlaybackHistorySamplingPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/history/PlaybackHistorySamplingPolicyTest.kt new file mode 100644 index 0000000..d411a58 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/history/PlaybackHistorySamplingPolicyTest.kt @@ -0,0 +1,49 @@ +package com.example.playeragent.history + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackHistorySamplingPolicyTest { + @Test + fun adaptsIntervalToPlaybackState() { + assertEquals( + PlaybackHistorySamplingPolicy.IDLE_INTERVAL_MS, + PlaybackHistorySamplingPolicy.intervalMs(null, hasActiveSession = false) + ) + assertEquals( + PlaybackHistorySamplingPolicy.PLAYING_INTERVAL_MS, + PlaybackHistorySamplingPolicy.intervalMs(null, hasActiveSession = true) + ) + assertEquals( + PlaybackHistorySamplingPolicy.PLAYING_INTERVAL_MS, + PlaybackHistorySamplingPolicy.intervalMs(snapshot(playing = true), false) + ) + assertEquals( + PlaybackHistorySamplingPolicy.PAUSED_INTERVAL_MS, + PlaybackHistorySamplingPolicy.intervalMs(snapshot(playing = false), true) + ) + assertEquals( + PlaybackHistorySamplingPolicy.IDLE_INTERVAL_MS, + PlaybackHistorySamplingPolicy.intervalMs( + snapshot(playing = false, stopped = true), + true + ) + ) + } + + private fun snapshot( + playing: Boolean, + stopped: Boolean = false + ): FastPlaybackSnapshot { + return FastPlaybackSnapshot( + packageName = "com.tencent.qqmusic", + title = "song", + artist = "artist", + album = "album", + playing = playing, + stopped = stopped, + positionMs = 1_000L, + durationMs = 100_000L + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtPlaceholderPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtPlaceholderPolicyTest.kt new file mode 100644 index 0000000..e4003b1 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtPlaceholderPolicyTest.kt @@ -0,0 +1,89 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumArtPlaceholderPolicyTest { + @Test + fun qqFallbackIconIsRejected() { + val pixels = IntArray(24 * 24) { index -> + if (index < 464) { + argb(48, 48, 48) + } else { + argb(96, 96, 96) + } + } + + assertTrue( + AlbumArtPlaceholderPolicy.isLikelyPlaceholder( + sourceWidth = 228, + sourceHeight = 228, + pixels = pixels + ) + ) + } + + @Test + fun realColorArtworkAtSameSizeIsAccepted() { + val pixels = IntArray(24 * 24) { index -> + val row = index / 24 + val column = index % 24 + argb( + red = (row * 11) and 0xff, + green = (column * 9) and 0xff, + blue = ((row + column) * 7) and 0xff + ) + } + + assertFalse( + AlbumArtPlaceholderPolicy.isLikelyPlaceholder( + sourceWidth = 228, + sourceHeight = 228, + pixels = pixels + ) + ) + } + + @Test + fun detailedMonochromeArtworkIsNotMistakenForFallback() { + val grayLevels = intArrayOf(20, 48, 76, 104, 132, 160, 188, 216) + val pixels = IntArray(24 * 24) { index -> + val gray = grayLevels[index % grayLevels.size] + argb(gray, gray, gray) + } + + assertFalse( + AlbumArtPlaceholderPolicy.isLikelyPlaceholder( + sourceWidth = 228, + sourceHeight = 228, + pixels = pixels + ) + ) + } + + @Test + fun tinyOrEmptyArtworkIsRejected() { + assertTrue( + AlbumArtPlaceholderPolicy.isLikelyPlaceholder( + sourceWidth = 16, + sourceHeight = 16, + pixels = intArrayOf(argb(255, 0, 0)) + ) + ) + assertTrue( + AlbumArtPlaceholderPolicy.isLikelyPlaceholder( + sourceWidth = 228, + sourceHeight = 228, + pixels = intArrayOf() + ) + ) + } + + private fun argb(red: Int, green: Int, blue: Int): Int { + return (0xff shl 24) or + ((red and 0xff) shl 16) or + ((green and 0xff) shl 8) or + (blue and 0xff) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/LyricIncrementalIdentityTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/LyricIncrementalIdentityTest.kt index 67e36ec..1f35240 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/media/LyricIncrementalIdentityTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/LyricIncrementalIdentityTest.kt @@ -41,4 +41,28 @@ class LyricIncrementalIdentityTest { ) ) } + + @Test + fun `runtime track can seed an empty lyric manager`() { + assertTrue( + canAdoptIncrementalLyricTrack( + activeSongKey = null, + activeTrackId = "", + snapshotSongKey = "末班车|赵乃吉|末班车", + snapshotTrackId = "track-1" + ) + ) + } + + @Test + fun `runtime track never replaces an established active song`() { + assertFalse( + canAdoptIncrementalLyricTrack( + activeSongKey = "当前歌曲|歌手|专辑", + activeTrackId = "track-2", + snapshotSongKey = "末班车|赵乃吉|末班车", + snapshotTrackId = "track-1" + ) + ) + } } diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/MediaExecutionContextTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/MediaExecutionContextTest.kt new file mode 100644 index 0000000..fec2ba0 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/MediaExecutionContextTest.kt @@ -0,0 +1,24 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class MediaExecutionContextTest { + @Test + fun closeStopsAllBoundedExecutionLanes() { + val context = MediaExecutionContext("TestMedia") + val ran = CountDownLatch(3) + context.realtime.execute(ran::countDown) + context.foregroundIO.execute(ran::countDown) + context.maintenance.execute(ran::countDown) + + assertTrue(ran.await(2, TimeUnit.SECONDS)) + context.close() + assertTrue(context.awaitTermination(2_000)) + assertTrue(context.realtime.isShutdown) + assertTrue(context.foregroundIO.isShutdown) + assertTrue(context.maintenance.isShutdown) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlaybackPositionAnchorPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlaybackPositionAnchorPolicyTest.kt new file mode 100644 index 0000000..85b9317 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlaybackPositionAnchorPolicyTest.kt @@ -0,0 +1,47 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackPositionAnchorPolicyTest { + @Test + fun projectsFromMonotonicAnchorAtPlaybackSpeed() { + assertEquals( + 2_500L, + PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = 1_000L, + positionAnchorElapsedMs = 10_000L, + nowElapsedMs = 11_000L, + durationMs = 30_000L, + isPlaying = true, + playbackSpeed = 1.5f + ) + ) + } + + @Test + fun pausedPositionDoesNotAdvanceAndDurationIsClamped() { + assertEquals( + 9_000L, + PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = 9_000L, + positionAnchorElapsedMs = 1_000L, + nowElapsedMs = 5_000L, + durationMs = 10_000L, + isPlaying = false, + playbackSpeed = 1f + ) + ) + assertEquals( + 10_000L, + PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = 9_000L, + positionAnchorElapsedMs = 1_000L, + nowElapsedMs = 5_000L, + durationMs = 10_000L, + isPlaying = true, + playbackSpeed = 1f + ) + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlayerAgentExecutionHubTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlayerAgentExecutionHubTest.kt new file mode 100644 index 0000000..c7134c1 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlayerAgentExecutionHubTest.kt @@ -0,0 +1,34 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class PlayerAgentExecutionHubTest { + + @Test + fun lanesUseBoundedNamedThreadsAndCloseTogether() { + val hub = PlayerAgentExecutionHub("HubTest") + val names = mutableListOf() + val latch = CountDownLatch(4) + listOf( + hub.realtime, + hub.foregroundIO, + hub.scheduled, + hub.maintenance + ).forEach { executor -> + executor.execute { + synchronized(names) { names += Thread.currentThread().name } + latch.countDown() + } + } + assertTrue(latch.await(2, TimeUnit.SECONDS)) + assertEquals(4, names.size) + assertTrue(names.all { it.startsWith("HubTest-") }) + + hub.close() + assertTrue(hub.isShutdown()) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicyTest.kt new file mode 100644 index 0000000..05173a9 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicyTest.kt @@ -0,0 +1,109 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class QrcCurrentTrackMatchPolicyTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun emptyMetadataUsesFirstNonCreditLyricTitle() { + val title = QrcCurrentTrackMatchPolicy.extractTitleCandidate( + listOf("末班车(Live 版)", "词:马嵩惟", "曲:李伟菘", "空气中吹来一阵风") + ) + assertEquals("末班车(Live 版)", title) + + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track( + title = "末班车 (Live)", + artist = "赵乃吉", + durationMs = 260_000L + ), + candidates = listOf( + candidate(groupId = "-1453943104", lyricTitle = title, endMs = 248_000L) + ), + recentCandidateCount = 1 + ) + + assertTrue(decision.matched) + assertEquals("-1453943104", decision.candidate?.groupId) + } + + @Test + fun ambiguousMissingArtistCandidatesAreRejected() { + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track("末班车", "赵乃吉"), + candidates = listOf( + candidate("1", "末班车"), + candidate("2", "末班车(Live版)") + ) + ) + assertFalse(decision.matched) + assertEquals("ambiguous recent candidates", decision.reason) + } + + @Test + fun singleExactTitleAmongStartupCatchupBatchIsAccepted() { + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track("末班车 (Live)", "赵乃吉"), + candidates = listOf(candidate("-1453943104", "末班车(Live版)")), + recentCandidateCount = 3 + ) + assertTrue(decision.matched) + } + + @Test + fun artistOnlyCandidateIsRejected() { + val decision = QrcCurrentTrackMatchPolicy.evaluateRecent( + track = QrcCurrentTrackMatchPolicy.Track("末班车", "赵乃吉"), + candidates = listOf( + candidate("1", "另一首歌", metadataArtist = "赵乃吉") + ) + ) + assertFalse(decision.matched) + } + + @Test + fun exSavingTimeIsReadWithoutProducer() { + val file = temporaryFolder.newFile("-1453943104.ex") + file.writeText("""{"saving_t":1786544231343,"qrc_t":1753786731}""") + assertEquals(1_786_544_231_343L, QrcLyricUtils.readExSavingTime(file)) + } + + @Test + fun startupCatchupOnlyReturnsNewThreeGroups() { + val selected = QrcWatcherCatchupPolicy.selectChanged( + signals = listOf( + QrcGroupChangeSignal("1", 100L), + QrcGroupChangeSignal("2", 500L), + QrcGroupChangeSignal("3", 300L), + QrcGroupChangeSignal("4", 400L) + ), + watermarkMs = 100L, + limit = 3 + ) + assertEquals(listOf("2", "4", "3"), selected.map(QrcGroupChangeSignal::groupId)) + } + + private fun candidate( + groupId: String, + lyricTitle: String, + metadataArtist: String = "", + endMs: Long = 0L + ): QrcCurrentTrackMatchPolicy.Candidate { + return QrcCurrentTrackMatchPolicy.Candidate( + groupId = groupId, + metadataTitle = "", + metadataArtist = metadataArtist, + lyricTitleCandidate = lyricTitle, + lastLineEndMs = endMs, + effectiveModifiedAtMs = 1L + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcWordTimingParserTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcWordTimingParserTest.kt new file mode 100644 index 0000000..82de924 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcWordTimingParserTest.kt @@ -0,0 +1,33 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class QrcWordTimingParserTest { + @Test + fun `absolute QRC markers belong to the text before each marker`() { + val parsed = QrcLyricUtils.parseQrcLineBody( + lineStartMs = 100_208, + body = "We'll (100208,200)light (100408,230)up (100638,240)the (100878,210)sky(101088,1040)" + ) + + assertEquals("We'll light up the sky", parsed.text) + assertEquals(listOf("We'll ", "light ", "up ", "the ", "sky"), parsed.words.map { it.text }) + assertEquals( + listOf(100_208L, 100_408L, 100_638L, 100_878L, 101_088L), + parsed.words.map { it.startMs } + ) + assertEquals(listOf(200L, 230L, 240L, 210L, 1_040L), parsed.words.map { it.durationMs }) + } + + @Test + fun `relative QRC markers are normalized against line start`() { + val parsed = QrcLyricUtils.parseQrcLineBody( + lineStartMs = 10_000, + body = "你(0,120)好(120,180)" + ) + + assertEquals(listOf("你", "好"), parsed.words.map { it.text }) + assertEquals(listOf(10_000L, 10_120L), parsed.words.map { it.startMs }) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/service/AccessibilityTraversalPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/service/AccessibilityTraversalPolicyTest.kt new file mode 100644 index 0000000..65cd218 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/service/AccessibilityTraversalPolicyTest.kt @@ -0,0 +1,47 @@ +package com.example.playeragent.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AccessibilityTraversalPolicyTest { + @Test + fun remainsPassiveUntilExplicitDiagnosticCapture() { + assertFalse( + AccessibilityTraversalPolicy.shouldTraverse( + nowElapsedMs = 10_000L, + lastTraversalAtElapsedMs = 0L, + treeDumpRequested = false, + captureUntilElapsedMs = 0L, + minimumIntervalMs = 1_500L + ) + ) + assertTrue( + AccessibilityTraversalPolicy.shouldTraverse( + nowElapsedMs = 10_000L, + lastTraversalAtElapsedMs = 0L, + treeDumpRequested = false, + captureUntilElapsedMs = 20_000L, + minimumIntervalMs = 1_500L + ) + ) + assertFalse( + AccessibilityTraversalPolicy.shouldTraverse( + nowElapsedMs = 10_500L, + lastTraversalAtElapsedMs = 10_000L, + treeDumpRequested = false, + captureUntilElapsedMs = 20_000L, + minimumIntervalMs = 1_500L + ) + ) + assertTrue( + AccessibilityTraversalPolicy.shouldTraverse( + nowElapsedMs = 10_500L, + lastTraversalAtElapsedMs = 10_000L, + treeDumpRequested = true, + captureUntilElapsedMs = 0L, + minimumIntervalMs = 1_500L + ) + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/service/SonyPlayerUiStateTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/service/SonyPlayerUiStateTest.kt new file mode 100644 index 0000000..f604fb5 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/service/SonyPlayerUiStateTest.kt @@ -0,0 +1,114 @@ +package com.example.playeragent.service + +import com.example.playeragent.ble.BleHealthState +import com.example.playeragent.ui.PlayerAgentUiInputs +import com.example.playeragent.ui.PlayerAgentUiStateMapper +import com.example.playeragent.ui.SafeRepairAction +import com.example.playeragent.ui.SetupAction +import com.example.playeragent.ui.SonyPlayerUiState +import com.example.playeragent.ui.UiTextKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SonyPlayerUiStateTest { + + @Test + fun lyricInternalStatesAreMappedToClearChineseText() { + assertEquals( + "等待 QQ 音乐生成歌词缓存", + SonyPlayerUiState.lyricStatusText("waiting QQMusic lyric cache") + ) + assertEquals( + "发现歌词文件,但无法安全关联", + SonyPlayerUiState.lyricStatusText("no safe qrc candidate") + ) + assertEquals("正在解析歌词", SonyPlayerUiState.lyricStatusText("lyrics loading")) + } + + @Test + fun relinkActionOnlyAppearsForActionableFailure() { + assertTrue(SonyPlayerUiState.lyricNeedsRelink("qrc ambiguous")) + assertTrue(SonyPlayerUiState.lyricNeedsRelink("parse failed")) + assertFalse(SonyPlayerUiState.lyricNeedsRelink("waiting QQMusic lyric cache")) + assertFalse(SonyPlayerUiState.lyricNeedsRelink("ready")) + } + + @Test + fun identicalSlicesRemainEqualForUiDeduplication() { + val first = SonyPlayerUiState.LyricsState("line", "ready") + val second = SonyPlayerUiState.LyricsState("line", "ready") + assertEquals(first, second) + } + + @Test + fun controllableHealthMapsToClearHomeStatusAndSummaries() { + val state = PlayerAgentUiStateMapper.map(completeInputs(BleHealthState.CONTROLLABLE)) + assertEquals(UiTextKey.STATUS_CONTROLLABLE, state.statusTitle) + assertEquals(UiTextKey.SUMMARY_HEALTHY, state.bleSummary) + assertEquals(UiTextKey.SUMMARY_CONTROLLABLE, state.iPhoneSummary) + assertEquals(UiTextKey.ARTWORK_READY, state.artworkSummary) + assertEquals( + UiTextKey.ARTWORK_LOADING, + PlayerAgentUiStateMapper.map( + completeInputs(BleHealthState.CONTROLLABLE).copy(artworkStatus = "LOADING") + ).artworkSummary + ) + } + + @Test + fun setupProgressAndMissingPermissionActionAreDerivedFromRealInputs() { + val state = PlayerAgentUiStateMapper.map( + completeInputs(BleHealthState.ADVERTISING).copy(fileAccess = false) + ) + assertEquals(3, state.completedSetupSteps) + val fileStep = state.setupSteps.first { it.title == UiTextKey.SETUP_FILE_TITLE } + assertFalse(fileStep.complete) + assertEquals(SetupAction.OPEN_FILE_ACCESS, fileStep.action) + } + + @Test + fun oneClickRepairContainsOnlySafeNonDestructiveActions() { + val state = PlayerAgentUiStateMapper.map( + completeInputs(BleHealthState.ERROR).copy( + notificationAccess = false, + runtimePermissions = false, + serviceRunning = false + ) + ) + assertEquals( + listOf( + SafeRepairAction.REQUEST_RUNTIME_PERMISSIONS, + SafeRepairAction.OPEN_NOTIFICATION_ACCESS, + SafeRepairAction.START_SERVICE + ), + state.safeRepairActions + ) + assertFalse(state.safeRepairActions.any { it.name.contains("CLEAR") || it.name.contains("REBUILD") }) + } + + @Test + fun notificationPresentationIsDeduplicatableAndHealthSpecific() { + val first = PlayerAgentUiStateMapper.notification(BleHealthState.CONTROLLABLE) + val second = PlayerAgentUiStateMapper.notification(BleHealthState.CONTROLLABLE) + assertEquals(first, second) + assertEquals(UiTextKey.NOTIFICATION_CONTROLLABLE_TITLE, first.title) + assertEquals( + UiTextKey.NOTIFICATION_RECOVERING_TITLE, + PlayerAgentUiStateMapper.notification(BleHealthState.RECOVERING).title + ) + } + + private fun completeInputs(healthState: BleHealthState) = PlayerAgentUiInputs( + healthState = healthState, + serviceRunning = true, + notificationAccess = true, + fileAccess = true, + accessibilityEnabled = true, + runtimePermissions = true, + hasPlayback = true, + lyricStatus = "ready", + artworkStatus = "HQ" + ) +} diff --git a/README.md b/README.md index 00bbacf..18df55b 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@

- Turn a Sony Android Walkman into a Bluetooth music bridge for iPhone.
- 让 iPhone 实时获取 Sony Android 播放器上的 QQ 音乐歌词、封面和播放状态。 + Turn a Sony Android Walkman into a Bluetooth music source for iPhone and Android.
+ 让 iPhone 与 Android 控制端实时获取 Sony 播放器上的 QQ 音乐歌词、封面和播放状态。

@@ -17,11 +17,12 @@ Kotlin

-MusicBleController is a source-available iPhone companion for Sony Walkman devices -running Android. A lightweight Android agent reads QQ Music playback metadata, -QRC lyrics and album art, then streams them to a native SwiftUI app over -Bluetooth Low Energy (BLE). The iPhone app provides playback controls, synced -lyrics, artwork caching, Dynamic Island and Live Activities. +MusicBleController is a source-available, cross-platform companion for Sony +Walkman devices running Android. A lightweight agent reads QQ Music playback +metadata, QRC lyrics and album art, then streams them over Bluetooth Low Energy +(BLE) to either a native SwiftUI iPhone app or a Jetpack Compose Android +controller. Both clients provide playback controls, synced lyrics and artwork +caching; iPhone additionally supports Dynamic Island and Live Activities. > [!IMPORTANT] > The current implementation supports **QQ Music on Android only**. It is an @@ -30,10 +31,11 @@ lyrics, artwork caching, Dynamic Island and Live Activities. ## 中文简介 -MusicBleController 是面向 **Sony Android Walkman 与 iPhone** 的 QQ 音乐 -BLE 伴侣项目。Sony 端读取 QQ 音乐的播放状态、本地 QRC 逐字歌词和通知封面, -再通过低功耗蓝牙实时同步到 iPhone;iPhone 端负责播放器界面、歌词高亮、封面缓存、 -播放控制、锁屏实时活动和灵动岛展示,全程不依赖云端中转服务。 +MusicBleController 是面向 **Sony Android Walkman、iPhone 与 Android 控制端** +的 QQ 音乐 BLE 伴侣项目。Sony 端读取 QQ 音乐的播放状态、本地 QRC 逐字歌词和 +通知封面,再通过低功耗蓝牙实时同步到 SwiftUI iPhone App 或 Jetpack Compose +Android App;两种控制端都支持歌词高亮、封面缓存和播放控制,iPhone 还支持锁屏 +实时活动与灵动岛,全程不依赖云端中转服务。 - 支持 QQ 音乐 QRC 逐字歌词、翻译和罗马音数据; - 支持预览封面优先传输、高清封面后台升级和缓存复用; @@ -66,11 +68,15 @@ devices without a cloud service: - **Synced QQ Music lyrics** — parses encrypted QRC lyrics, including word-level timing, translation and romanization where available. +- **Automatic lyric timing** — maps Sony and iPhone monotonic clocks to compensate + BLE queueing and delivery latency while retaining a manual fine-tune control. - **Fast album art** — preview-first BLE delivery, HQ background upgrades, CRC validation and stale-while-revalidate caching. - **Remote playback controls** — play/pause, next, previous, seek and volume. -- **Native iPhone experience** — SwiftUI player, full-screen lyrics, lock-screen - Live Activity and Dynamic Island support. +- **Two native controllers** — SwiftUI on iPhone and Jetpack Compose on Android, + with full-screen lyrics, history, settings and diagnostics. +- **Native iPhone extras** — lock-screen Live Activity and Dynamic Island + support. - **Connection recovery** — capability negotiation, health checks, reconnect protection and old-client protocol fallback. - **Built for slow hardware** — priority-based BLE scheduling, compressed lyric @@ -84,14 +90,18 @@ devices without a cloud service: flowchart LR QQ["QQ Music on Sony Walkman"] --> Agent["PlayerAgent · Kotlin"] Agent -->|"BLE · lyrics / artwork / state"| iOS["iPhone app · SwiftUI"] + Agent -->|"BLE · lyrics / artwork / state"| Android["Android controller · Compose"] iOS -->|"BLE · controls / seek / volume"| Agent + Android -->|"BLE · controls / seek / volume"| Agent iOS --> Live["Live Activity / Dynamic Island"] ``` The Sony device is the authoritative media source. `PlayerAgentApp` reads -MediaSession, notifications and QQ Music's local QRC cache, while the iOS app -acts as the BLE controller, cache and presentation layer. No external server is -required. +MediaSession, notifications and QQ Music's local QRC cache. The iOS and Android +apps are interchangeable BLE controllers, caches and presentation layers; only +up to two controllers can stay connected to one Sony device at the same time. +Direct replies return to the requester while authoritative playback, lyric and +artwork state is synchronized to both controllers. No external server is required. ## Compatibility @@ -100,6 +110,7 @@ required. | Music source | QQ Music for Android | | Sony side | Android 6.0+; tested on Sony NW-WM1AM2 (Android 11) | | iPhone side | iOS 18.0+ with Bluetooth enabled | +| Android controller | Android 6.0+ with BLE; Android 12+ requires Nearby Devices permission | | Development | Android Studio/JDK and Xcode with an Apple Development team | Other Android-based Sony Walkman models may work, but hardware and firmware @@ -120,7 +131,19 @@ Install `PlayerAgentApp/build/outputs/apk/debug/PlayerAgentApp-debug.apk` on the Sony player. Grant notification access, enable the required accessibility service, then start the PlayerAgent foreground service. -### 2. Build the iPhone app +### 2. Build an Android controller + +```bash +bash gradlew :ControllerApp:assembleDebug +``` + +Install `ControllerApp/build/outputs/apk/debug/ControllerApp-debug.apk` on an +Android phone. Grant Bluetooth and notification permissions, open the app, and +connect to `SonyPlayerAgent`. The controller includes a Compose player, +word-synced and full-screen lyrics, Preview/HQ artwork, playback history, +statistics, settings, MediaStyle background controls and diagnostics. + +### 3. Build the iPhone app 1. Open `IOSBleFeasibility/IOSBleFeasibility.xcodeproj` in Xcode. 2. Select your Apple Development team for the app and Live Activity extension. @@ -144,15 +167,17 @@ For deeper implementation and troubleshooting details, see: |---|---| | `PlayerAgentApp/` | Sony/Android media agent and BLE GATT server | | `IOSBleFeasibility/` | Native iPhone app and Live Activity extension | -| `ControllerApp/` | Legacy Android controller, retained for compatibility | +| `ControllerApp/` | Jetpack Compose Android BLE V2 controller and foreground connection service | | `docs/` | Protocol and architecture documentation | | `tools/` | iOS, Android and cross-device smoke tests | ## Validation ```bash -# Android unit tests and debug build -bash gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug +# Android unit tests and debug builds +bash gradlew \ + :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug \ + :ControllerApp:testDebugUnitTest :ControllerApp:assembleDebug # Installed-device quick smoke tests ./tools/smoke/run_all_smoke_tests.sh --quick --json diff --git a/README.zh-CN.md b/README.zh-CN.md index 36a3024..6ec1ae1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,8 +6,8 @@

- 让 iPhone 实时获取 Sony Android Walkman 上的 QQ 音乐歌词、封面和播放状态。
- 一个不依赖云端中转的 Sony 播放器 × iPhone 蓝牙音乐伴侣。 + 让 iPhone 与 Android 控制端实时获取 Sony Walkman 上的 QQ 音乐歌词、封面和播放状态。
+ 一个不依赖云端中转的 Sony 播放器跨平台蓝牙音乐伴侣。

@@ -17,11 +17,12 @@ Kotlin

-MusicBleController 是一个面向 Sony Android Walkman 和 iPhone 的 QQ 音乐 -状态同步与远程控制项目。运行在 Sony 播放器上的轻量 Android 服务负责读取 -QQ 音乐的 MediaSession、通知封面和本地 QRC 歌词,再通过低功耗蓝牙(BLE) -传输到原生 SwiftUI iPhone App。iPhone 可以显示逐字歌词、歌曲封面和播放进度, -也可以控制播放、切歌、进度和音量,并在锁屏实时活动与灵动岛中展示当前歌曲。 +MusicBleController 是一个面向 Sony Android Walkman、iPhone 和 Android 手机的 +QQ 音乐状态同步与远程控制项目。运行在 Sony 播放器上的轻量 Android 服务负责读取 +QQ 音乐的 MediaSession、通知封面和本地 QRC 歌词,再通过低功耗蓝牙(BLE)传输到 +原生 SwiftUI iPhone App 或 Jetpack Compose Android App。两种控制端都可以显示 +逐字歌词、歌曲封面和播放进度,也可以控制播放、切歌、进度和音量;iPhone 还支持 +锁屏实时活动与灵动岛。 > [!IMPORTANT] > 当前版本只支持 **Android 版 QQ 音乐**,不支持 Apple Music、Spotify、网易云音乐 @@ -56,9 +57,12 @@ Sony 的 Android Walkman 可以安装 QQ 音乐,但它的播放状态、QRC 尽可能及时和稳定地展示当前歌曲: - **QQ 音乐逐字歌词**:解析本地加密 QRC 歌词,支持逐字时间、翻译和罗马音数据; +- **自动歌词校时**:使用 Sony/iPhone 单调时钟映射补偿 BLE 排队和传输延迟,并保留人工微调; - **快速封面显示**:优先发送小型预览图,再在后台升级高清封面; - **完整播放控制**:支持播放、暂停、上一首、下一首、进度和音量; -- **原生 iPhone 体验**:使用 SwiftUI、ActivityKit、锁屏实时活动和灵动岛; +- **双原生控制端**:iPhone 使用 SwiftUI,Android 使用 Jetpack Compose,均支持 + 主播放器、完整歌词、历史、设置与诊断; +- **iPhone 专属能力**:使用 ActivityKit 提供锁屏实时活动和灵动岛; - **弱性能设备优化**:使用可抢占 BLE 队列、压缩歌词、索引缓存和分级封面; - **兼容与恢复**:支持新旧协议协商、健康探测、有限重试和自动重连; - **可诊断与可测试**:两端均提供诊断日志,并包含 Android、iOS 和跨端冒烟测试。 @@ -69,17 +73,22 @@ Sony 的 Android Walkman 可以安装 QQ 音乐,但它的播放状态、QRC flowchart LR QQ["Sony Walkman 上的 QQ 音乐"] --> Agent["PlayerAgent · Kotlin"] Agent -->|"BLE · 歌词 / 封面 / 播放状态"| iOS["iPhone App · SwiftUI"] + Agent -->|"BLE · 歌词 / 封面 / 播放状态"| Android["Android App · Compose"] iOS -->|"BLE · 播放 / 进度 / 音量控制"| Agent + Android -->|"BLE · 播放 / 进度 / 音量控制"| Agent iOS --> Live["锁屏实时活动 / 灵动岛"] ``` -Sony 端是播放状态、歌词和封面的权威数据源,iPhone 端是控制、缓存和展示端: +Sony 端是播放状态、歌词和封面的权威数据源,iPhone 与 Android App 是对等的 +控制、缓存和展示端;同一台 Sony 最多可同时连接两个控制端。查询和大文件传输只返回 +给发起设备,播放状态、歌曲信息、逐字进度、音量和封面 Offer 会同步给两个控制端: 1. Sony `PlayerAgentApp` 启动 BLE GATT Server,并监听 QQ 音乐状态与本地 QRC 文件; -2. iPhone `BLETestManager` 扫描并连接 `SonyPlayerAgent`; -3. iPhone 通过命令特征发送 JSON 控制命令; +2. iPhone `BLETestManager` 或 Android `ControllerConnectionService` 扫描并连接 + `SonyPlayerAgent`; +3. 控制端通过命令特征发送 JSON 控制命令; 4. Sony 通过状态特征返回播放状态、歌词窗口、完整歌词和二进制封面分包; -5. iPhone 合并传输结果,更新播放器、歌词、缓存和 Live Activity。 +5. 控制端合并传输结果并更新播放器、歌词和缓存;iPhone 同时更新 Live Activity。 固定 BLE UUID、已有控制命令和旧协议兼容路径会被保留,新功能只在双方确认能力后启用。 @@ -105,6 +114,7 @@ Sony 端是播放状态、歌词和封面的权威数据源,iPhone 端是控 - 控制响应、播放状态和当前词可以抢占完整歌词、高清封面等大任务; - 播放中和暂停中采用不同的静默探测周期,避免正常状态下频繁重连; - 新协议使用轻量 PING/PONG,旧 Sony 版本自动回退到原有状态请求; +- iPhone 在连接后主动进行低 RTT 多样本校时,播放位置按消息采样时刻自动追平,严重积压的旧歌词不会覆盖当前进度; - 连续探测失败后才执行硬重连,明确断开事件仍会立即处理。 ## 兼容性 @@ -114,6 +124,7 @@ Sony 端是播放状态、歌词和封面的权威数据源,iPhone 端是控 | 音乐来源 | Android 版 QQ 音乐 | | Sony 端 | Android 6.0+;已在 Sony NW-WM1AM2(Android 11)验证 | | iPhone 端 | iOS 18.0+,需要开启蓝牙 | +| Android 控制端 | Android 6.0+,需要 BLE;Android 12+ 需要“附近的设备”权限 | | Android 开发 | Android Studio、JDK 和 Android SDK | | iOS 开发 | Xcode、Apple Development Team 和实体 iPhone | @@ -150,7 +161,24 @@ PlayerAgentApp/build/outputs/apk/debug/PlayerAgentApp-debug.apk 4. 按系统版本授予歌词缓存所需的存储访问权限; 5. 打开 Player Agent,并确认前台服务和 BLE 广播已经运行。 -### 2. 构建 iPhone App +### 2. 构建 Android 控制端 + +```bash +bash gradlew :ControllerApp:assembleDebug +``` + +生成的 APK 位于: + +```text +ControllerApp/build/outputs/apk/debug/ControllerApp-debug.apk +``` + +安装到 Android 手机后允许蓝牙和通知权限,再连接 `SonyPlayerAgent`。Android +控制端采用 BLE V2、Compose、ViewModel、StateFlow 和前台连接服务,提供逐字/完整 +歌词、翻译与罗马音、Preview/HQ 封面、播放历史与统计、设置、系统健康、歌曲/歌词 +诊断,以及 MediaStyle 后台通知控制。RFCOMM 只保留为调试模式下的手动兼容入口。 + +### 3. 构建 iPhone App 由于 Apple 签名与设备、团队绑定,Release 页面不提供通用 IPA。请自行构建: @@ -162,11 +190,11 @@ PlayerAgentApp/build/outputs/apk/debug/PlayerAgentApp-debug.apk iOS 模拟器无法验证真实的 BLE 媒体传输链路。 -### 3. 连接与播放 +### 4. 连接与播放 1. 在 Sony 播放器上启动 Player Agent; 2. 打开 QQ 音乐并播放一首歌曲; -3. 在 iPhone App 中搜索并连接 `SonyPlayerAgent`; +3. 在 iPhone 或 Android 控制端中搜索并连接 `SonyPlayerAgent`; 4. 连接成功后等待当前歌曲、歌词窗口和预览封面同步; 5. 如果 QQ 音乐尚未生成本地歌词,可先在 QQ 音乐中打开一次歌词页面。 @@ -190,7 +218,7 @@ iOS 模拟器无法验证真实的 BLE 媒体传输链路。 |---|---| | `PlayerAgentApp/` | Sony/Android 媒体代理与 BLE GATT Server | | `IOSBleFeasibility/` | 原生 iPhone App 与 Live Activity Extension | -| `ControllerApp/` | 为兼容保留的旧 Android Controller,不属于当前主链路 | +| `ControllerApp/` | Jetpack Compose Android BLE V2 控制端与前台连接服务 | | `docs/` | BLE、歌词、封面、重连和总体架构文档 | | `tools/` | iOS、Android、跨端和长时间播放冒烟测试 | @@ -208,7 +236,9 @@ iOS 模拟器无法验证真实的 BLE 媒体传输链路。 ```bash # Android 单元测试与 Debug 构建 -bash gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug +bash gradlew \ + :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug \ + :ControllerApp:testDebugUnitTest :ControllerApp:assembleDebug # 已安装设备的快速跨端冒烟测试 ./tools/smoke/run_all_smoke_tests.sh --quick --json diff --git a/docs/ALBUM_ART_ARCHITECTURE.md b/docs/ALBUM_ART_ARCHITECTURE.md index 6f30de0..2742e40 100644 --- a/docs/ALBUM_ART_ARCHITECTURE.md +++ b/docs/ALBUM_ART_ARCHITECTURE.md @@ -5,19 +5,22 @@ ## 模块职责 - Sony `AlbumArtTestManager`:从 MediaMetadata 和通知 largeIcon 中探测当前可用封面。当前真实链路常见来源是 QQMusic notification largeIcon。 -- Sony `BleGattServerManager`:处理 `ALBUM_ART_REQUEST`,压缩 preview/HQ/fallback,发送 `albumArtOffer` 和 binary chunk。 +- Sony `BleGattServerManager`:处理 `ALBUM_ART_REQUEST`,压缩 preview/HQ/fallback,发送 `albumArtOffer` 和 binary chunk;短期重传记录由独立 `AlbumArtTransferCoordinator` 持有。 - Sony `BleNotifyQueue`:发送 `albumArt` 长任务,控制 chunk 进度、超时和短状态让路。 - iOS `AlbumArtReceiver`:接收 offer、请求 preview/HQ、处理 binary start/chunk/end、超时恢复、缓存、displayQuality、enhanced、诊断 snapshot。 +- iOS `ArtworkImageCache`:缓存已经解码/降采样的 `UIImage`,40 项、32MB;内存警告只清理解码层,不删除磁盘原图。 - iOS `ArtworkEnhancementManager`:本地离线视觉增强缓存,不覆盖 HQ 原图。 - Live Activity:通过 `LiveActivityArtworkStore` 写 App Group 小缩略图,`ContentState` 只带 key/revision。 ## 核心文件 - iOS 接收:[AlbumArtReceiver.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift) +- iOS 解码缓存:[ArtworkImageCache.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift) - iOS 增强:[ArtworkEnhancementManager.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift) - iOS 诊断模型:[NowPlayingDiagnosticSnapshot.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift) - iOS Live Activity 缩略图:[LiveActivityArtworkStore.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift) - Sony GATT 封面发送:[BleGattServerManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt) +- Sony 封面传输协调器:[MediaTransferCoordinators.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt) - Sony 封面探测:[AlbumArtTestManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt) - iOS smoke AlbumArt 验收:[ios_album_art_flow_test.sh](/Volumes/雷电/project/MusicBleController/tools/ios-smoke-tests/ios_album_art_flow_test.sh) @@ -31,8 +34,10 @@ 6. 二进制传输: - JSON:`albumArtBinaryStart` / `albumArtBinaryEnd` - 二进制 chunk:6 字节 header + payload -7. iOS 在 utility 串行队列原子保存 `Documents/AlbumArtCache/`,不阻塞 CoreBluetooth/UI 主线程;增强图保存到 Enhanced 子目录。 -8. 主 UI 使用最终 `albumArtImage`;Live Activity 使用 App Group 小图。 +7. 传输结束先复制不可变 `Data` 快照到 utility 串行队列,再做 JPEG 校验、磁盘读写和 ImageIO 降采样;主图约 780px、历史缩略图约 128px。 +8. 解码完成回主线程前再次校验 `transferId + artworkId`;切歌后的旧任务不能覆盖新封面。 +9. iOS 在 utility 串行队列原子保存 `Documents/AlbumArtCache/`;增强图保存到 Enhanced 子目录。 +10. 主 UI 使用最终 `albumArtImage`;Live Activity 使用 App Group 小图。加载层明确区分 preview、HQ 和失败,可只重试当前歌曲。 ## 关键状态 @@ -46,6 +51,7 @@ - 缓存最长可展示 30 天;通知来源 30 分钟后复验,稳定 metadata 来源 24 小时后复验。小于 300px 只触发后台刷新,不清空可用图。 - Sony preview 目标约 112px、Q40~50、最多 1.8KB/12 包;HQ Q70~80、最多 8KB且保持最低优先级。 - Sony 已编码 JPEG 使用 40 项、16MB、30 分钟内存 LRU,避免同歌曲重复压缩。 +- iOS 解码缓存 key 包含 `artworkId + quality + targetPixelSize`,避免同一 JPEG 在主界面和历史列表重复全尺寸解码。 - A1 header 和 quality code 不变;start/end 可带 `transferId`、`generation`、`crc32`,新端支持局部重传,旧端直接忽略新字段。 ## 不允许随便修改的点 @@ -55,6 +61,7 @@ - 不要让 HQ 请求在 fullLyrics、lyricSecondary、remoteLog、mediaFieldDump 长任务期间抢占。 - 不要覆盖 HQ 原图;enhanced 必须是独立缓存。 - 不要把封面接收逻辑塞回 `BLETestManager`。 +- 不要在 SwiftUI `body` 或主线程同步 `Data(contentsOf:)`、校验 JPEG 或创建全尺寸图片。 ## 常见问题排查入口 @@ -64,6 +71,7 @@ - `[AlbumArt-iOS] transfer start/first chunk timeout/idle chunk timeout/total timeout/transfer cancelled` - `[AlbumArtCache] saved/display quality` - `[ArtworkDisplay] upgrade ...` + - `[ArtworkImageCache]` / `[ArtworkDecode]` - `[ArtworkEnhance] ...` - Sony: - `[AlbumArt-Sony]` diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index c14c061..e81dda0 100644 --- a/docs/BLE_PROTOCOL.md +++ b/docs/BLE_PROTOCOL.md @@ -4,7 +4,8 @@ ## 模块职责 -- Sony `BleGattServerManager`:GATT Server、command 写入解析、status notify、长任务分片。 +- Sony `BleGattServerManager`:GATT Server、command 写入解析和 status notify;歌词、封面与能力协商的连接态分别交给内部协调器持有。 +- Sony `LyricsTransferCoordinator` / `AlbumArtTransferCoordinator` / `ConnectionCommandCoordinator`:隔离歌词热缓存与重传、封面重传记录、连接代次与能力协商,避免跨媒体类型错误失效。 - Sony `BleNotifyQueue`:短消息和长任务队列,保证控制命令、播放状态、歌词、封面互不长期阻塞。 - iOS `BLETestManager`:GATT Client / Central,写 command,接收 status notify 和封面 binary chunk。 - Live Activity command bridge:iOS AppIntent 通过主 App 已有 BLE 连接发送控制命令。 @@ -15,6 +16,9 @@ - Sony UUID:[PlayerAgentUuids.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/PlayerAgentUuids.kt) - Sony GATT:[BleGattServerManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt) - Sony notify 队列:[BleNotifyQueue.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt) +- Sony 链路档案:[BleLinkProfile.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt) +- Sony 压缩歌词热缓存:[CompressedLyricsCache.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt) +- Sony 传输协调器:[MediaTransferCoordinators.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt) - iOS BLE:[BLETestManager.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift) - Live Activity 控制:[LiveActivityControlModels.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LiveActivityControlModels.swift)、[LiveActivityCommandBridge.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LiveActivityCommandBridge.swift) @@ -29,7 +33,7 @@ ## 命令数据流 -1. iOS 将 JSON 写到 command characteristic,字段通常包含 `cmd`、`time`、`seq`。 +1. iOS 或 Android Controller 将 JSON 写到 command characteristic,字段通常包含 `cmd`、`time`、`seq`。 2. Sony `onCharacteristicWriteRequest` 解析 JSON。 3. Sony `handleCommand` 执行控制、状态查询或长任务。 4. Sony 通过 status characteristic notify JSON 状态,或用二进制 notify 发送 AlbumArt chunk。 @@ -62,27 +66,84 @@ - `lyricDiagnostic` - `historyPayloadStart` / `historyPayloadChunk` / `historyPayloadEnd` +## 双控制器会话 + +- Sony 最多接受两个 status notify 订阅端;第三个订阅会返回失败并断开,不影响已有连接。 +- 每台控制器独立保存 capability、协商代次、MTU、发送节奏、歌词/封面等待请求和重传记录;任一设备退订或断开只清理自己的状态。 +- `PING`、能力 ACK、歌词、封面、历史、日志和诊断等请求响应只发送给命令来源设备。 +- `playbackState`、`trackInfo`、`currentWord`、`volumeState` 和 `albumArtOffer` 属于 Sony 权威状态,自动推送或控制完成后广播给全部订阅端。 +- 两台设备在 300ms 内发出相同 `PLAY_PAUSE`、`NEXT` 或 `PREVIOUS` 时只执行一次,避免双切换;seek 和音量仍按到达顺序执行并以最后状态为准。 +- 压缩歌词正文和已编码 JPEG 缓存全局共享,但分包、transferId、generation、CRC 和局部重传按设备隔离。 +- notify 成功、失败和退避按设备统计;仅一端连续失败时只断开该端,所有订阅端同时异常时才重建 Sony BLE 栈。 +- 第一个 Central 连接后会刷新 connectable advertising,第二个连接占满容量后停止 advertising;任一端断开后重新开放剩余连接位。 + ## 关键状态 - Sony `BleNotifyQueue` 使用四级优先级:P0 控制/状态/逐字,P1 lyricWindow/preview,P2 完整歌词/secondary,P3 HQ/历史/日志。 -- P0 每包可抢占;P2 每 4 包为 P1 让路;P3 每包让路。同优先级 FIFO,切歌按任务 generation 取消旧歌词/封面。 +- 队列的 enqueue、notify callback、超时、取消和抢占全部收敛到专用 `HandlerThread`;不要从 Binder/媒体线程直接修改队列状态。 +- P0 每包可抢占;P2 每 4 包为 P1 让路;P3 每包让路。同优先级的大传输按设备轮转,单台设备内部保持 FIFO;切歌按任务 generation 取消旧歌词/封面。 - iOS 控制命令在连接不健康时会丢弃,不缓存,不重连后补发。 - AlbumArt binary 使用 Sony 端 `ALBUM_ART_BINARY_MAGIC` 和 6 字节 header;iOS 在 `didUpdateValueFor` 中把非 JSON 二进制 chunk 转交给 `AlbumArtReceiver`。 ## V2 能力协商与回退 -1. iOS 完成 status notify 订阅后立即发送 `CLIENT_CAPABILITIES`,包含 `protocolVersion=2`、`fullLyricsZlib`、`lyricWindow`、`ping`、`transferRetry`。 +1. iOS 完成 status notify 订阅后立即发送 `CLIENT_CAPABILITIES`,包含 `protocolVersion=2`、`fullLyricsZlib`、`lyricWindow`、`ping`、`clockSyncV1`、`transferRetry`。 2. Sony 返回 `clientCapabilitiesAck`。Sony 在 ACK 或 250ms 超时前暂缓首次封面 Offer;iOS 300ms 未收到 ACK 时保持旧协议。 3. V2 `GET_FULL_LYRICS` 附带 `format=zlib-json-v1`。旧 Sony 忽略新字段并返回 legacy 逐行响应,新 iOS 同时解析两种格式。 4. 压缩歌词正文为 zlib JSON,最大 24KB;分包头为 `0xA2 + version + index + total` 共 6 字节。start/end 在小 MTU 下可使用 `id/tid/g/s/u/c/n/crc` 别名。 5. A1/A2 都使用 `trackId + generation + transferId + CRC32` 栅栏。缺包不超过 32 个时 `RETRY_TRANSFER` 局部重传,否则完整重试一次。 + - FullLyrics 重传只校验歌词自身的 `trackId + generation + transferId`,不得依赖当前封面 ID/状态。 6. 协商失败、正文超限、metadata 超 MTU 或旧端连接时自动回退 legacy,不改变 QRC 解密和 secondary 协议。 +## 可选 V3 基础协商 + +V3 是逐项可选能力层,不替换 V2。UUID、命令名、A1/A2、legacy 和全部 V2 字段保持不变;任一能力未协商时立即沿用现有 V2/legacy 行为。 + +- 客户端必须同时发送 `protocolVersion=3` 和 `f3` 才进入 V3 协商。只发送版本号不会启用任何 V3 行为。 +- `f3` bit0=`statusMetaV1`、bit1=`structuredErrorV1`、bit2=`mediaLoadStateV1`。Sony 只回显双方都支持的位;单项不支持时仅关闭该位。 +- `statusMetaV1` 仅在该设备有效 notify payload 不小于 247 bytes 时启用,小 MTU 会自动清除 bit0,不影响另外两个能力。 +- V3 ACK 使用紧凑格式:`{"type":"clientCapabilitiesAck","protocolVersion":3,"f2":63,"f3":,"sid":"1234abcd"}`。`sid` 是本次 Sony 服务进程的 8 位十六进制会话 ID。 +- `f2` bit0~bit5 依次表示 `albumArtBinary`、`fullLyricsZlib`、`lyricWindow`、`ping`、`clockSyncV1`、`transferRetry`。客户端仍发送原有 V2 boolean,Sony 据此生成 `f2`。 +- V1/V2 客户端继续收到原有 verbose ACK(`protocolVersion=2` 加六个 boolean);旧端不会收到 `f2`、`f3`、`sid` 或 `es`。 + +协商 `statusMetaV1` 后,Sony 可为该设备的 JSON 状态附加 `sid` 和 `es`。`es` 是每设备 UInt64 enqueue 序号,只用于发现 notify 缺口或重复;不同优先级可能交错,接收端不得按 `es` 全局丢弃状态。媒体正确性仍以 `trackId + generation + transferId` 为准。 + +协商 `structuredErrorV1` 后,Sony 可发送: + +```json +{"type":"commandError","seq":"42","cmd":"GET_FULL_LYRICS","domain":"lyrics","code":"qrc_pending","retryable":true,"retryAfterMs":1200,"trackId":"...","generation":7,"sid":"1234abcd","es":9} +``` + +`domain` 为 `protocol/lyrics/artwork/history/connection`。错误必须关联原命令 `seq`;ATT 写响应只表示命令已收到,不能冒充业务成功。正常成功状态仍由现有 authoritative status 表示。 + +协商 `mediaLoadStateV1` 后,Sony 可发送 P1 `mediaLoadState`:`resource=lyrics/artwork`,`stage=waiting/preparing/transferring/ready/unavailable/failed`。状态按设备与 `trackId/resource/stage/reason/generation` 去重,仅转换时发送;`ready` 表示对应传输已经完成。歌词 reason 包含 `qrc_pending/qrc_not_found/qrc_ambiguous/qrc_parse_failed/transfer_preparing/transfer_failed`,封面包含 `source_pending/source_unavailable/encode_failed/transfer_preparing/transfer_failed`。 + +## 自动歌词时钟同步 + +- `clockSyncV1` 只在双方能力 ACK 后启用。iOS 连接后连续发送 5 个低开销 PING,之后每 120 秒发送 3 个复验样本;旧端忽略新字段并维持原偏移逻辑。 +- PING 可携带 `clientSendElapsedMs`,PONG 回显该值并返回 `serverReceiveElapsedMs`、`serverSendElapsedMs`。两端均使用单调时钟,系统时区、手工校时或网络授时跳变不会改变播放锚点。 +- iOS 选取低 RTT 样本估算 Sony→iPhone 单调时钟映射。至少 3 个有效样本、最佳 RTT 不超过 300ms 且偏移抖动不超过 100ms 后才标记为可信。 +- 协商成功的 `playbackState` 使用 `sampleMono` 标记播放位置采样时刻;`currentWord` 在所有订阅端都支持该能力时也附带 `sampleMono`。原有 `timestamp` 保留用于旧协议和降级诊断。 +- 播放中接收端使用 `position + transportAge × speed` 建立本地锚点,暂停时不推进。小于 400ms 的校准差分段平滑;传输年龄超过 1.5 秒时丢弃旧锚点,避免积压歌词覆盖当前进度。 +- 自动补偿只消除时钟差和 BLE/调度延迟;QRC 文件自身偏差、MediaSession 与真实音频输出偏差仍通过 iOS“人工微调”设置处理。 +- 旧 Sony 不支持 `clockSyncV1` 时,iOS 在人工微调仍为 0 的情况下保留原有 600ms 兼容补偿;用户已有的自定义偏移不会被叠加覆盖。 +- Sony 将 `positionAnchorElapsedMs` 与歌词、封面等普通状态更新时间分离;非播放状态更新不得重置播放位置锚点。 + ## 自适应发送 -- JSON 歌词初始间隔 5ms,A2 binary 初始 2ms。 -- notify 失败后增加 5ms,连续 20 包成功后逐步降低,最大 30ms。 +- 每次连接创建独立 `BleLinkProfile`,记录设备、连接代次、MTU、EWMA notify RTT、失败率和 JSON/二进制间隔;重连或 MTU 改变时重置。 +- JSON 初始 5ms、范围 2~30ms;binary 初始 2ms、范围 1~30ms。 +- notify 失败或 callback RTT 超过 120ms 时增加 5ms;连续 20 次成功且 EWMA RTT 小于 60ms 时减少 1ms。 - `GET_LYRIC_WINDOW` 与完整歌词走独立歌词命令执行器,不被较慢的播放状态读取 FIFO 阻塞。 +- 压缩歌词缓存最多 16 项/512KB,保存 zlib 正文与 CRC,按当前 MTU 重新分包;fingerprint、generation、格式或逐字行集合变化时失效。 +- Sony 执行器只分实时、前台媒体 I/O、后台维护和 BLE 发送四类;历史、索引、日志与诊断不得占用实时线程。 + +## iOS 写回调容错 + +- iOS 进入 inactive/background 时暂停健康探测、订阅超时、写回调超时和时钟同步调度;回到前台先用 `PING`(旧协议用 `GET_PLAYBACK_STATE`)验证链路,收到任意有效 notify 后才同步播放状态、音量和歌词。 +- CoreBluetooth 偶发漏掉单次 `didWrite` 时,第一次超时只标记 suspect 并延长等待,不弹出 in-flight 请求、不推进下一条写,避免迟到回调串到下一条命令;连续两次超时才 hard reconnect。 +- Sony 端 45 秒无业务流量只会按设备发送 `{"type":"link"}` 小探针,不再重建共享 GATT。探针的真实 notify 失败累计到阈值后,仅隔离失败地址,其他控制器继续工作。 +- 恢复连接的 smoke 参数通过一次性 App 容器标记传递,避免 CoreBluetooth 后台恢复抢先启动导致测试序列丢失。 ## 不允许随便修改的点 diff --git a/docs/IOS_ARCHITECTURE.md b/docs/IOS_ARCHITECTURE.md index 6bc220d..728d199 100644 --- a/docs/IOS_ARCHITECTURE.md +++ b/docs/IOS_ARCHITECTURE.md @@ -4,13 +4,22 @@ ## 模块职责 -- `BLETestManager`:CoreBluetooth Central、扫描/连接/重连、命令写入、status notify 分发、歌词/历史/诊断/Live Activity 更新协调。 -- `PreferencesStore`:本地设置和 UserDefaults。包括日常/调试模式、自动重连、歌词偏移、歌词显示模式、封面增强开关、封面显示尺寸。 +- `BLETestManager`:兼容页面动作门面,继续暴露现有播放、扫描、重连、音量、Seek、歌词和历史方法;协议接收后只把受影响的数据同步到对应 Store/Coordinator。 +- `BLEInboundPipeline`:单消费者串行接收 notify,在后台维持接收顺序并完成 JSON、TrackInfo 和历史数据解码,主线程只应用最终结果。 +- `ConnectionStore` / `PlaybackStore` / `LyricsStore` / `ArtworkStore` / `DiagnosticsStore`:基于 Observation 的聚焦状态域;播放器子视图只读取自己需要的属性。 +- `BLEProtocolV3Parser`:V1/V2/V3 能力 ACK、`sid/es` 元信息、`commandError` 和 `mediaLoadState` 的 typed 解析及输入尺寸保护。 +- `PreferencesStore`:本地设置和 UserDefaults。包括日常/调试模式、自动重连、歌词偏移、歌词显示模式、封面增强、封面尺寸和自动/流畅/省电性能模式。 - `AlbumArtReceiver`:封面 offer、preview/HQ 请求、legacy/base64 和 binary 接收、超时、缓存、displayQuality、enhanced、诊断 snapshot。 -- `ContentView`:主播放器 UI,按 `AppExperienceMode` 控制日常/调试入口。 +- `ArtworkImageCache`:40 项/32MB 解码图片缓存,以及 utility 串行队列上的 ImageIO 降采样。 +- `BLEObservableStateModels`:聚焦 Store、协议 typed payload、事件序号诊断、布局/刷新策略和 Live Activity revision fence;旧 `ObservableStateSlice` 仅保留给兼容代码和渐进迁移测试。 +- `LastNowPlayingSnapshotStore`:24 小时版本化启动占位,只显示为“上次播放 · 等待同步”。 +- `FullLyricsCacheStore`:完整歌词 80 项/8MB/30 天版本化磁盘 LRU;重复歌曲先显示缓存,再由 Sony 后台复验。 +- `MediaLoadingState`:歌词窗口/完整歌词和 preview/HQ 的加载阶段及明确失败原因。 +- `ContentView`:安全区内的响应式播放器 UI;使用 compact/regular 两档布局、`ViewThatFits` 和垂直滚动兜底,并按 `AppExperienceMode` 控制日常/调试入口。 - `FullLyricsView`:全屏歌词、翻译/罗马音显示模式、逐字高亮和点击 seek。 - `NowPlayingDiagnosticView` / `SystemHealthOverviewView`:当前歌曲诊断、快捷修复、系统健康总览。 -- `LiveActivityManager`:ActivityKit 状态合并、去重、队列、启动/恢复/更新。 +- `LiveActivityManager`:ActivityKit 状态合并、去重、队列、启动/恢复/更新;封面由后台编码队列生成 80×80 JPEG,再交给 actor 原子写入共享容器。 +- `AppPerformanceLog` / `MetricDiagnosticsSubscriber`:按连接、协议、歌词、封面、Live Activity、UI 分类记录 OSLog/signpost,并接收 MetricKit 指标和诊断。 ## 核心文件 @@ -18,6 +27,11 @@ - [PreferencesStore.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift) - [AppExperienceMode.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/AppExperienceMode.swift) - [AlbumArtReceiver.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift) +- [ArtworkImageCache.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift) +- [BLEObservableStateModels.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift) +- [LastNowPlayingSnapshotStore.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LastNowPlayingSnapshotStore.swift) +- [FullLyricsCacheStore.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift) +- [MediaLoadingState.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/MediaLoadingState.swift) - [ContentView.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift) - [PreferencesView.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift) - [FullLyricsView.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift) @@ -34,8 +48,22 @@ - `lyricSecondary*` 组装翻译/罗马音。 - `albumArt*` 转交 `AlbumArtReceiver`。 - `playHistory*` 合并到 `PlaybackHistoryStore`。 -4. UI 通过 `@Published` 状态刷新。 -5. 诊断页通过 `makeNowPlayingDiagnosticSnapshot()` 从当前状态派生。 +4. JSON status、TrackInfo 拼包和历史拼包在 `BLEInboundPipeline` 的串行后台队列解码;以 transfer token、`trackId + generation + transferId` 校验时效后,回到 MainActor 应用 typed 结果。 +5. `BLETestManager` 保留兼容 `@Published` API,但主界面不观察它的全量变化;`BLETestManagerOwner` 作为不发布变化的 `@StateObject` 稳定持有门面,避免 View 重建时重复创建 CoreBluetooth 栈。连接、播放、歌词、封面和诊断子视图分别读取对应 Store,相同值和无关事件不会重绘整个播放器。 +6. 诊断页通过 `makeNowPlayingDiagnosticSnapshot()` 从当前状态派生。 + +## 协议协商与兼容 + +- 正常模式发送 `CLIENT_CAPABILITIES protocolVersion=3`,保留全部 V2 boolean,并发送 `f3=7`。 +- `f2` bit 0...5 依次表示 `albumArtBinary`、`fullLyricsZlib`、`lyricWindow`、`ping`、`clockSyncV1`、`transferRetry`。 +- `f3` bit 0...2 依次表示 `statusMetaV1`、`structuredErrorV1`、`mediaLoadStateV1`。 +- 没有 ACK 时保持 legacy/V1;V2 ACK 读取原 boolean;V3 ACK 读取紧凑的 `f2`、`f3`、`sid`。调试模式可强制发送 V2 以便 A/B 和紧急回退。 +- `sid/es` 只用于记录服务会话内的重复、缺口和乱序。不同优先级 notify 可以交错,所以 `es` 不能作为全局丢弃依据。 +- `commandError` 通过原命令 `seq` 关联,保留 domain、code、retryable、retryAfterMs、trackId、generation;面向用户显示可理解原因,完整字段进入调试日志。 +- `mediaLoadState` 按 `trackId + generation + resource + stage + reason` 去重并驱动歌词/封面加载状态;未协商该能力时继续使用 iOS 本地推断。 +- 小 MTU 下 Sony 可以关闭 `statusMetaV1`;iOS 必须接受没有 `sid/es` 的 V3 status,并继续按消息类型处理。 + +CoreBluetooth 使用 `com.musicblecontroller.sony.central.v1` 做状态恢复,并在 iOS 17+ 连接选项中启用系统自动重连。系统报告 `isReconnecting=true` 时,自建 scan/reconnect 暂停;回到前台且系统重连超过连接超时后才由现有主动扫描接管。恢复到已连接且特征有效的 Sony 时直接复用 characteristic/notify;恢复确认前,前台生命周期检查不得把 `disconnected` 初值误判为真实断链。只有恢复状态无效时才回到 scan/reconnect。 ## 关键状态 @@ -44,6 +72,17 @@ - 歌词:`lyric`、`fullLyrics`、`fullLyricsTrackId`、`lyricDiagnostic`、`lyricSecondaryTransfer`。 - 封面:`albumArtImage`、`artworkDisplayQuality`、`artworkEnhancementStatus`,由 `AlbumArtReceiver` 镜像到 `BLETestManager`。 - 设置:`PreferencesStore.shared` 是真实 owner;`BLETestManager` 只做兼容读取/同步。 +- 播放时钟只在前台且播放中运行;暂停、断开、后台保持静态位置。进度刷新为自动 4Hz、流畅 10Hz、省电 2Hz;伪频谱自动 15fps、流畅 20fps、省电或 Reduce Motion 静态化。 +- 稳定歌曲会异步写入 `LastNowPlayingSnapshot-v1.json`;首个真实 playbackState 不匹配或没有活动歌曲时立即清除占位。 + +## UI 状态与无障碍 + +- 可用高度不超过 700pt 或 Dynamic Type 为无障碍字号时使用 compact:封面约 140pt、歌词区至少 120pt、控制间距约 32pt;regular 封面约 204pt,并沿用原暗色播放器的分段弹性留白,把剩余高度分配在封面、歌词、进度和控制之间,音量条贴近底部安全区。 +- 蓝牙关闭、未授权、不支持、未连接、扫描、连接、恢复连接、连接失败和健康连接均有明确页面状态。没有缓存时只显示连接引导;有缓存时明确标记“上次播放 · 等待同步”。 +- 背景单独忽略安全区,内容始终在安全区内。`ViewThatFits` 放不下时改用垂直滚动,音量和控制按钮仍可访问。 +- 逐字歌词以连续字符进度驱动 iOS 18 `TextRenderer`,在同一份文本排版上按行裁切高亮;按性能模式在 10Hz/4Hz/2Hz 状态采样之间线性补间并前视一个采样周期。切行、Seek/倒退、大跨度校正和 Reduce Motion 必须直接对齐真实位置,不能播放反向扫动。 +- 歌词交互使用 `Button`,Slider 保留 VoiceOver 可调整操作;Reduce Motion 下不播放脉冲和频谱动画。 +- 用户文案维护在 String Catalog,简体中文和英文必须同步;升级后的默认界面语言是简体中文,用户可在设置中显式切换英文,避免系统语言变化造成中英混排。日志、协议字段和内部诊断值不本地化。 ## 不允许随便修改的点 @@ -51,12 +90,15 @@ - 不要把设置重新塞回 `BLETestManager`。 - 不要把封面接收链路从 `AlbumArtReceiver` 拆回 `BLETestManager`。 - 不要让控制命令在非 healthy 状态下排队补发。 +- 不要绕过五个聚焦 Store 让主界面重新直接观察 `BLETestManager` 的所有 BLE 诊断字段,也不要恢复全局 `objectWillChange` 镜像链。 +- 不要把“上次播放”快照当作真实在线播放状态。 - 不要让 UI 模式影响 BLE、歌词、封面、Live Activity 的业务逻辑。 ## 常见问题排查入口 - 自动连接慢:查 `[BLE-Reconnect] foreground strategy=scanFirst`、retrieve fast timeout、didDiscover、didConnect。 - 假连接:查 `[BLE-Health] suspect/probe/hard reconnect` 和 `connectionHealthState`。 +- 恢复后立即重连:查 `[BLE-Restore]`、`foreground restore skipped`,恢复完成前不应出现 `foreground unhealthy ... hard reconnect`。 - 设置未持久化:查 `[Preferences] loaded`、`[Preferences] changed`、smoke `[SmokeTest] preferences persisted`。 - 诊断页数据不对:查 `makeNowPlayingDiagnosticSnapshot()` 和 `SystemHealthSnapshot(nowPlaying:)`。 @@ -66,3 +108,8 @@ - 改 `PreferencesStore.swift`、`PreferencesView.swift`、UserDefaults key、`project.pbxproj`:跑 full。 - quick 命令:`./tools/ios-smoke-tests/run_ios_smoke_tests.sh --quick --json` - full 命令:`./tools/ios-smoke-tests/run_ios_smoke_tests.sh --json` +- XCTest:`xcodebuild -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj -scheme sonyMusic -destination 'platform=iOS Simulator,name=iPhone 16 Pro' CODE_SIGNING_ALLOWED=NO test` +- 覆盖率验收必须对 `sonyMusic.app` 生产 Target 单独计算,不能把测试 Target 混入总数。命令在 XCTest 后追加 `-enableCodeCoverage YES -resultBundlePath /tmp/IOSCoverage.xcresult`,再用 `xcrun xccov view --report /tmp/IOSCoverage.xcresult` 读取。2026-08-13 基线为 50.19%(14571/29029),36 个 XCTest 全部通过。 +- 覆盖率测试包含主播放器 regular/compact、设置、调试、诊断、完整歌词四种显示模式、历史统计等真实 SwiftUI 渲染,以及诊断 reducer、自愈状态机、歌词/封面/历史/日志/快照持久化。不得通过排除生产源码或把无断言执行计入测试来维持门槛。 + +工程暂时使用 Swift 5 + targeted strict concurrency;升级 Swift 6 前必须先保持 clean build 无源代码 warning。Debug/诊断构建可启用主线程 heartbeat,生产性能以 signpost 和 MetricKit 为准。 diff --git a/docs/LYRICS_ARCHITECTURE.md b/docs/LYRICS_ARCHITECTURE.md index 8f92018..8af9911 100644 --- a/docs/LYRICS_ARCHITECTURE.md +++ b/docs/LYRICS_ARCHITECTURE.md @@ -11,6 +11,7 @@ - Sony `QrcDirectoryWatcher` / `QrcIncrementalPrebuildManager`:监听 QQMusic 文件后到并增量解析。 - Sony `LyricRecoveryEngine`:当前歌曲无歌词后短期恢复窗口,处理 QQ音乐懒加载歌词缓存。 - iOS `BLETestManager`:接收 `lyricWindow*`、legacy `fullLyrics*`、A2 zlib full lyrics、`lyricSecondary*` 和歌词诊断。 +- Sony `LyricsTransferCoordinator` / `CompressedLyricsCache`:独立持有歌词重传与最多 16 项/512KB 的 zlib 正文和 CRC,不受封面生命周期影响。 - iOS `FullLyricsView`:显示原文、翻译、罗马音,逐字高亮只作用于原文。 ## 核心文件 @@ -25,6 +26,8 @@ - Sony recovery:[LyricRecoveryEngine.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt) - iOS full lyrics:[FullLyricsView.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift) - iOS 诊断:[LyricDiagnostic.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift) +- Sony 压缩热缓存:[CompressedLyricsCache.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt) +- Sony 歌词传输协调器:[MediaTransferCoordinators.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt) ## 数据流 @@ -35,8 +38,11 @@ 5. QRC 前台顺序为 L1、alias、exact L2、title+artist 唯一安全直达、内存索引 fuzzy;不再为当前歌曲 `listFiles` 或解析所有缓存 JSON。 6. TrackInfo 后 iOS 优先请求最多 5 行的 `GET_LYRIC_WINDOW`,先构建可见 UI;完整歌词后台请求。 7. V2 完整歌词为 zlib JSON + A2 binary + CRC32;协商/大小失败自动回退 legacy 逐行协议。 -8. 翻译/罗马音继续通过 `GET_LYRIC_SECONDARY mode=translation|romanization` 独立分片发送。 + - `RETRY_TRANSFER` 使用歌词传输自己的 `trackId + generation + transferId`,与封面是否存在、是否完成无关。 + - 重传结果仍受当前 generation 栅栏保护,旧歌曲不得覆盖新歌。 +8. 翻译/罗马音继续通过 `GET_LYRIC_SECONDARY mode=translation|romanization` 独立分片发送。iOS 分别维护开始、分包空闲和总传输超时;临时错误或缺包只重试一次,明确 unavailable 不重试,切歌/断线会取消旧连接代次的请求。 9. QRC 就绪回调会立即恢复 currentWord 边界调度;暂停时无周期任务,seek/恢复/切歌重新计算,最长 500ms 漂移校正。 +10. iOS 主界面显示等待 QQ QRC、歌词窗口、完整歌词、完成或明确失败;“重试歌词”只清理当前歌曲 cooldown/失败状态,并用 `GET_FULL_LYRICS forceRefresh=true` 触发兼容刷新。 ## 关键状态 @@ -46,6 +52,8 @@ - `QrcParsedCacheIndex.json`:只保存 songKey、标准化 metadata、groupId、文件名、行数、时间和 fingerprint;500ms 防抖、临时文件原子替换。 - `LyricRecoveryState`:`WAITING_QQMUSIC_CACHE`、`WATCHING_RECENT_QRC`、`RETRY_SCHEDULED`、`RETRYING`、`RESOLVED`、`EXPIRED` 等。 - iOS `LyricLine`:解析 fullLyrics 和 secondary 后合并,secondary 会清洗 `//`、`/`、`暂无翻译` 等占位。 +- iOS `FullLyricsCacheStore`:按精确 trackId 并复核标准化 title+artist,最多 80 首/8MB/30 天;缓存命中只做即时展示,仍请求 Sony 复验,远端主歌词刷新时保留已有翻译/罗马音直到新结果到达。 +- 压缩缓存 key 包含 songKey、fingerprint、generation、格式和逐字行集合;翻译/罗马音、fingerprint、generation 改变时不复用旧正文。 ## 不允许随便修改的点 @@ -56,6 +64,7 @@ - 不要把 translation/romanization 塞回基础 `fullLyricsChunk` 导致 MTU 裁剪。 - 不要让 Live Activity 显示翻译/罗马音或携带完整歌词。 - 不要删除 negative/cooldown/alias 策略,除非有日志证明它们错误。 +- 不要让歌词重传读取或比较 `currentAlbumArtId`。 ## 常见问题排查入口 diff --git a/docs/PRODUCT_UI_V2_IMPLEMENTATION.md b/docs/PRODUCT_UI_V2_IMPLEMENTATION.md new file mode 100644 index 0000000..599bbee --- /dev/null +++ b/docs/PRODUCT_UI_V2_IMPLEMENTATION.md @@ -0,0 +1,78 @@ +# Product UI V2 实现说明 + +本文记录 V4.0 第一阶段的 iOS 控制端与 Sony PlayerAgent 产品化 UI。UI 只消费现有播放、歌词、封面、历史和 BLE Health 状态;BLE UUID、命令、payload、分片、栅栏、优先级与恢复阈值均未修改。 + +## 设计 Token + +两端统一采用 Canvas `#06090E`、Surface 1 `#0F151F`、Surface 2 `#182230`、主文字 `#F5F7FB`、辅助文字 `#A7B0BD`、弱文字 `#697484`、健康 `#64D98C`、警告 `#F2B56D`、断开 `#EC7774`。音乐高亮使用稳定的 `#78DBC3`,不参与连接健康表达。小组件、普通卡片和主卡片圆角分别为 12、18 和 26–28 pt/dp;交互目标不小于 iOS 44 pt、Android 48 dp。 + +## iOS 自适应布局 + +`PlayerLayoutMode` 和 `PlayerLayoutMetrics` 位于 `PlayerProductUI.swift`,共同考虑可用宽高、Safe Area、横竖屏、Dynamic Type 和封面偏好: + +- Accessibility Dynamic Type 使用 accessibility 紧凑布局。 +- 横屏、有效高度小于 735 pt 或宽度小于 360 pt 使用 compact。 +- 其他情况使用 regular。 +- 375×667 会进入 compact;393×852 和 430×932 会进入 regular。 + +regular 使用居中的封面、曲目信息、三行歌词、进度、播放控制和音量。compact 使用 126–144 pt 左侧封面与右侧曲目信息,歌词和控制保持在固定布局中。主播放器不使用整页 `ScrollView`。 + +封面偏好的语义尺寸为小 184 pt、中 224 pt、大 272 pt。旧值 200/220/260 会在读取时映射到新枚举并沿用原 UserDefaults key;实际渲染尺寸仍受当前屏幕可用空间约束。 + +## iOS 播放状态矩阵 + +| 业务状态 | 主界面表达 | 状态胶囊交互 | 控制 | +| --- | --- | --- | --- | +| 已连接 | 实时曲目与歌词 | 打开设备详情 | 启用 | +| 首次扫描/连接 | 连接引导或加载状态 | 不重复发起连接 | 禁用 | +| 自动重连且有快照 | 保留快照并显示重连 Banner | 打开设备详情 | 禁用 | +| 已断开且有快照 | 标注“上次播放内容不是实时状态” | 执行现有扫描/连接 | 禁用 | +| 已断开且无快照 | 首次连接引导 | 执行现有扫描/连接 | 禁用 | +| 蓝牙/权限异常 | “需要处理”及文字说明 | 进入对应现有处理路径 | 禁用 | + +设备详情只显示真实可得的设备名、MTU、歌词时钟 RTT、最近 Notify、Health 和协商后的 V3 信息;缺失字段不渲染。“重新同步”调用已有重连同步入口,“重新连接”调用已有强重连入口,“忘记设备”调用已有本地设备记忆清理并要求确认。 + +完整歌词在用户主动滚动后切换为 browsing,不再使用 4 秒自动回跳;“回到当前歌词”或歌曲身份变化才恢复 following。歌词使用稳定 `LyricLine.id`,不再逐行持续测量 frame,也不比较整个歌词数组。 + +历史页采用暗色卡片、日期分组、搜索和今日/7 天/30 天/全部筛选;Formatter 为静态缓存,缩略图继续通过现有降采样缓存读取,分页与同步保持单飞。设置页按当前设备、播放器显示、歌词、封面、连接与系统、播放历史、高级与诊断、关于分组,协议与日志只在调试模式显示。 + +Live Activity 不改变 ActivityKit 模型、控制桥或 App Group 封面读取,只把播放音乐色、重连警告色和断开色与主应用 Token 对齐。所有 Dynamic Island 样式均保留左侧歌曲封面;歌词优先和节奏优先只改变中部信息层级,不再用引号或节奏图标替代封面。 + +## Sony 三层信息架构 + +`PlayerAgentNavigationView` 使用原生 Android View 构建稳定底部导航,切页不启动或停止服务,也不重建 GATT: + +- 主页:产品化主状态卡、Service/BLE/iPhone 三项摘要、当前歌曲/歌词/封面、设置完成度和“一键检查并修复”。 +- 设置:通知读取、QRC 文件访问、歌词辅助功能、按系统版本需要的蓝牙/通知权限,以及后台前台服务说明。不显示无后端的开机启动开关。 +- 诊断:链路、歌词、封面、日志四个标签。原扫描、经典蓝牙、QRC 监听/构建/修复/预热、封面发现、历史、日志导出等入口均迁移到相应标签;清理和重建仍保留原确认流程。 + +`PlayerAgentUiStateMapper` 是纯函数边界。它把 `BleHealthState`、权限是否完成和现有媒体状态映射为展示键、设置步骤和安全恢复动作,不直接访问 GATT 或保存第二套业务状态。“一键修复”动作集合只包括权限引导、启动前台服务和现有 BLE 恢复,不包含清缓存、重建索引、清历史或修改协议。 + +前台通知使用同一 Health 映射生成“可控制 / 等待 iPhone / 正在恢复连接 / 需要处理”等语义;仅在映射结果变化时调用 `notify`,不会重启服务或 BLE。通知提供“检查连接”和“打开应用”,不包含设备标识和诊断详情。 + +## 无障碍与性能 + +- 图标按钮提供可读标签,播放/暂停标签随状态变化。 +- 节奏条从无障碍树隐藏;状态同时使用文字与颜色。 +- 主播放器与 Sony 导航按钮满足最小触控尺寸,长曲目允许多行且不覆盖控制。 +- Reduce Motion 下歌词滚动和高亮使用静态/最小动画。 +- UI `body` 不发送 BLE 命令、不增加 Timer、不同步读取磁盘或解码全尺寸图片。 + +## 与静态设计稿的降级差异 + +- Sony 广播状态没有可靠的播放/暂停字段,因此主页只展示真实曲目信息,不伪造播放按钮状态。 +- Sony 当前没有统一可公开的 MTU/RTT/TrackId/generation 广播给 Activity;这些技术字段仍留在现有真实诊断输出,主页不显示示例值。 +- 没有新增封面取色链路,音乐元素使用稳定回退色。 +- 没有 `BOOT_COMPLETED` 接收器和持久化策略,因此设置页不显示开机启动。 + +## 视觉复核 + +- iOS:启动可用的 iPhone Simulator,执行无签名 `xcodebuild test` 后安装 `Debug-iphonesimulator/sonyMusic.app`。模拟器没有 BLE,因此只能复核真实的“不支持蓝牙”空态;连接、重连、快照、歌词浏览、历史、设置与设备详情由 `testPrimaryIOSSurfacesRenderRepresentativeStates` 的代表状态渲染测试覆盖。375×667、393×852 与 430×932 的布局策略由 `testFocusedStoresAndResponsiveLayoutModes` 覆盖。 +- Sony:连接 Android 6 或更高版本的 Sony 设备/模拟器,安装 `PlayerAgentApp/build/outputs/apk/debug/PlayerAgentApp-debug.apk`,依次打开底部“主页 / 设置 / 诊断”,并在诊断页切换“链路 / 歌词 / 封面 / 日志”。当前自动化环境没有 Android 设备,因此不生成虚假的运行时状态截图。 + +## 下一阶段 + +- 安全配对和受信任控制端。 +- `BOOT_COMPLETED` 自动启动的独立产品与后台策略。 +- 端到端延迟 SLO 与 Trace。 +- `BLETestManager`、`BleGattServerManager`、`LyricManager` 等核心大类的独立拆分。 diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 4ba558f..e4a8db7 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -7,7 +7,7 @@ - `PlayerAgentApp/`:运行在 Sony Android 设备上的权威数据源。负责读取 MediaSession/通知、记录播放历史、解析 QQMusic QRC 歌词、监听 QRC 文件变化、提供 BLE GATT Server、执行播放/音量控制。 - `IOSBleFeasibility/IOSBleFeasibility/`:iPhone 主 App。负责 CoreBluetooth Central 连接 Sony、发送命令、接收状态、缓存封面/歌词/历史、展示播放器、诊断页和设置页。 - `IOSBleFeasibility/SonyMusicLiveActivityExtension/`:Live Activity / Dynamic Island / 锁屏 Widget Extension。只展示 `ActivityKit` 状态和 App Group 中的小封面缩略图,不直接连接 Sony。 -- `ControllerApp/`:旧 Android Controller 侧代码,当前主链路不依赖它。除非任务明确要求,不要修改。 +- `ControllerApp/`:Android BLE V2 控制端。使用 Jetpack Compose、ViewModel、StateFlow 和前台连接服务,负责连接 Sony、播放控制、逐字/完整歌词、封面、历史统计、设置与诊断;RFCOMM 仅作为调试兼容入口。 - `tools/ios-smoke-tests/`:iOS-only smoke test 工具。只依赖 iPhone、`devicectl` 和 iOS App 日志,不使用 adb。 ## 核心文件 @@ -21,20 +21,27 @@ - Sony BLE 队列:[BleNotifyQueue.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt) - Sony 播放状态:[PlaybackStateReader.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt) - Sony 歌词:[LyricManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt)、[QrcLyricManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt) +- Android Controller 服务:[ControllerConnectionService.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt) +- Android Controller 协议与状态:[ControllerRepository.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt)、[ControllerModels.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt) +- Android Controller 界面:[ControllerAppUi.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt) ## 总体数据流 1. Sony `PlayerAgentForegroundService` 启动 GATT Server、advertising、QRC watcher、播放历史监控。 -2. iPhone `BLETestManager` 作为 Central 扫描 `SonyPlayerAgent`,连接服务 `0000A001...`。 -3. iPhone 向 command characteristic 写 JSON 命令。 +2. iPhone `BLETestManager` 和 Android `ControllerConnectionService` 可同时作为 Central 扫描并连接 `SonyPlayerAgent`(最多两个控制端)。 +3. 控制端向 command characteristic 写 JSON 命令;直接响应按来源设备路由,权威播放状态广播给全部订阅端。 4. Sony 从 MediaSession / Notification / QRC 缓存读取状态,通过 status characteristic notify JSON 或二进制封面 chunk。 5. iPhone 更新主播放器、全屏歌词、诊断页、Live Activity。 6. Live Activity Extension 只读取 `ContentState` 和 App Group 缩略图,不访问 BLE。 +7. Android `ControllerConnectionService` 使用同一 BLE V2 协议更新 Compose 界面和 + MediaStyle 通知,可与 iPhone 同时在线并保持控制与媒体状态同步。 ## 关键状态 - Sony 是播放、历史、歌词和封面来源的权威端。 +- iOS 与 Sony 可协商 `clockSyncV1`,使用单调时钟和播放位置采样时间动态补偿 BLE 歌词延迟;严重积压的旧锚点不得覆盖当前进度。 - iPhone 是同步、缓存和展示端。 +- Android Controller 是与 iPhone 对等的同步、缓存和展示端,不包含 Live Activity。 - iOS `BLETestManager` 仍是连接/协议分发中心,但设置已拆到 `PreferencesStore`,封面接收已拆到 `AlbumArtReceiver`。 - Live Activity 状态必须保持轻量,`ContentState` 不允许放图片、完整歌词、大数组或 Base64。 - iOS 日志落盘在 App 容器 `Documents/Logs/ios_ble.log`。 @@ -63,4 +70,6 @@ `./tools/ios-smoke-tests/run_ios_smoke_tests.sh --quick --json` - iOS 启动、安装、UserDefaults、日志、App 容器、`project.pbxproj` 改动: `./tools/ios-smoke-tests/run_ios_smoke_tests.sh --json` +- Android Controller 改动: + `./gradlew :ControllerApp:testDebugUnitTest :ControllerApp:assembleDebug :ControllerApp:lintDebug` - docs-only 改动:只需 `git diff --check`。 diff --git a/docs/RECONNECT_HEALTH_ARCHITECTURE.md b/docs/RECONNECT_HEALTH_ARCHITECTURE.md index 494b337..a119b6d 100644 --- a/docs/RECONNECT_HEALTH_ARCHITECTURE.md +++ b/docs/RECONNECT_HEALTH_ARCHITECTURE.md @@ -25,6 +25,9 @@ 5. Health timer 检查静默时间:播放中 15 秒、暂停中 30 秒才探测。 6. V2 发送轻量 `PING/PONG`;旧 Sony 回退 `GET_PLAYBACK_STATE`。连续两次探测失败才 hard reconnect,明确断开回调仍立即处理。 7. `connectionDisplayState` 给 UI,`connectionHealthState` 给诊断和控制保护。 +8. CoreBluetooth 状态恢复优先复用已连接 Sony 和已恢复 characteristic;iOS 17+ 同时启用系统自动重连,系统重连期间禁止自建 reconnect work item 竞争,前台超过连接超时后再回退主动扫描。进入 inactive/background 后暂停 health、订阅/写超时和时钟同步;回到前台先用单次探针验证,收到有效 notify 后才恢复状态、音量和歌词同步。 +9. 单次 `didWrite` 回调超时只标记 suspect 并延长等待,不移除 in-flight 请求、不推进写队列;连续两次写回调超时才 hard reconnect。 +10. Sony 端业务静默只触发按地址的轻量 notify 探针;只有真实 notify 失败达到阈值才隔离该地址,禁止用静默时间直接重建共享 GATT。 ## 关键状态 @@ -40,6 +43,7 @@ - `connectionHealthState`:内部健康,如 `healthy`、`suspect`、`stale`、`disconnected`。 - `autoReconnectState`:`idle`、`reconnectScheduled`、`scanning`、`connecting` 等。 - `connectionAttemptId`:防止旧 scan/connect 回调污染当前状态。 +- `coreBluetoothRestoreInProgress`:隔离状态恢复和前台检查的竞态。 ## 不允许随便修改的点 @@ -48,16 +52,20 @@ - 不要让 retrieve connect 阻塞 scan 太久。 - 不要在非 healthy/suspect 时发送播放控制命令。 - 不要重连后补发旧控制命令。 +- 不要因为恢复连接尚未完成 health 初始化就主动取消已连接 peripheral。 ## 常见问题排查入口 - 打开 App 连接慢:看 `[BLE-Reconnect] foreground strategy=scanFirst`、`retrieve connect fast timeout`、`didDiscover`、`didConnect`。 - 一直显示重连:看 `autoReconnectState`、`connectionAttemptId`、`ignore stale callback`、`scan timeout`。 - 假连接:看 `[BLE-Health] suspect`、`probe sent`、`probe timeout`、`hard reconnect reason=...`。 +- 恢复竞态:看 `[BLE-Restore] restored`、`foreground restore skipped`、`reuse restored notifying characteristic`。 +- 写回调偶发丢失:看 `[CTRL-iOS] write timeout extended`;第一轮超时后 in-flight 序号应保持不变。 +- 前后台恢复:看 `BLE watchdogs paused`、`foreground validation queued/success`,验证成功前不应出现状态/音量/歌词同步突发。 - 胶囊闪烁:区分 `connectionDisplayState` 和 `connectionHealthState`,日常模式不应展示技术细节。 ## 修改后必须跑哪些 smoke test - 改连接、重连、Health、UI 胶囊:quick smoke。 - 改 `autoReconnectEnabled` 默认值/UserDefaults/Preferences:full smoke。 -- 真机建议测试:Sony 服务停止、恢复、App 前后台、Force Reconnect。 +- 真机建议测试:`reconnect_sync_v28_test.sh`、Sony 服务停止/恢复、App 前后台、Force Reconnect。 diff --git a/docs/SMOKE_TEST_GUIDE.md b/docs/SMOKE_TEST_GUIDE.md index 9ac0eb5..966aa57 100644 --- a/docs/SMOKE_TEST_GUIDE.md +++ b/docs/SMOKE_TEST_GUIDE.md @@ -5,6 +5,7 @@ - iOS-only:只验证 iPhone 端,不控制 Sony,也不使用 adb。 - Android/Sony-only:只验证 Sony `PlayerAgentApp`,使用 adb,不操作 iPhone。 - Cross-device entry:编排 iOS-only 和 Android/Sony-only,并生成总报告。 +- CI:`.github/workflows/build.yml` 固定 JDK 17 跑 Android 单测/assembleDebug,并在无签名 iOS Simulator 上跑 XCTest。 ## iOS-only 模块职责 @@ -75,6 +76,7 @@ 1. 选择 iPhone:`--device` > `IOS_DEVICE_ID` > 自动发现 available iPhone。 2. 可选 build/install。 3. `devicectl launch` 启动 App。 + - 有 `--smoke-*` 参数时,`ios_ble_precheck.sh` 会先写入一次性 `Documents/SmokeLaunchArguments.txt`。即使 CoreBluetooth 状态恢复先在后台唤醒 App,测试参数也不会丢失;App 读取后立即删除。 4. 从 App container 复制 `Documents/Logs/ios_ble.log`。 5. DEBUG smoke preferences 写入并重启验证。 6. 文件检查 App container 中日志、AlbumArtCache、Preferences。 @@ -179,6 +181,7 @@ - `fullLyricsSendStart/End` 同时覆盖 A2 与 legacy,不能因协议升级造成 trace 缺失。 - 冷测使用调试流程自然换歌,不删除用户真实 QRC/封面缓存。 - 自动切歌必须通过 iOS BLE 控制或 Android `cmd media_session dispatch next`;不要向无焦点 PlayerAgent Activity 注入 `input keyevent`,否则系统可能产生与业务线程无关的 input-dispatch ANR。 +- Sony-only 模式的当前歌词刷新必须通过 Debug Receiver 的 `com.example.playeragent.debug.REFRESH_CURRENT_LYRIC` 转发;正式前台服务保持 `exported=false`,shell 不能直接启动。 - iPhone 锁屏导致 `ios_app_launch_failed` 时测试不得执行动作,解锁后整项重跑。 ## iOS BLE 硬前置校验 @@ -188,12 +191,25 @@ 硬性条件: - iOS App launched。 -- BLE connected。 +- BLE connected。新的 `didConnect` 或 `[BLE-Restore] restored ... state=connected` 都是有效连接证据。 - notify subscribed。 - 5 秒内收到至少 1 条 `playbackState`。 如果前置校验失败,脚本必须立即 FAIL,`precheckFailReason=ios_ble_not_connected`,并且不得继续执行 `GET_FULL_LYRICS`、AlbumArt 请求、`NEXT`、`VOLUME`、`SEEK` 等动作。报告必须输出 `iosAppLaunched`、`iosBleConnected`、`notifySubscribed`、`firstPlaybackStateReceived`、`firstPlaybackStateLatencyMs`、`precheckResult`、`precheckFailReason`。 +## iOS XCTest + +```bash +xcodebuild \ + -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ + -scheme sonyMusic \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +`PerformanceStabilityTests` 覆盖 A1/A2 分发、乱序/重复/缺包、CRC/zlib 固定向量、封面降采样和损坏数据、快照过期/损坏、状态切片去重及播放时钟暂停。新增协议或缓存行为时,应先增加确定性 XCTest,再跑真机 smoke。 + ## Source Capability V3.0 源可用性诊断 命令: diff --git a/gradle.properties b/gradle.properties index 5bac8ac..357b11b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 diff --git a/image.png b/image.png new file mode 100644 index 0000000..65b5b82 Binary files /dev/null and b/image.png differ diff --git a/tools/android-smoke-tests/README.md b/tools/android-smoke-tests/README.md index 6c12f03..f01c615 100644 --- a/tools/android-smoke-tests/README.md +++ b/tools/android-smoke-tests/README.md @@ -16,13 +16,15 @@ It is Android-only: - Android platform-tools `adb` available in `PATH`, `ADB_BIN`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, or `~/Library/Android/sdk/platform-tools/adb`. - For full build, Gradle must be able to build `:PlayerAgentApp:assembleDebug`. -If `JAVA_HOME` is not set, the build script uses Android Studio JBR when it exists: +The build script prefers Android Studio JBR when it exists, including when the +parent shell still exports an incompatible Java 8 runtime: ```text /Volumes/雷电/Android Studio.app/Contents/jbr/Contents/Home ``` -Existing `JAVA_HOME` always wins. +On CI or machines without that local JBR path, the existing `JAVA_HOME` remains +in effect. ## Commands @@ -130,6 +132,10 @@ Then it waits briefly and checks logcat for: Release builds do not include this receiver. The formal `PlayerAgentForegroundService` remains `exported=false`. +Sony-only歌词矩阵还会使用 +`com.example.playeragent.debug.REFRESH_CURRENT_LYRIC`,经同一个 Debug Receiver +转发给非导出的前台服务;测试脚本不得直接从 shell 启动该服务。 + Use `--no-debug-control` to keep the previous passive behavior. ## Reports diff --git a/tools/android-smoke-tests/android_build_install.sh b/tools/android-smoke-tests/android_build_install.sh index f63e69a..653ebe5 100755 --- a/tools/android-smoke-tests/android_build_install.sh +++ b/tools/android-smoke-tests/android_build_install.sh @@ -8,12 +8,12 @@ DEVICE_ID="${DEVICE_ID:-}" SKIP_BUILD="${SKIP_BUILD:-false}" SKIP_INSTALL="${SKIP_INSTALL:-false}" -if [[ -z "${JAVA_HOME:-}" ]]; then - bundled_jbr="/Volumes/雷电/Android Studio.app/Contents/jbr/Contents/Home" - if [[ -d "$bundled_jbr" ]]; then - export JAVA_HOME="$bundled_jbr" - export PATH="$JAVA_HOME/bin:$PATH" - fi +bundled_jbr="/Volumes/雷电/Android Studio.app/Contents/jbr/Contents/Home" +if [[ -d "$bundled_jbr" ]]; then + # Prefer the known-good Android Studio runtime even when an inherited shell + # still points at Java 8; AGP 8.5 requires Java 11 or newer. + export JAVA_HOME="$bundled_jbr" + export PATH="$JAVA_HOME/bin:$PATH" fi echo "JAVA_HOME=${JAVA_HOME:-unset}" diff --git a/tools/deploy/ios_deploy.sh b/tools/deploy/ios_deploy.sh index ecccce6..d4334f9 100755 --- a/tools/deploy/ios_deploy.sh +++ b/tools/deploy/ios_deploy.sh @@ -15,6 +15,7 @@ RENEW_PROFILE_WAIT_SECONDS="${RENEW_PROFILE_WAIT_SECONDS:-90}" IOS_DEVICE_ID="${IOS_DEVICE_ID:-}" XCODE_DESTINATION="${XCODE_DESTINATION:-}" XCODE_DESTINATION_ID="${XCODE_DESTINATION_ID:-}" +XCODE_WARM_AFTER_PROFILE_REMOVAL="${XCODE_WARM_AFTER_PROFILE_REMOVAL:-true}" FORCE_REINSTALL=false REFRESH_ONLY=false RENEW_PROFILES=false @@ -36,7 +37,7 @@ Options: -h, --help Show this help. Environment overrides: - ROOT_DIR PROJECT_PATH SCHEME CONFIGURATION BUNDLE_ID APP_NAME DERIVED_DATA_PATH OUT_DIR SMOKE_CHECK RENEW_PROFILE_WAIT_SECONDS IOS_DEVICE_ID XCODE_DESTINATION XCODE_DESTINATION_ID + ROOT_DIR PROJECT_PATH SCHEME CONFIGURATION BUNDLE_ID APP_NAME DERIVED_DATA_PATH OUT_DIR SMOKE_CHECK RENEW_PROFILE_WAIT_SECONDS IOS_DEVICE_ID XCODE_DESTINATION XCODE_DESTINATION_ID XCODE_WARM_AFTER_PROFILE_REMOVAL EOF } @@ -293,6 +294,22 @@ PY return 1 } +warm_xcode_after_profile_removal() { + if [[ "$XCODE_WARM_AFTER_PROFILE_REMOVAL" != true ]]; then + return 0 + fi + + echo "[Deploy] opening Xcode after profile removal to warm signing account state" | tee -a "$OUT_DIR/xcodebuild.log" + open -a Xcode "$PROJECT_PATH" >/dev/null 2>&1 || true + echo "[Deploy] waiting up to ${RENEW_PROFILE_WAIT_SECONDS}s for Xcode to regenerate profiles" | tee -a "$OUT_DIR/xcodebuild.log" + if wait_for_matching_profiles 2>&1 | tee -a "$OUT_DIR/xcodebuild.log"; then + echo "[Deploy] regenerated profiles found before xcodebuild" | tee -a "$OUT_DIR/xcodebuild.log" + sync_xcode_profiles + else + echo "[Deploy] Xcode did not regenerate profiles before xcodebuild; continuing" | tee -a "$OUT_DIR/xcodebuild.log" + fi +} + IOS_DEVICE_ID="$(find_device)" XCODE_DESTINATION="$(resolve_xcode_destination)" export ROOT_DIR OUT_DIR IOS_DEVICE_ID BUNDLE_ID XCODE_DESTINATION @@ -329,6 +346,10 @@ if [[ "$RENEW_PROFILES" == true ]]; then echo "[Deploy] removing provisioning profile: $profile" rm -f "$profile" done < <(renew_matching_profiles) + + if [[ -s "$backup_manifest" ]]; then + warm_xcode_after_profile_removal + fi fi set +e diff --git a/tools/ios-smoke-tests/ios_launch.sh b/tools/ios-smoke-tests/ios_launch.sh index bd86201..db8c29c 100755 --- a/tools/ios-smoke-tests/ios_launch.sh +++ b/tools/ios-smoke-tests/ios_launch.sh @@ -8,6 +8,33 @@ WAIT_SECONDS="${WAIT_SECONDS:-5}" mkdir -p "$OUT_DIR" +smoke_args=() +for launch_arg in "$@"; do + if [[ "$launch_arg" == --smoke-* ]]; then + smoke_args+=("$launch_arg") + fi +done + +# CoreBluetooth restoration may relaunch the app before devicectl can deliver +# process arguments. Stage the same one-shot marker consumed by the DEBUG app so +# every smoke entry point remains deterministic on a restored connection. +if [[ "${#smoke_args[@]}" -gt 0 ]]; then + launch_marker="$OUT_DIR/SmokeLaunchArguments.txt" + : > "$launch_marker" + for launch_arg in "${smoke_args[@]}"; do + printf '%s\n' "$launch_arg" >> "$launch_marker" + done + if ! xcrun devicectl device copy to \ + --device "$IOS_DEVICE_ID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source "$launch_marker" \ + --destination "Documents/SmokeLaunchArguments.txt" \ + >>"$OUT_DIR/ios_launch.log" 2>&1; then + echo "[iOSLaunch] WARN unable to stage restoration-safe smoke marker" >&2 + fi +fi + xcrun devicectl device process launch \ --device "$IOS_DEVICE_ID" \ --terminate-existing \ diff --git a/tools/ios-smoke-tests/ios_settings_test.sh b/tools/ios-smoke-tests/ios_settings_test.sh index c0cd4f3..4d405e0 100755 --- a/tools/ios-smoke-tests/ios_settings_test.sh +++ b/tools/ios-smoke-tests/ios_settings_test.sh @@ -16,7 +16,7 @@ if ! grep -q '\[SmokeTest\] preferences written' "$OUT_DIR/ios_ble_after_prefere exit 1 fi -if ! grep -q '\[SmokeTest\] preferences verified mode=debug artworkDisplaySize=200 lyricOffsetMs=300 autoReconnect=true' "$OUT_DIR/ios_ble_after_preferences.log"; then +if ! grep -q '\[SmokeTest\] preferences verified mode=debug artworkDisplaySize=184 lyricOffsetMs=300 autoReconnect=true' "$OUT_DIR/ios_ble_after_preferences.log"; then echo "Missing preferences verified log" >&2 exit 1 fi diff --git a/tools/ios-smoke-tests/run_ios_smoke_tests.sh b/tools/ios-smoke-tests/run_ios_smoke_tests.sh index d48b661..d82973c 100755 --- a/tools/ios-smoke-tests/run_ios_smoke_tests.sh +++ b/tools/ios-smoke-tests/run_ios_smoke_tests.sh @@ -257,7 +257,7 @@ fi start="$(now_ms)" if "$SCRIPT_DIR/ios_settings_test.sh" >"$OUT_DIR/ios_settings_stdout.log" 2>"$OUT_DIR/ios_settings_stderr.log"; then - record_required "Preferences" "PASS" "$(elapsed_ms "$start")" "mode=debug size=200 offset=300 autoReconnect=true" + record_required "Preferences" "PASS" "$(elapsed_ms "$start")" "mode=debug size=184 offset=300 autoReconnect=true" else record_required "Preferences" "FAIL" "$(elapsed_ms "$start")" "smoke-test preference logs missing" fi diff --git a/tools/smoke/README.md b/tools/smoke/README.md index a5eeddb..a93d32e 100644 --- a/tools/smoke/README.md +++ b/tools/smoke/README.md @@ -87,12 +87,14 @@ Use this when lyrics or artwork are slow/missing and you need to know whether th All tests that perform real iOS BLE operations must pass `ios_ble_precheck.sh` before they send commands or request large payloads. The hard precheck requires: - iOS app launched. -- BLE connected. +- BLE connected through a fresh `didConnect` or CoreBluetooth restored-connected event. - status notify subscribed. - at least one `playbackState` received within 5 seconds. If any of these conditions fail, the test exits immediately with `reason=ios_ble_not_connected` and does not send `GET_FULL_LYRICS`, AlbumArt requests, `NEXT`, volume, or seek commands. Reports include `iosAppLaunched`, `iosBleConnected`, `notifySubscribed`, `firstPlaybackStateReceived`, `firstPlaybackStateLatencyMs`, `precheckResult`, and `precheckFailReason`. +When a test supplies `--smoke-*` launch arguments, the precheck also stages a one-shot app-container marker. This keeps the test trigger deterministic when CoreBluetooth restoration wakes the app before the foreground launch process receives its arguments. + ## Device Behavior When neither `--ios-only` nor `--android-only` is used: diff --git a/tools/smoke/ios_ble_precheck.sh b/tools/smoke/ios_ble_precheck.sh index 0cb270a..69e776b 100755 --- a/tools/smoke/ios_ble_precheck.sh +++ b/tools/smoke/ios_ble_precheck.sh @@ -121,12 +121,21 @@ def first(pattern: str): return line, parse_ios_ms(line) return "", None -_, connected_at = first(r"\[BLE-iOS\]\s+didConnect|\[BLE\]\s+connected|\[Reconnect\]\s+connected") +_, connected_at = first( + r"\[BLE-iOS\]\s+didConnect|" + r"\[BLE\]\s+connected|" + r"\[Reconnect\]\s+connected|" + r"\[BLE-Restore\]\s+restored\b.*\bstate=connected" +) _, notify_at = first(r"status notify subscribed|notify subscribed|\[Reconnect\]\s+subscribed") _, playback_at = first(r'\{"type":"playbackState"|\[iOS\]\[Status\]\s+playbackState|\[Reconnect\]\s+playbackState accepted|notify received type=playbackState') display_state = "unknown" -if re.search(r"didConnect|\[BLE\]\s+connected", window_text, re.I): +if re.search( + r"didConnect|\[BLE\]\s+connected|\[BLE-Restore\]\s+restored\b.*\bstate=connected", + window_text, + re.I, +): display_state = "connected_no_status" if re.search(r"notify subscribed", window_text, re.I): display_state = "subscribed_waiting_status" @@ -194,6 +203,22 @@ if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then launch_args_display="${LAUNCH_ARGS[*]}" fi log "launch app for BLE precheck timeout=${TIMEOUT_SEC}s args=${launch_args_display}" +if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then + LAUNCH_MARKER="$OUT_DIR/SmokeLaunchArguments.txt" + : > "$LAUNCH_MARKER" + for launch_arg in "${LAUNCH_ARGS[@]}"; do + printf '%s\n' "$launch_arg" >> "$LAUNCH_MARKER" + done + if ! xcrun devicectl device copy to \ + --device "$IOS_DEVICE_ID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source "$LAUNCH_MARKER" \ + --destination "Documents/SmokeLaunchArguments.txt" \ + >>"$PRECHECK_LOG" 2>&1; then + log "WARN unable to stage restoration-safe smoke launch marker; using process argument only" + fi +fi LAUNCH_OK=true launch_cmd=(xcrun devicectl device process launch --device "$IOS_DEVICE_ID" --terminate-existing "$BUNDLE_ID") if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then diff --git a/tools/smoke/lyrics_timeline_v34_test.sh b/tools/smoke/lyrics_timeline_v34_test.sh index 192aee8..0bd6df6 100755 --- a/tools/smoke/lyrics_timeline_v34_test.sh +++ b/tools/smoke/lyrics_timeline_v34_test.sh @@ -397,9 +397,9 @@ if [[ "$SONY_ONLY" == true ]]; then for _ in $(seq 1 "$EXPECTED_TRACK_COUNT"); do "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell cmd media_session dispatch next || true sleep 1 - "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell am start-foreground-service \ - -n com.example.playeragent/.service.PlayerAgentForegroundService \ - -a com.example.playeragent.ACTION_REFRESH_CURRENT_LYRIC || true + "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell am broadcast \ + -p com.example.playeragent \ + -a com.example.playeragent.debug.REFRESH_CURRENT_LYRIC || true sleep "$interval" done ) >"$OUT_DIR/sony_next_driver.out" 2>"$OUT_DIR/sony_next_driver.err" & diff --git a/tools/smoke/reconnect_sync_v28_test.sh b/tools/smoke/reconnect_sync_v28_test.sh index 9706ee3..3868062 100755 --- a/tools/smoke/reconnect_sync_v28_test.sh +++ b/tools/smoke/reconnect_sync_v28_test.sh @@ -388,12 +388,26 @@ sync_current_word_sent = count(r"\[ReconnectSync\] send currentWord", sony_text) sync_current_word_skip = count(r"\[ReconnectSync\] skip currentWord", sony_text) sync_album_offer_sent = count(r"\[ReconnectSync\] send albumArtOffer", sony_text) cooldown_skip = count(r"\[ReconnectSync\] skip reason=cooldown", sony_text) +restored_connection = count( + r"\[BLE-Restore\]\s+restored\b.*\bstate=connected", + ios_window, +) +ios_restore_sync_start = count(r"\[BLE-Reconnect\] sync playback state", ios_window) ios_state_sync = count(r"\[Reconnect\] state sync received", ios_window) ios_reconnect_playback_accepted = count(r"\[Reconnect\] playbackState accepted", ios_window) ios_raw_playback_state = count(r'\{"type":"playbackState"', ios_window) ios_playback_accepted = max(ios_reconnect_playback_accepted, ios_raw_playback_state) -ios_current_word_after = count(r"\[Reconnect\] currentWord accepted after reconnect", ios_window) +ios_current_word_reconnect_log = count( + r"\[Reconnect\] currentWord accepted after reconnect", + ios_window, +) +ios_raw_current_word = count(r"\[Lyrics-iOS\]\s+currentWord", ios_window) +ios_current_word_after = max(ios_current_word_reconnect_log, ios_raw_current_word) ios_album_offer = count(r"albumArtOffer", ios_window) +ios_artwork_cache_reused = count( + r"\[AlbumArtCache\]\s+hit|\[ArtworkDisplay\].*\bcacheHit\b", + ios_window, +) stale_after = count(r"\[Reconnect\] stale discard after reconnect", ios_window) main_stall = count(r"main stall detected", ios_window) payload_too_large = count(r"payload=\d+ max=\d+|Payload maximum size exceeded", sony_text + "\n" + ios_window) @@ -422,13 +436,13 @@ if ios_playback_accepted == 0: issues.append("iOS did not receive reconnect playbackState") elif playback_latency and playback_latency > 5_000: issues.append(f"reconnect playbackState latency exceeded 5s ({playback_latency}ms)") -if sync_start == 0: +if sync_start == 0 and not (restored_connection and ios_restore_sync_start): warnings.append("Sony reconnect sync start was not observed") -if sync_playback_sent == 0: +if sync_playback_sent == 0 and ios_playback_accepted == 0: warnings.append("Sony reconnect playbackState send log was not observed") -if sync_current_word_sent == 0: +if sync_current_word_sent == 0 and ios_current_word_after == 0: warnings.append("currentWord was not sent after reconnect or was unavailable") -if sync_album_offer_sent == 0: +if sync_album_offer_sent == 0 and ios_album_offer == 0 and ios_artwork_cache_reused == 0: warnings.append("albumArtOffer was not resent after reconnect") if stale_after > 0: issues.append(f"stale discard after reconnect observed ({stale_after})") @@ -466,13 +480,18 @@ payload = { }, "ios": { "reconnectCount": reconnect_count, + "coreBluetoothRestoredConnection": restored_connection, + "restoreSyncStartCount": ios_restore_sync_start, "notifySubscribedCount": notify_subscribed, "reconnectPlaybackStateReceived": ios_playback_accepted, "reconnectPlaybackStateAcceptedLogCount": ios_reconnect_playback_accepted, "rawPlaybackStateReceived": ios_raw_playback_state, "reconnectStateSyncReceived": ios_state_sync, "reconnectCurrentWordReceived": ios_current_word_after, + "reconnectCurrentWordAcceptedLogCount": ios_current_word_reconnect_log, + "rawCurrentWordReceived": ios_raw_current_word, "albumArtOfferReceived": ios_album_offer, + "artworkCacheReused": ios_artwork_cache_reused, "staleDiscardAfterReconnect": stale_after, "mainStallCount": main_stall, "payloadTooLargeCount": payload_too_large, @@ -506,6 +525,8 @@ md = [ "## Metrics", "", f"- reconnect count: {reconnect_count}", + f"- CoreBluetooth restored connection: {restored_connection}", + f"- iOS restore sync start: {ios_restore_sync_start}", f"- notify subscribed: {notify_subscribed}", f"- Sony sync start: {sync_start}", f"- Sony playbackState sent: {sync_playback_sent}", @@ -515,6 +536,7 @@ md = [ f"- iOS playbackState received: {ios_playback_accepted}", f"- iOS currentWord received: {ios_current_word_after}", f"- iOS albumArtOffer received: {ios_album_offer}", + f"- iOS artwork cache reused: {ios_artwork_cache_reused}", f"- playback latency: {playback_latency}ms", f"- currentWord latency: {current_word_latency}ms", f"- albumArtOffer latency: {album_offer_latency}ms",