diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a9caa0f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,88 @@ +name: Cross-platform build and tests + +on: + push: + branches: + - main + - master + - "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..8b2b9dd 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 @@ -218,11 +336,21 @@ final class AlbumArtReceiver { updateArtworkEnhancementStatus(message: "ready") } + private func realtimeMonoMs() -> Int64 { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + } + func handleOffer(id: String) { guard !id.isEmpty else { log("[AlbumArt] invalid offer") return } + RealtimeTraceStore.shared.record( + stage: "albumArtOfferReceived", + trackId: id, + payloadType: "albumArtOffer", + result: "received" + ) if !authoritativeAlbumArtID.isEmpty, id != authoritativeAlbumArtID { deferredAlbumArtOfferWorkItem?.cancel() @@ -256,34 +384,53 @@ 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 { + RealtimeTraceStore.shared.record( + stage: "artworkCacheHit", + trackId: id, + payloadType: "hq", + result: "hit" + ) 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) @@ -292,6 +439,12 @@ final class AlbumArtReceiver { } else if let image = previewValidation.image, previewValidation.valid, !previewNeedsRefresh { + RealtimeTraceStore.shared.record( + stage: "artworkCacheHit", + trackId: id, + payloadType: "preview", + result: "hit" + ) cancelAlbumArtFallback() setAlbumArtDisplay(image: image, id: id, quality: .preview, reason: "offer cacheHit") delegate?.albumArtPublishLiveArtwork(image: image, key: id, reason: "cacheHit") @@ -344,14 +497,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 @@ -469,22 +625,30 @@ final class AlbumArtReceiver { binaryAlbumArtGeneration = generation binaryAlbumArtLastPublishedProgressBucket = 0 startBinaryAlbumArtTransferSession(id: id, quality: quality, size: size, chunks: chunks) + RealtimeTraceStore.shared.record( + stage: quality == "preview" ? "previewTransferStart" : "hqTransferStart", + trackId: id, + generation: generation, + transferId: transferId, + payloadType: quality, + chunkCount: chunks, + result: "accepted" + ) let message = "[AlbumArtBinary] start chunks=\(chunks)" log(message) consoleLog(message) } 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 +668,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 +805,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 +846,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 +858,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 +865,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 +976,11 @@ final class AlbumArtReceiver { binaryAlbumArtRetryCounts.removeAll() requestedAlbumArtKeys.removeAll() requestedHqAlbumArtIDs.removeAll() + forceRefreshAlbumArtIDs.removeAll() + sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() cancelHqAlbumArtRequest() + cancelProvisionalAlbumArtRefresh() predictivePendingHqId = "" notifyStateChanged() } @@ -828,6 +1007,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 +1650,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 +1774,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 +1798,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 +1831,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 } - guard artworkDisplayQuality < .hq else { + 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 forceRefresh || artworkDisplayQuality < .hq else { if predictive { recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) } @@ -1539,7 +1908,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 +1929,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 +1952,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 +1988,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 +2018,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 + ) } - let previewValidation = validateAlbumArtCache(id: id, preferredQuality: "preview") - if let image = previewValidation.image, - previewValidation.shouldDisplayWhileRefreshing { - return (image, "preview") + if let image { + ArtworkImageCache.shared.store( + image, + artworkId: id, + quality: resolvedQuality, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) } - let legacyValidation = validateAlbumArtCache(id: id, preferredQuality: nil) - if let image = legacyValidation.image, - legacyValidation.shouldDisplayWhileRefreshing { - return (image, legacyValidation.quality) + 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 + } + + 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? { @@ -1688,6 +2256,12 @@ final class AlbumArtReceiver { displayedAlbumArtID = id artworkDisplayQuality = quality currentCachedAlbumArtQuality = quality.label + RealtimeTraceStore.shared.record( + stage: quality >= .hq ? "hqPublished" : "previewPublished", + trackId: id, + payloadType: quality.label, + result: "published" + ) updateArtworkEnhancementStatus(message: reason) if previous != quality { log( @@ -1723,13 +2297,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 +2494,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 +2539,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 +2569,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 +2594,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 +2623,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 +2637,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 +2651,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 +2964,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..1a5126e --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift @@ -0,0 +1,1194 @@ +import Combine +import Foundation +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 mediaCacheValidationV1 = Self(rawValue: 1 << 3) + static let all: Self = [ + .statusMetaV1, + .structuredErrorV1, + .mediaLoadStateV1, + .mediaCacheValidationV1 + ] +} + +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 + } +} + +enum TrackIdentityGenerationDecision: Equatable, Sendable { + case accept + case acceptSessionReset + case rejectStale + case rejectConflict +} + +enum TrackIdentityGenerationPolicy { + static func decision( + currentTrackId: String, + currentGeneration: Int64, + currentSessionId: String, + incomingTrackId: String, + incomingGeneration: Int64, + incomingSessionId: String + ) -> TrackIdentityGenerationDecision { + guard currentGeneration > 0, incomingGeneration > 0 else { + return .accept + } + let currentSession = normalizedSessionId(currentSessionId) + let incomingSession = normalizedSessionId(incomingSessionId) + if !currentSession.isEmpty, + !incomingSession.isEmpty, + currentSession != incomingSession { + return .acceptSessionReset + } + if incomingGeneration < currentGeneration { + return .rejectStale + } + if incomingGeneration == currentGeneration, + !currentTrackId.isEmpty, + !incomingTrackId.isEmpty, + currentTrackId != incomingTrackId { + return .rejectConflict + } + return .accept + } + + private static func normalizedSessionId(_ value: String) -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized == "-" ? "" : normalized + } +} + +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 + } + } +} + +enum CurrentWordOrderingDecision: String, Equatable { + case accept = "ACCEPT" + case generationMismatch = "GENERATION_MISMATCH" + case sequenceOld = "SEQUENCE_OLD" + case positionStale = "POSITION_STALE" + case duplicate = "DUPLICATE" +} + +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 { + decision( + generation: incomingGeneration, + sequence: incomingSequence, + positionMs: incomingPositionMs + ) == .accept + } + + mutating func decision( + generation incomingGeneration: Int64, + sequence incomingSequence: Int64, + positionMs incomingPositionMs: Int64 + ) -> CurrentWordOrderingDecision { + if incomingGeneration > 0, incomingSequence > 0 { + if incomingGeneration < generation { + return .generationMismatch + } + if incomingGeneration == generation, incomingSequence < sequence { + return .sequenceOld + } + if incomingGeneration == generation, incomingSequence == sequence { + return .duplicate + } + } + if positionMs >= 0, + incomingPositionMs < positionMs, + positionMs - incomingPositionMs <= 1_500 { + return .positionStale + } + if incomingGeneration > 0, incomingSequence > 0 { + generation = incomingGeneration + sequence = incomingSequence + } + positionMs = incomingPositionMs + return .accept + } + + mutating func reset() { + generation = -1 + sequence = -1 + positionMs = -1 + } +} + +enum RealtimeHandoffTriggerType: String, Equatable { + case iosNext = "IOS_NEXT" + case iosPrevious = "IOS_PREVIOUS" + case sonyMediaSessionNext = "SONY_MEDIA_SESSION_NEXT" + case naturalAutoplay = "NATURAL_AUTOPLAY" + case sonyLocalControl = "SONY_LOCAL_CONTROL" + case unknown = "UNKNOWN" +} + +struct RealtimeHandoffContext: Equatable { + let handoffId: String + let triggerType: RealtimeHandoffTriggerType + let commandSeq: Int64? + let commandType: String? + let createdMonoMs: Int64 + var trackId: String? + var generation: Int64? +} + +struct RealtimeHandoffCorrelation { + private static let pendingTTLms: Int64 = 15_000 + private(set) var pending: RealtimeHandoffContext? + private(set) var current: RealtimeHandoffContext? + private var nextLocalId: UInt64 = 0 + + mutating func registerCommand( + sequence: Int64, + command: String, + monoMs: Int64 + ) -> RealtimeHandoffContext? { + let trigger: RealtimeHandoffTriggerType + switch command { + case "NEXT": trigger = .iosNext + case "PREVIOUS": trigger = .iosPrevious + default: return nil + } + let context = RealtimeHandoffContext( + handoffId: "command-\(sequence)", + triggerType: trigger, + commandSeq: sequence, + commandType: command, + createdMonoMs: monoMs, + trackId: nil, + generation: nil + ) + pending = context + return context + } + + mutating func contextForInbound(monoMs: Int64) -> RealtimeHandoffContext? { + expirePending(monoMs: monoMs) + return pending ?? current + } + + func context(for trackId: String) -> RealtimeHandoffContext? { + guard current?.trackId == trackId else { return nil } + return current + } + + func commandContext(sequence: Int64) -> RealtimeHandoffContext? { + if pending?.commandSeq == sequence { return pending } + if current?.commandSeq == sequence { return current } + return nil + } + + mutating func acceptTrack( + trackId: String, + generation: Int64, + monoMs: Int64 + ) -> RealtimeHandoffContext { + expirePending(monoMs: monoMs) + var accepted = pending ?? localContext(monoMs: monoMs) + accepted.trackId = trackId + accepted.generation = generation + pending = nil + current = accepted + return accepted + } + + mutating func reset() { + pending = nil + current = nil + } + + private mutating func expirePending(monoMs: Int64) { + guard let pending, + monoMs - pending.createdMonoMs > Self.pendingTTLms else { return } + self.pending = nil + } + + private mutating func localContext(monoMs: Int64) -> RealtimeHandoffContext { + nextLocalId &+= 1 + return RealtimeHandoffContext( + handoffId: "ios-\(monoMs)-\(nextLocalId)", + triggerType: .unknown, + commandSeq: nil, + commandType: nil, + createdMonoMs: monoMs, + trackId: nil, + generation: nil + ) + } +} + +// MARK: - V4 realtime latency trace + +struct RealtimeTraceEvent: Equatable { + let sequence: UInt64 + let side: String + let stage: String + let monoMs: Int64 + let commandSeq: Int64? + let commandType: String? + let trackId: String? + let generation: Int64? + let transferId: String? + let payloadType: String? + let queueWaitMs: Int64? + let processingMs: Int64? + let chunkIndex: Int? + let chunkCount: Int? + let result: String? + let reason: String? + let handoffId: String? + let triggerType: String? + let positionAnchorMs: Int64? + let lineIndex: Int? + let wordTimingStatus: String? + let cacheSource: String? + let failureReason: String? + + var logLine: String { + let fields: [(String, String)] = [ + ("side", side), + ("stage", stage), + ("monoMs", String(monoMs)), + ("commandSeq", commandSeq.map(String.init) ?? "-"), + ("commandType", commandType ?? "-"), + ("trackId", trackId ?? "-"), + ("generation", generation.map(String.init) ?? "-"), + ("transferId", transferId ?? "-"), + ("payloadType", payloadType ?? "-"), + ("queueWaitMs", queueWaitMs.map(String.init) ?? "-"), + ("processingMs", processingMs.map(String.init) ?? "-"), + ("chunkIndex", chunkIndex.map(String.init) ?? "-"), + ("chunkCount", chunkCount.map(String.init) ?? "-"), + ("result", result ?? "-"), + ("reason", reason ?? "-"), + ("handoffId", handoffId ?? "-"), + ("triggerType", triggerType ?? "-"), + ("positionAnchorMs", positionAnchorMs.map(String.init) ?? "-"), + ("lineIndex", lineIndex.map(String.init) ?? "-"), + ("wordTimingStatus", wordTimingStatus ?? "-"), + ("cacheSource", cacheSource ?? "-"), + ("failureReason", failureReason ?? "-") + ] + return "[RealtimeTrace] " + fields + .map { "\($0.0)=\(Self.safe($0.1))" } + .joined(separator: " ") + } + + private static func safe(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union( + CharacterSet(charactersIn: "-_.:/") + ) + return String( + value.unicodeScalars.prefix(96).map { scalar in + allowed.contains(scalar) ? Character(String(scalar)) : "_" + } + ) + } +} + +final class RealtimeTraceBuffer { + typealias MonotonicClock = () -> Int64 + + let capacity: Int + private let clock: MonotonicClock + private let lock = NSLock() + private var storage: [RealtimeTraceEvent?] + private var nextIndex = 0 + private var storedCount = 0 + private var nextSequence: UInt64 = 0 + private var lastMonoMs: Int64 = 0 + + init( + capacity: Int = 2_048, + clock: @escaping MonotonicClock = { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + } + ) { + self.capacity = max(capacity, 1) + self.clock = clock + storage = Array(repeating: nil, count: max(capacity, 1)) + } + + @discardableResult + func append( + side: String = "ios", + stage: String, + monoMs suppliedMonoMs: Int64? = nil, + commandSeq: Int64? = nil, + commandType: String? = nil, + trackId: String? = nil, + generation: Int64? = nil, + transferId: String? = nil, + payloadType: String? = nil, + queueWaitMs: Int64? = nil, + processingMs: Int64? = nil, + chunkIndex: Int? = nil, + chunkCount: Int? = nil, + result: String? = nil, + reason: String? = nil, + handoffId: String? = nil, + triggerType: String? = nil, + positionAnchorMs: Int64? = nil, + lineIndex: Int? = nil, + wordTimingStatus: String? = nil, + cacheSource: String? = nil, + failureReason: String? = nil + ) -> RealtimeTraceEvent { + lock.lock() + defer { lock.unlock() } + let sampled = suppliedMonoMs ?? clock() + let monoMs = max(sampled, lastMonoMs) + lastMonoMs = monoMs + nextSequence &+= 1 + let event = RealtimeTraceEvent( + sequence: nextSequence, + side: side, + stage: stage, + monoMs: monoMs, + commandSeq: commandSeq, + commandType: commandType, + trackId: trackId, + generation: generation, + transferId: transferId, + payloadType: payloadType, + queueWaitMs: queueWaitMs, + processingMs: processingMs, + chunkIndex: chunkIndex, + chunkCount: chunkCount, + result: result, + reason: reason, + handoffId: handoffId, + triggerType: triggerType, + positionAnchorMs: positionAnchorMs, + lineIndex: lineIndex, + wordTimingStatus: wordTimingStatus, + cacheSource: cacheSource, + failureReason: failureReason + ) + storage[nextIndex] = event + nextIndex = (nextIndex + 1) % capacity + storedCount = min(storedCount + 1, capacity) + return event + } + + func snapshot() -> [RealtimeTraceEvent] { + lock.lock() + defer { lock.unlock() } + guard storedCount > 0 else { return [] } + let start = storedCount == capacity ? nextIndex : 0 + return (0.. Void + + init( + capacity: Int = 2_048, + enabled: Bool = { +#if DEBUG + true +#else + false +#endif + }(), + clock: @escaping RealtimeTraceBuffer.MonotonicClock = { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + }, + logSink: @escaping (String) -> Void = { AppLogStore.shared.append($0) } + ) { + buffer = RealtimeTraceBuffer(capacity: capacity, clock: clock) + self.enabled = enabled + self.logSink = logSink + } + + @discardableResult + func record( + stage: String, + monoMs: Int64? = nil, + commandSeq: Int64? = nil, + commandType: String? = nil, + trackId: String? = nil, + generation: Int64? = nil, + transferId: String? = nil, + payloadType: String? = nil, + queueWaitMs: Int64? = nil, + processingMs: Int64? = nil, + chunkIndex: Int? = nil, + chunkCount: Int? = nil, + result: String? = nil, + reason: String? = nil, + handoffId: String? = nil, + triggerType: String? = nil, + positionAnchorMs: Int64? = nil, + lineIndex: Int? = nil, + wordTimingStatus: String? = nil, + cacheSource: String? = nil, + failureReason: String? = nil + ) -> RealtimeTraceEvent? { + guard enabled else { return nil } + let event = buffer.append( + stage: stage, + monoMs: monoMs, + commandSeq: commandSeq, + commandType: commandType, + trackId: trackId, + generation: generation, + transferId: transferId, + payloadType: payloadType, + queueWaitMs: queueWaitMs, + processingMs: processingMs, + chunkIndex: chunkIndex, + chunkCount: chunkCount, + result: result, + reason: reason, + handoffId: handoffId, + triggerType: triggerType, + positionAnchorMs: positionAnchorMs, + lineIndex: lineIndex, + wordTimingStatus: wordTimingStatus, + cacheSource: cacheSource, + failureReason: failureReason + ) + logSink(event.logLine) + return event + } + + func summary() -> RealtimeTraceSummary { + RealtimeTraceSummary(events: buffer.snapshot()) + } +} + +enum RealtimeTraceStatistics { + static func percentile(_ values: [Int64], percentile: Double) -> Double? { + guard !values.isEmpty else { return nil } + let sorted = values.sorted() + let clamped = min(max(percentile, 0), 100) + let rank = clamped / 100 * Double(sorted.count - 1) + let lower = Int(rank.rounded(.down)) + let upper = Int(rank.rounded(.up)) + guard lower != upper else { return Double(sorted[lower]) } + let fraction = rank - Double(lower) + return Double(sorted[lower]) + + (Double(sorted[upper] - sorted[lower]) * fraction) + } +} + +enum RealtimeLatencyPolicy { + static func crossDeviceDuration( + startMonoMs: Int64, + endMonoMs: Int64, + clockTrusted: Bool + ) -> Int64? { + guard clockTrusted, endMonoMs >= startMonoMs else { return nil } + return endMonoMs - startMonoMs + } +} + +struct RealtimeTraceSummary: Equatable { + let eventCount: Int + let missingResultCount: Int + let latestMonoMs: Int64? + let stageCounts: [String: Int] + + init(events: [RealtimeTraceEvent]) { + eventCount = events.count + missingResultCount = events.filter { $0.result == nil }.count + latestMonoMs = events.last?.monoMs + stageCounts = Dictionary(grouping: events, by: \.stage) + .mapValues(\.count) + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index eb9bb89..b8457d4 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,132 @@ 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 CONNECTION_RECOVERY_PRESENTATION_GRACE_MS: Int64 = 8_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 reconnectPreservingPresentation + case reconnect +} + +enum CommandWriteTimeoutPolicy { + static func action( + appIsActive: Bool, + transportReady: Bool, + linkRecentlyActive: Bool, + timeoutCountAfterIncrement: Int, + reconnectThreshold: Int + ) -> CommandWriteTimeoutAction { + guard appIsActive else { return .suspendUntilForeground } + guard transportReady else { return .reconnect } + if timeoutCountAfterIncrement < reconnectThreshold { + return .extendWithoutAdvancingQueue + } + return linkRecentlyActive ? .reconnectPreservingPresentation : .reconnect + } +} + +enum CommandWriteQueuePolicy { + static func shouldDropPending( + existingCommand: String, + existingIsProtected: Bool, + incomingCommand: String, + incomingIsControl: Bool + ) -> Bool { + guard !existingIsProtected else { return false } + guard existingCommand == "GET_PLAYBACK_STATE" else { return false } + return incomingIsControl || incomingCommand == existingCommand + } +} + +enum CommandRefreshPolicy { + static func fallbackDelay(for command: String) -> TimeInterval { + switch command { + case "NEXT", "PREVIOUS": + // QQ Music exposes the new MediaSession identity around 0.8s p95. + // An earlier GET_PLAYBACK_STATE usually returns the old track and + // competes with the authoritative TrackInfo/artwork push. + return 1.0 + default: + return 0.5 + } + } +} + +enum ClockSyncProbeMediaPolicy { + static func shouldDefer( + isFullLyricsReceiving: Bool, + artworkTransferState: String + ) -> Bool { + if isFullLyricsReceiving { + return true + } + switch artworkTransferState.lowercased() { + case "receiving", "decoding": + return true + default: + return false + } + } +} + +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 +221,31 @@ 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 +} + +private struct DeferredClockSyncProbeBatch { + let count: Int + let reason: String +} + +final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { private static let logTimestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -111,42 +255,118 @@ 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() + if oldValue, !isFullLyricsReceiving { + resumeDeferredClockSyncProbesIfPossible() + } + } + } + @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 +375,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,37 +397,99 @@ 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? private var sonyCommandCharacteristic: CBCharacteristic? private var sonyStatusCharacteristic: CBCharacteristic? private var commandSeq: UInt64 = 0 + private var realtimeHandoffCorrelation = RealtimeHandoffCorrelation() private var commandWriteInflight: [CommandWriteInfo] = [] private var pendingCommandWrites: [PendingCommandWrite] = [] + private var playbackStateRefreshWorkItem: DispatchWorkItem? private var commandWriteTimeoutWorkItem: DispatchWorkItem? + private var consecutiveCommandWriteTimeouts = 0 private var volumeWriteInFlightSeq: UInt64? private var lastVolumeSendAtMs: Int64 = 0 private var lastVolumeRequestedValue: Int? @@ -220,19 +506,47 @@ 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 = "" + func recordNowPlayingStateConsumed() { + let handoff = realtimeHandoffCorrelation.context(for: currentTrackID) + RealtimeTraceStore.shared.record( + stage: "nowPlayingStateConsumed", + trackId: currentTrackID, + generation: currentTrackGeneration, + payloadType: "trackIdentity", + result: "changed", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) + } + private var currentTrackGeneration: Int64 = 0 + private var currentTrackGenerationSessionId = "" 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 currentFullLyricsCacheEntry: FullLyricsCacheEntry? + private var currentFullLyricsCacheDescriptor: FullLyricsCacheValidationDescriptor? + private var fullLyricsCacheValidationRetryTrackIDs: Set = [] private var fullLyricsUnavailableTrackIDs: Set = [] private var fullLyricsDelayedRetryTrackIDs: Set = [] private var fullLyricsOptionalRefreshTrackIDs: Set = [] @@ -241,11 +555,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? @@ -262,6 +584,9 @@ final class BLETestManager: NSObject, ObservableObject { #if DEBUG private var trackMatrixV31RunID = "" private var trackMatrixV31Active = false + private var realtimeV4RunID = "" + private var realtimeV4Active = false + private var realtimeV4KeepsScreenAwake = false #endif private var remoteLogExpectedChunks = 0 private var remoteLogExpectedLines = 0 @@ -278,12 +603,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 +632,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 +647,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,21 +660,39 @@ 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 deferredClockSyncProbeBatch: DeferredClockSyncProbeBatch? + 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? + private var connectionRecoveryPresentationWorkItem: DispatchWorkItem? + private var preservesConnectedPresentationDuringRecovery = false private struct CommandWriteInfo { let seq: UInt64 let cmd: String let writeCalledAtMs: Int64 + let writeCalledMonoMs: Int64 } private struct PendingCommandWrite { @@ -348,11 +701,21 @@ final class BLETestManager: NSObject, ObservableObject { let data: Data let payloadText: String let enqueuedAtMs: Int64 + let enqueuedMonoMs: Int64 let isControl: Bool let volumeValue: Int? 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 +741,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 +756,7 @@ final class BLETestManager: NSObject, ObservableObject { override init() { super.init() - preferences.load() + syncAllStores() syncPreferencesStateFromStore() logAppExperienceModeLoaded() log("[BLE-iOS] app log store ready") @@ -398,13 +766,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 +972,57 @@ 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 + } + if transfer.state != "receiving", transfer.state != "decoding" { + resumeDeferredClockSyncProbesIfPossible() + } } private func logAppExperienceModeLoaded() { @@ -449,9 +1039,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 +1060,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 +1080,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") @@ -491,10 +1094,50 @@ final class BLETestManager: NSObject, ObservableObject { if arguments.contains("--smoke-track-matrix-v31") { scheduleTrackMatrixV31SmokeTest() } + if arguments.contains("--smoke-realtime-v4") { + let requestedRuns = arguments.first { + $0.hasPrefix("--smoke-realtime-runs=") + } + .flatMap { Int($0.split(separator: "=").last ?? "") } ?? 30 + let mode = arguments.first { + $0.hasPrefix("--smoke-realtime-mode=") + } + .map { String($0.split(separator: "=").last ?? "next") } ?? "next" + scheduleRealtimeV4SmokeTest( + runs: min(max(requestedRuns, 1), 500), + mode: mode, + fastSwitch: arguments.contains("--smoke-realtime-fast-switch") + ) + } #endif } #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\"}]}" @@ -532,6 +1175,164 @@ final class BLETestManager: NSObject, ObservableObject { log("[ProtocolSelfTest] fullLyricsV2 \(passed ? "PASS" : "FAIL")") } + private func scheduleRealtimeV4SmokeTest( + runs: Int, + mode: String, + fastSwitch: Bool + ) { + let runID = "\(currentTimeMs())" + realtimeV4RunID = runID + realtimeV4Active = true + realtimeV4KeepsScreenAwake = mode != "natural" + UIApplication.shared.isIdleTimerDisabled = realtimeV4KeepsScreenAwake + RealtimeTraceStore.shared.buffer.clear() + AppLogStore.shared.clear() + log( + "[RealtimeV4] scheduled runId=\(runID) runs=\(runs) " + + "mode=\(mode) fastSwitch=\(fastSwitch)" + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.runRealtimeV4WhenReady( + attempt: 0, + runID: runID, + runs: runs, + mode: mode, + fastSwitch: fastSwitch + ) + } + } + + private func runRealtimeV4WhenReady( + attempt: Int, + runID: String, + runs: Int, + mode: String, + fastSwitch: Bool + ) { + guard realtimeV4Active, realtimeV4RunID == runID else { return } + let requiresForeground = mode != "natural" + // Clock sync quality controls only cross-device duration eligibility. + // Same-device handoff segments remain valid and must still be sampled. + let traceProtocolReady = serverProtocolVersion >= 3 + let ready = sonyPeripheral?.state == .connected && + sonyCommandCharacteristic != nil && + sonyStatusCharacteristic != nil && + isConnectionHealthyOrSuspect && + !currentTrackID.isEmpty && + traceProtocolReady && + (!requiresForeground || appLifecycleState == "active") + guard ready else { + if attempt >= 40 { + finishRealtimeV4SmokeTest( + runID: runID, + message: "[RealtimeV4] abort runId=\(runID) reason=not_ready" + ) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.runRealtimeV4WhenReady( + attempt: attempt + 1, + runID: runID, + runs: runs, + mode: mode, + fastSwitch: fastSwitch + ) + } + return + } + let maximumWriteLength = sonyPeripheral?.maximumWriteValueLength(for: .withResponse) ?? 0 + let negotiatedMTU = maximumWriteLength > 0 ? maximumWriteLength + 3 : 0 + log( + "[RealtimeV4] start runId=\(runID) runs=\(runs) " + + "mode=\(mode) fastSwitch=\(fastSwitch) " + + "protocolVersion=\(serverProtocolVersion) mtu=\(negotiatedMTU) " + + "clockSyncTrusted=\(lyricClockSyncConfident)" + ) + if mode == "auto" { + log("[RealtimeV4] observing Sony-local track changes runId=\(runID)") + let observationSeconds = TimeInterval(runs * 4 + 16) + DispatchQueue.main.asyncAfter(deadline: .now() + observationSeconds) { [weak self] in + self?.finishRealtimeV4SmokeTest( + runID: runID, + message: "[RealtimeV4] end runId=\(runID) observed=\(runs)" + ) + } + return + } + if mode == "natural" { + finishRealtimeV4SmokeTest( + runID: runID, + message: "[RealtimeV4] observing Sony-local track changes runId=\(runID)" + ) + return + } + runRealtimeV4Command( + index: 0, + runID: runID, + runs: runs, + mode: mode, + interval: fastSwitch ? 0.65 : 4.0 + ) + } + + private func runRealtimeV4Command( + index: Int, + runID: String, + runs: Int, + mode: String, + interval: TimeInterval + ) { + guard realtimeV4Active, realtimeV4RunID == runID else { return } + guard appLifecycleState == "active" else { + finishRealtimeV4SmokeTest( + runID: runID, + message: "[RealtimeV4] abort runId=\(runID) reason=left_foreground " + + "completed=\(index)" + ) + return + } + guard index < runs else { + finishRealtimeV4SmokeTest( + runID: runID, + message: "[RealtimeV4] end runId=\(runID) completed=\(runs)" + ) + return + } + let command: String + switch mode { + case "previous": + command = "PREVIOUS" + case "fast": + command = index.isMultiple(of: 2) ? "NEXT" : "PREVIOUS" + default: + command = "NEXT" + } + log( + "[RealtimeV4] command runId=\(runID) index=\(index + 1) " + + "cmd=\(command) trackId=\(currentTrackID)" + ) + sendUserCommand(cmd: command) + DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in + self?.runRealtimeV4Command( + index: index + 1, + runID: runID, + runs: runs, + mode: mode, + interval: interval + ) + } + } + + private func finishRealtimeV4SmokeTest(runID: String, message: String) { + guard realtimeV4RunID == runID else { return } + log(message) + realtimeV4Active = false + if realtimeV4KeepsScreenAwake { + UIApplication.shared.isIdleTimerDisabled = false + realtimeV4KeepsScreenAwake = false + } + } + private static func dataFromHex(_ text: String) -> Data { var data = Data() var index = text.startIndex @@ -783,7 +1584,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 +1596,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 +1743,8 @@ final class BLETestManager: NSObject, ObservableObject { healthCheckWorkItem?.cancel() healthProbeTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem?.cancel() + foregroundInflightSettleWorkItem?.cancel() NotificationCenter.default.removeObserver(self) } @@ -991,6 +1799,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,7 +1815,15 @@ final class BLETestManager: NSObject, ObservableObject { performHardReconnect(reason: "manual force reconnect", manual: true) } - private func performHardReconnect(reason: String, manual: Bool) { + func resyncCurrentPlaybackFromDevice() { + syncAfterReconnect(reason: "device detail manual resync") + } + + private func performHardReconnect( + reason: String, + manual: Bool, + preserveConnectedPresentation: Bool = false + ) { let now = Date() if !manual, let lastHardReconnectAt, @@ -1009,7 +1832,17 @@ final class BLETestManager: NSObject, ObservableObject { setConnectionHealth(.stale, reason: "hard reconnect rate limited") return } + if preserveConnectedPresentation { + beginConnectedRecoveryPresentation(reason: reason) + } else { + finishConnectedRecoveryPresentation( + reason: "visible hard reconnect", + refresh: false + ) + } lastHardReconnectAt = now + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil connectionHealthHardReconnectCount += 1 log("[BLE-Health] hard reconnect reason=\(reason)") cancelPendingReconnect(reason: manual ? "manual hard reconnect" : "force reconnect") @@ -1034,7 +1867,9 @@ final class BLETestManager: NSObject, ObservableObject { } clearConnectionTransports(reason: "force reconnect") sonyPeripheral = nil - connectedDeviceName = "-" + if !preservesConnectedPresentationDuringRecovery { + connectedDeviceName = "-" + } setConnectionHealth(.disconnected, reason: reason) setStatus("正在重新连接") refreshConnectionDisplayState(reason: "hard reconnect \(reason)") @@ -1055,17 +1890,17 @@ final class BLETestManager: NSObject, ObservableObject { func sendPlayPause() { sendUserCommand(cmd: "PLAY_PAUSE") - refreshPlaybackState(after: 0.5) + refreshPlaybackState(after: CommandRefreshPolicy.fallbackDelay(for: "PLAY_PAUSE")) } func sendNext() { sendUserCommand(cmd: "NEXT") - refreshPlaybackState(after: 0.5) + refreshPlaybackState(after: CommandRefreshPolicy.fallbackDelay(for: "NEXT")) } func sendPrevious() { sendUserCommand(cmd: "PREVIOUS") - refreshPlaybackState(after: 0.5) + refreshPlaybackState(after: CommandRefreshPolicy.fallbackDelay(for: "PREVIOUS")) } func sendVolumeUp() { @@ -1140,16 +1975,60 @@ final class BLETestManager: NSObject, ObservableObject { } } - @discardableResult - func requestCurrentHqAlbumArt() -> Bool { - albumArtReceiver.requestCurrentHqAlbumArt() - } - - @discardableResult - func forceRefreshCurrentAlbumArt() -> Bool { - albumArtReceiver.forceRefreshCurrentAlbumArt() - } - + 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(forceRefresh: Bool = false) -> Bool { + albumArtReceiver.requestCurrentHqAlbumArt(forceRefresh: forceRefresh) + } + + @discardableResult + func forceRefreshCurrentAlbumArt() -> Bool { + albumArtReceiver.forceRefreshCurrentAlbumArt() + } + func noteNowPlayingDiagnosticsCopied(trackId: String) { log("[NowDiag] copied diagnostics trackId=\(trackId)") } @@ -1230,7 +2109,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 +2128,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 +2158,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 +2219,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 +2395,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))" ) @@ -1507,7 +2543,24 @@ final class BLETestManager: NSObject, ObservableObject { } private func sendUserCommand(cmd: String, extra: [String: Any] = [:]) { + playbackStateRefreshWorkItem?.cancel() + playbackStateRefreshWorkItem = nil let seq = nextCommandSeq() + let intentMonoMs = monotonicTimeMs() + let handoff = realtimeHandoffCorrelation.registerCommand( + sequence: Int64(seq), + command: cmd, + monoMs: intentMonoMs + ) + RealtimeTraceStore.shared.record( + stage: "commandIntent", + monoMs: intentMonoMs, + commandSeq: Int64(seq), + commandType: cmd, + result: "accepted", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) ctrlLog("[CTRL-iOS] tap seq=\(seq) cmd=\(cmd) uiTimeMs=\(currentTimeMs())") sendCommand(cmd: cmd, extra: extra, seq: seq) } @@ -1521,7 +2574,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" && cmd != "GET_FULL_LYRICS" { + payload["time"] = startMs + } payload["seq"] = seq let connected = sonyPeripheral?.state == .connected @@ -1554,7 +2612,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)") @@ -1571,6 +2629,7 @@ final class BLETestManager: NSObject, ObservableObject { data: data, payloadText: text, enqueuedAtMs: startMs, + enqueuedMonoMs: monotonicTimeMs(), isControl: isControlCommand(cmd), volumeValue: nil, volumeReason: nil @@ -1579,12 +2638,52 @@ final class BLETestManager: NSObject, ObservableObject { } private func enqueueCommandWrite(_ request: PendingCommandWrite) { - if request.isControl, + let protectedSequences = Set( + [foregroundValidationCommandSeq, healthProbeCommandSeq].compactMap { $0 } + ) + let pendingBeforeCoalescing = pendingCommandWrites.count + pendingCommandWrites.removeAll { existing in + CommandWriteQueuePolicy.shouldDropPending( + existingCommand: existing.cmd, + existingIsProtected: protectedSequences.contains(existing.seq), + incomingCommand: request.cmd, + incomingIsControl: request.isControl + ) + } + let supersededCount = pendingBeforeCoalescing - pendingCommandWrites.count + if supersededCount > 0 { + ctrlLog( + "[CTRL-iOS] superseded playback refresh count=\(supersededCount) " + + "incomingSeq=\(request.seq) incomingCmd=\(request.cmd)" + ) + RealtimeTraceStore.shared.record( + stage: "commandRefreshSuperseded", + commandSeq: Int64(request.seq), + commandType: request.cmd, + processingMs: Int64(supersededCount), + result: "dropped", + reason: "control_or_newer_refresh" + ) + } + 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 { pendingCommandWrites.append(request) } + let handoff = realtimeHandoffCorrelation.commandContext(sequence: Int64(request.seq)) + RealtimeTraceStore.shared.record( + stage: "commandEnqueued", + commandSeq: Int64(request.seq), + commandType: request.cmd, + queueWaitMs: 0, + result: "queued", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) if !commandWriteInflight.isEmpty { ctrlLog( "[CTRL-iOS] write queued seq=\(request.seq) cmd=\(request.cmd) " + @@ -1595,6 +2694,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, @@ -1605,13 +2711,26 @@ final class BLETestManager: NSObject, ObservableObject { let request = pendingCommandWrites.removeFirst() let writeBeginMs = currentTimeMs() + let writeBeginMonoMs = monotonicTimeMs() commandWriteInflight.append( CommandWriteInfo( seq: request.seq, cmd: request.cmd, - writeCalledAtMs: writeBeginMs + writeCalledAtMs: writeBeginMs, + writeCalledMonoMs: writeBeginMonoMs ) ) + let handoff = realtimeHandoffCorrelation.commandContext(sequence: Int64(request.seq)) + RealtimeTraceStore.shared.record( + stage: "commandWriteStart", + monoMs: writeBeginMonoMs, + commandSeq: Int64(request.seq), + commandType: request.cmd, + queueWaitMs: max(writeBeginMonoMs - request.enqueuedMonoMs, 0), + result: "started", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) if request.cmd == "SET_VOLUME" { volumeWriteInFlightSeq = request.seq lastVolumeSendAtMs = writeBeginMs @@ -1639,24 +2758,48 @@ 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 + RealtimeTraceStore.shared.record( + stage: "commandWriteTimeout", + commandSeq: Int64(seq), + commandType: cmd, + processingMs: COMMAND_WRITE_CALLBACK_TIMEOUT_MS, + result: "timeout", + reason: "callback_timeout" ) + self.handleCommandWriteCallbackTimeout(seq: seq, cmd: cmd) } commandWriteTimeoutWorkItem = item DispatchQueue.main.asyncAfter( @@ -1665,6 +2808,67 @@ 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 recentLinkThresholdMs = isPlaying + ? CONNECTION_HEALTH_PLAYING_SUSPECT_MS + : CONNECTION_HEALTH_PAUSED_SUSPECT_MS + let linkRecentlyActive = transportReady && + recentNotifyAgeMs <= recentLinkThresholdMs + let nextTimeoutCount = consecutiveCommandWriteTimeouts + 1 + let action = CommandWriteTimeoutPolicy.action( + appIsActive: appLifecycleState == "active", + transportReady: transportReady, + linkRecentlyActive: linkRecentlyActive, + 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 .reconnectPreservingPresentation: + consecutiveCommandWriteTimeouts = nextTimeoutCount + ctrlLog( + "[CTRL-iOS] write path recovery keeps connected presentation " + + "seq=\(seq) cmd=\(cmd) recentNotifyAgeMs=\(recentNotifyAgeMs)" + ) + performHardReconnect( + reason: "command write callback timeout x\(nextTimeoutCount) cmd=\(cmd) seq=\(seq)", + manual: false, + preserveConnectedPresentation: true + ) + 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 +2879,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 +2906,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 +2924,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 +2938,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,12 +2967,149 @@ 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)" } + 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 + } + } + + 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 + ) + } + + 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 @@ -1762,8 +3129,9 @@ final class BLETestManager: NSObject, ObservableObject { 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 +3239,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 } @@ -1883,6 +3251,7 @@ final class BLETestManager: NSObject, ObservableObject { data: data, payloadText: text, enqueuedAtMs: startMs, + enqueuedMonoMs: monotonicTimeMs(), isControl: true, volumeValue: value, volumeReason: forceFinal ? "final" : reason @@ -2071,9 +3440,14 @@ final class BLETestManager: NSObject, ObservableObject { } private func refreshPlaybackState(after delay: TimeInterval) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendGetPlaybackState() + playbackStateRefreshWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + self.playbackStateRefreshWorkItem = nil + self.sendGetPlaybackState() } + playbackStateRefreshWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } private func refreshVolume(after delay: TimeInterval) { @@ -2139,10 +3513,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,72 +3552,322 @@ final class BLETestManager: NSObject, ObservableObject { Int64(Date().timeIntervalSince1970 * 1_000) } - private func isInStartupLoadWindow() -> Bool { - guard firstConnectionReadyAtMs > 0 else { return false } - return currentTimeMs() - firstConnectionReadyAtMs < 3_000 - } - - private var sonyCharacteristicsReady: Bool { - sonyPeripheral?.state == .connected && - sonyCommandCharacteristic != nil && - sonyStatusCharacteristic != nil + 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 + deferredClockSyncProbeBatch = 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 + deferredClockSyncProbeBatch = 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 var isSonyHealthyForControls: Bool { - sonyCharacteristicsReady && - connectionStatus == "已连接" && - isConnectionHealthyOrSuspect + private func scheduleClockSyncProbes(count: Int, reason: String) { + guard appLifecycleState == "active", count > 0 else { return } + if deferClockSyncProbesIfMediaBusy(count: count, reason: reason) { + return + } + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + for index in 0.. Bool { - switch cmd { - case "PLAY_PAUSE", "NEXT", "PREVIOUS", "VOLUME_UP", "VOLUME_DOWN", "SEEK_TO", "SET_VOLUME": - return true - default: + @discardableResult + private func deferClockSyncProbesIfMediaBusy(count: Int, reason: String) -> Bool { + let transfer = albumArtReceiver.transferSnapshot() + guard ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: isFullLyricsReceiving, + artworkTransferState: transfer.state + ) else { return false } - } - private func peripheralStateText(_ peripheral: CBPeripheral?) -> String { - guard let peripheral else { return "nil" } - switch peripheral.state { - case .connected: - return "connected" - case .connecting: - return "connecting" - case .disconnected: - return "disconnected" - case .disconnecting: - return "disconnecting" - @unknown default: - return "unknown" + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + let boundedCount = min(max(count, 1), CLOCK_SYNC_BOOTSTRAP_SAMPLE_COUNT) + let existing = deferredClockSyncProbeBatch + if existing == nil || boundedCount > (existing?.count ?? 0) { + deferredClockSyncProbeBatch = DeferredClockSyncProbeBatch( + count: boundedCount, + reason: reason + ) } - } - - private func updateConnectionHealthDebugFields() { - if let lastStatusNotifyAt { - connectionHealthLastNotifyAgeMs = Int64(Date().timeIntervalSince(lastStatusNotifyAt) * 1_000) + let mediaReason: String + if isFullLyricsReceiving { + mediaReason = "full_lyrics" } else { - connectionHealthLastNotifyAgeMs = -1 + let quality = transfer.quality.lowercased() + mediaReason = quality.isEmpty || quality == "-" + ? "artwork" + : "artwork_\(quality)" } - if let lastHealthProbeAt { - connectionHealthLastProbeAtText = "\(Int64(Date().timeIntervalSince(lastHealthProbeAt) * 1_000))ms ago" - } else { - connectionHealthLastProbeAtText = "-" + if existing == nil { + log( + "[ClockSync] probes deferred reason=\(reason) " + + "media=\(mediaReason) count=\(boundedCount)" + ) + RealtimeTraceStore.shared.record( + stage: "clockSyncProbeDeferred", + payloadType: "clockSync", + processingMs: Int64(boundedCount), + result: "deferred", + reason: mediaReason + ) } - connectionHealthProbeInFlight = healthProbeStartedAt != nil - connectionHealthAttemptId = connectionAttemptId.uuidString - connectionHealthPeripheralState = peripheralStateText(sonyPeripheral) - connectionHealthCharacteristicReady = sonyCharacteristicsReady + return true } - private func setConnectionHealth(_ state: ConnectionHealthState, reason: String) { - if connectionHealthState != state.rawValue { + private func resumeDeferredClockSyncProbesIfPossible() { + guard let deferred = deferredClockSyncProbeBatch, + appLifecycleState == "active", + serverSupportsClockSyncV1, + sonyCharacteristicsReady else { + return + } + let transfer = albumArtReceiver.transferSnapshot() + guard !ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: isFullLyricsReceiving, + artworkTransferState: transfer.state + ) else { + return + } + + deferredClockSyncProbeBatch = nil + log( + "[ClockSync] probes resumed reason=\(deferred.reason) " + + "count=\(deferred.count)" + ) + RealtimeTraceStore.shared.record( + stage: "clockSyncProbeResumed", + payloadType: "clockSync", + processingMs: Int64(deferred.count), + result: "resumed", + reason: "media_idle" + ) + scheduleClockSyncProbes(count: deferred.count, reason: deferred.reason) + } + + private func sendClockSyncProbe(reason: String, sample: Int, total: Int) { + guard appLifecycleState == "active", + serverSupportsClockSyncV1, + sonyCharacteristicsReady else { return } + let sequence = nextCommandSeq() + let sentAtElapsedMs = monotonicTimeMs() + let probe = ClockSyncProbe( + sequence: sequence, + clientSendElapsedMs: sentAtElapsedMs, + clientSendDate: Date() + ) + clockSyncProbes[String(sequence)] = probe + log( + "[ClockSync] probe sent seq=\(sequence) reason=\(reason) " + + "sample=\(sample)/\(total)" + ) + sendCommand( + cmd: "PING", + extra: [ + "clockSyncV1": true, + "clientSendElapsedMs": sentAtElapsedMs + ], + seq: sequence + ) + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(CLOCK_SYNC_PROBE_TTL_MS) / 1_000.0 + ) { [weak self] in + guard let self, + self.clockSyncProbes[String(sequence)]?.sequence == sequence else { + return + } + self.clockSyncProbes.removeValue(forKey: String(sequence)) + self.log("[ClockSync] probe expired seq=\(sequence)") + } + } + + @discardableResult + private func handleClockSyncPong(_ object: [String: Any]) -> 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 + } + + private var sonyCharacteristicsReady: Bool { + sonyPeripheral?.state == .connected && + sonyCommandCharacteristic != nil && + sonyStatusCharacteristic != nil + } + + private var isSonyHealthyForControls: Bool { + sonyCharacteristicsReady && + connectionStatus == "已连接" && + isConnectionHealthyOrSuspect + } + + private var isConnectionHealthyOrSuspect: Bool { + connectionHealthState == ConnectionHealthState.healthy.rawValue || + connectionHealthState == ConnectionHealthState.suspect.rawValue + } + + private func isControlCommand(_ cmd: String) -> Bool { + switch cmd { + case "PLAY_PAUSE", "NEXT", "PREVIOUS", "VOLUME_UP", "VOLUME_DOWN", "SEEK_TO", "SET_VOLUME": + return true + default: + return false + } + } + + private func peripheralStateText(_ peripheral: CBPeripheral?) -> String { + guard let peripheral else { return "nil" } + switch peripheral.state { + case .connected: + return "connected" + case .connecting: + return "connecting" + case .disconnected: + return "disconnected" + case .disconnecting: + return "disconnecting" + @unknown default: + return "unknown" + } + } + + private func updateConnectionHealthDebugFields() { + if let lastStatusNotifyAt { + connectionHealthLastNotifyAgeMs = Int64(Date().timeIntervalSince(lastStatusNotifyAt) * 1_000) + } else { + connectionHealthLastNotifyAgeMs = -1 + } + if let lastHealthProbeAt { + connectionHealthLastProbeAtText = "\(Int64(Date().timeIntervalSince(lastHealthProbeAt) * 1_000))ms ago" + } else { + connectionHealthLastProbeAtText = "-" + } + connectionHealthProbeInFlight = healthProbeStartedAt != nil + connectionHealthAttemptId = connectionAttemptId.uuidString + connectionHealthPeripheralState = peripheralStateText(sonyPeripheral) + connectionHealthCharacteristicReady = sonyCharacteristicsReady + } + + private func setConnectionHealth(_ state: ConnectionHealthState, reason: String) { + if connectionHealthState != state.rawValue { log("[BLE-Health] state=\(state.rawValue) reason=\(reason)") if state == .suspect { connectionHealthSuspectCount += 1 @@ -2242,7 +3883,10 @@ final class BLETestManager: NSObject, ObservableObject { private func refreshConnectionDisplayState(reason: String, explicitDisconnect: Bool = false) { let desired: ConnectionDisplayState - if connectionHealthState == ConnectionHealthState.stale.rawValue || isReconnectInProgress { + if preservesConnectedPresentationDuringRecovery, + connectionDisplayState == ConnectionDisplayState.connected.rawValue { + desired = .connected + } else if connectionHealthState == ConnectionHealthState.stale.rawValue || isReconnectInProgress { desired = .reconnecting } else if connectionStatus == "已连接", connectionHealthState == ConnectionHealthState.healthy.rawValue || @@ -2262,6 +3906,43 @@ final class BLETestManager: NSObject, ObservableObject { setConnectionDisplayState(desired, reason: reason, explicitDisconnect: explicitDisconnect) } + private func beginConnectedRecoveryPresentation(reason: String) { + guard connectionDisplayState == ConnectionDisplayState.connected.rawValue else { + return + } + preservesConnectedPresentationDuringRecovery = true + connectionRecoveryPresentationWorkItem?.cancel() + log("[BLE-UIState] silent transport recovery started reason=\(reason)") + let item = DispatchWorkItem { [weak self] in + guard let self, + self.preservesConnectedPresentationDuringRecovery else { return } + self.preservesConnectedPresentationDuringRecovery = false + self.connectionRecoveryPresentationWorkItem = nil + self.log("[BLE-UIState] silent transport recovery grace expired") + self.refreshConnectionDisplayState(reason: "transport recovery grace expired") + } + connectionRecoveryPresentationWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + + Double(CONNECTION_RECOVERY_PRESENTATION_GRACE_MS) / 1_000.0, + execute: item + ) + } + + private func finishConnectedRecoveryPresentation( + reason: String, + refresh: Bool = true + ) { + connectionRecoveryPresentationWorkItem?.cancel() + connectionRecoveryPresentationWorkItem = nil + guard preservesConnectedPresentationDuringRecovery else { return } + preservesConnectedPresentationDuringRecovery = false + log("[BLE-UIState] silent transport recovery finished reason=\(reason)") + if refresh { + refreshConnectionDisplayState(reason: reason) + } + } + private func setConnectionDisplayState( _ state: ConnectionDisplayState, reason: String, @@ -2323,6 +4004,7 @@ final class BLETestManager: NSObject, ObservableObject { healthProbeTimeoutWorkItem = nil subscribeNotifyTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem = nil + healthProbeCommandSeq = nil healthProbeStartedAt = nil healthProbeFailureCount = 0 connectionHealthProbeInFlight = false @@ -2337,6 +4019,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 +4031,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 +4047,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 +4095,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 +4114,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 +4162,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 +4179,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 +4191,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,11 +4209,19 @@ final class BLETestManager: NSObject, ObservableObject { type == "historyPayloadChunk" || type == "lyricSecondaryPart" || type == "mediaFieldDumpChunk" || - type == "logChunk" + type == "logChunk" || + type == "currentWord" || + type == "link" } private func setAutoReconnectState(_ state: AutoReconnectState) { autoReconnectState = state.rawValue + if state == .connected { + finishConnectedRecoveryPresentation( + reason: "transport recovery synchronized", + refresh: false + ) + } if state == .reconnectScheduled { if reconnectScheduledAt == nil { reconnectScheduledAt = Date() @@ -2544,6 +4275,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 +4416,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,13 +4460,29 @@ 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 + playbackStateRefreshWorkItem?.cancel() + playbackStateRefreshWorkItem = 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() + realtimeHandoffCorrelation.reset() liveActivityControlInFlightSeq = nil liveActivityControlWriteStartedAtMs = 0 clearPendingVolume() @@ -2735,30 +4491,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 +4654,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 +4677,90 @@ 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 + playbackStateRefreshWorkItem?.cancel() + playbackStateRefreshWorkItem = 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 +4788,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 +5197,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,22 +5232,113 @@ extension BLETestManager: CBCentralManagerDelegate { } } - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String - ?? peripheral.name - ?? "Unknown" - log("[BLE] scan result name=\(name) rssi=\(RSSI)") - log("[BLE-Reconnect] didDiscover name=\(name) id=\(peripheral.identifier.uuidString)") - log("[BLE-iOS] didDiscover name=\(name) id=\(peripheral.identifier)") + 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 !isConnectingToSony else { return } - guard sonyPeripheral == nil || sonyPeripheral?.state == .disconnected else { - log("[BLE-Reconnect] ignore discover reason=active peripheral state=\(peripheralStateText(sonyPeripheral))") + 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, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? peripheral.name + ?? "Unknown" + log("[BLE] scan result name=\(name) rssi=\(RSSI)") + log("[BLE-Reconnect] didDiscover name=\(name) id=\(peripheral.identifier.uuidString)") + log("[BLE-iOS] didDiscover name=\(name) id=\(peripheral.identifier)") + + guard !isConnectingToSony else { return } + guard sonyPeripheral == nil || sonyPeripheral?.state == .disconnected else { + log("[BLE-Reconnect] ignore discover reason=active peripheral state=\(peripheralStateText(sonyPeripheral))") return } @@ -3243,6 +5362,8 @@ extension BLETestManager: CBCentralManagerDelegate { centralManager.cancelPeripheralConnection(peripheral) return } + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil isConnectingToSony = false updateAutoReconnectDebugFields() connectTimeoutWorkItem?.cancel() @@ -3254,7 +5375,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 +5413,9 @@ extension BLETestManager: CBCentralManagerDelegate { log("[BLE-Reconnect] ignore stale didFailToConnect id=\(peripheral.identifier.uuidString)") return } + finishConnectedRecoveryPresentation(reason: "transport recovery connect failed") + systemAutoReconnectInProgress = false + systemAutoReconnectStartedAt = nil isConnectingToSony = false connectTimeoutWorkItem?.cancel() connectTimeoutWorkItem = nil @@ -3307,34 +5435,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) } } @@ -3418,8 +5596,23 @@ extension BLETestManager: CBPeripheralDelegate { commandWriteTimeoutWorkItem = nil let completed = commandWriteInflight.isEmpty ? nil : commandWriteInflight.removeFirst() let didWriteMs = currentTimeMs() + let didWriteMonoMs = monotonicTimeMs() if let completed { let costMs = didWriteMs - completed.writeCalledAtMs + let handoff = realtimeHandoffCorrelation.commandContext( + sequence: Int64(completed.seq) + ) + RealtimeTraceStore.shared.record( + stage: "commandWriteCallback", + monoMs: didWriteMonoMs, + commandSeq: Int64(completed.seq), + commandType: completed.cmd, + processingMs: max(didWriteMonoMs - completed.writeCalledMonoMs, 0), + result: error == nil ? "success" : "failure", + reason: error == nil ? nil : "core_bluetooth_error", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) let errorText = error?.localizedDescription ?? "nil" ctrlLog( "[CTRL-iOS] didWrite seq=\(completed.seq) cmd=\(completed.cmd) " + @@ -3448,6 +5641,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 +5662,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,8 +5684,12 @@ extension BLETestManager: CBPeripheralDelegate { return } guard characteristic.uuid == BLEUUIDs.status else { return } + coreBluetoothRestoreInProgress = false + coreBluetoothRestoreTimeoutWorkItem?.cancel() + coreBluetoothRestoreTimeoutWorkItem = nil if let error { + finishConnectedRecoveryPresentation(reason: "notify subscribe failed") setStatus("未连接") log("[BLE] status notify subscription failed: \(error.localizedDescription)") log("[BLE-Reconnect] failed reason=notify subscribe error=\(error.localizedDescription)") @@ -3511,13 +5719,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 +5751,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 +5831,167 @@ 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 + let notifyReceivedMonoMs = monotonicTimeMs() + let pendingHandoff = realtimeHandoffCorrelation.contextForInbound( + monoMs: notifyReceivedMonoMs + ) + if data.first != 0xA1, data.first != 0xA2 { + RealtimeTraceStore.shared.record( + stage: "statusNotifyReceived", + payloadType: "statusJSON", + result: "received" + ) } - - 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 decodeStartedMonoMs = self.monotonicTimeMs() + RealtimeTraceStore.shared.record( + stage: "playbackDecodeStart", + monoMs: decodeStartedMonoMs, + payloadType: "statusJSON", + result: "started" + ) + 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) + let decodeEndedMonoMs = self.monotonicTimeMs() + if type == "playbackState" { + RealtimeTraceStore.shared.record( + stage: "playbackNotifyReceived", + monoMs: notifyReceivedMonoMs, + trackId: pendingHandoff?.trackId, + generation: pendingHandoff?.generation, + payloadType: "playbackState", + result: "received", + handoffId: pendingHandoff?.handoffId, + triggerType: pendingHandoff?.triggerType.rawValue + ) + } + RealtimeTraceStore.shared.record( + stage: "playbackDecodeEnd", + monoMs: decodeEndedMonoMs, + trackId: objectBox?.value["trackId"] as? String, + generation: Self.optionalInt64Value(objectBox?.value["generation"]), + payloadType: type ?? "invalidJSON", + processingMs: max(decodeEndedMonoMs - decodeStartedMonoMs, 0), + result: objectBox == nil ? "failure" : "success", + reason: objectBox == nil ? "json_decode_failed" : nil, + handoffId: pendingHandoff?.handoffId, + triggerType: pendingHandoff?.triggerType.rawValue + ) + 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,10 +6000,45 @@ 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 { + let lyricHandoff = self.realtimeHandoffCorrelation.context( + for: self.currentTrackID + ) + RealtimeTraceStore.shared.record( + stage: "lyricCurrentLineReceived", + trackId: self.currentTrackID, + generation: self.currentTrackGeneration, + payloadType: type, + result: lyric.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty ? "empty" : "received", + handoffId: lyricHandoff?.handoffId, + triggerType: lyricHandoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + failureReason: lyric.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty ? "NO_LINE_AT_POSITION" : nil + ) self.lyric = lyric let lyricEmpty = lyric.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if !lyricEmpty { + RealtimeTraceStore.shared.record( + stage: "lyricCurrentLineAccepted", + trackId: self.currentTrackID, + generation: self.currentTrackGeneration, + payloadType: type, + result: "accepted", + handoffId: lyricHandoff?.handoffId, + triggerType: lyricHandoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]) + ) + } if !self.currentTrackID.isEmpty { let nowMs = self.currentTimeMs() var details = @@ -3689,11 +6078,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 +6127,45 @@ extension BLETestManager: CBPeripheralDelegate { ) { self.updateLiveActivity(force: false, reason: "playbackState") } + self.scheduleLastNowPlayingSnapshotSave(reason: "playbackState") + let playbackHandoff = self.realtimeHandoffCorrelation.context( + for: self.currentTrackID + ) + RealtimeTraceStore.shared.record( + stage: "playbackStatePublished", + trackId: self.currentTrackID, + generation: self.currentTrackGeneration, + payloadType: type, + result: "published", + reason: "\(self.isPlaying ? "playing" : "paused")_\(self.appLifecycleState)", + handoffId: playbackHandoff?.handoffId, + triggerType: playbackHandoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]) + ) + if oldLyric != self.lyric { + RealtimeTraceStore.shared.record( + stage: "currentLyricPublished", + trackId: self.currentTrackID, + generation: self.currentTrackGeneration, + payloadType: type, + result: "published" + ) + RealtimeTraceStore.shared.record( + stage: "lyricCurrentLinePublished", + trackId: self.currentTrackID, + generation: self.currentTrackGeneration, + payloadType: type, + result: self.lyric.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty ? "empty" : "published", + handoffId: playbackHandoff?.handoffId, + triggerType: playbackHandoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + failureReason: self.lyric.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty ? "NO_LINE_AT_POSITION" : nil + ) + } case "currentWord": self.handleCurrentWord(object) @@ -3773,6 +6213,12 @@ extension BLETestManager: CBPeripheralDelegate { case "fullLyricsUnavailable": self.handleFullLyricsUnavailable(object) + case "fullLyricsCacheMetadata": + self.handleFullLyricsCacheMetadata(object) + + case "fullLyricsNotModified": + self.handleFullLyricsNotModified(object) + case "fullLyricsBinaryStart": self.handleFullLyricsBinaryStart(object) @@ -3809,8 +6255,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 +6466,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 +6665,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 { + playbackStateRefreshWorkItem?.cancel() + playbackStateRefreshWorkItem = nil + resetCurrentWordFence() lyric = "" fullLyricsTrackId = "" isFullLyricsCurrent = false requestedFullLyricsTrackIDs.removeAll() completedFullLyricsTrackIDs.removeAll() + currentFullLyricsCacheEntry = nil + currentFullLyricsCacheDescriptor = nil + fullLyricsCacheValidationRetryTrackIDs.removeAll() fullLyricsBinaryFallbackTrackIDs.removeAll() fullLyricsBinaryRetryCounts.removeAll() fullLyricsBinaryTransfer = nil @@ -4228,10 +6889,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 +6910,40 @@ extension BLETestManager: CBPeripheralDelegate { album = newAlbum if !trackID.isEmpty { currentTrackID = trackID + currentTrackGeneration = generation + if !serverSessionId.isEmpty, serverSessionId != "-" { + currentTrackGenerationSessionId = serverSessionId + } + let acceptedMonoMs = monotonicTimeMs() + let handoff = trackChanged + ? realtimeHandoffCorrelation.acceptTrack( + trackId: trackID, + generation: generation, + monoMs: acceptedMonoMs + ) + : realtimeHandoffCorrelation.context(for: trackID) + RealtimeTraceStore.shared.record( + stage: "trackIdentityAccepted", + monoMs: acceptedMonoMs, + trackId: trackID, + generation: generation, + payloadType: "trackInfo", + result: trackChanged ? "changed" : "refreshed", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue + ) + isShowingLastNowPlayingSnapshot = false + // A restored LastNowPlayingSnapshot can make the first authoritative + // trackInfo look like a same-track refresh. The snapshot contains + // display lyrics but not the cache-validation descriptor, so load + // the exact cache entry whenever that descriptor source is absent. + if trackChanged || currentFullLyricsCacheEntry?.trackId != trackID { + restoreFullLyricsCacheIfAvailable( + trackID: trackID, + title: newTitle, + artist: newArtist + ) + } let nowMs = currentTimeMs() lyricTraceTrackInfoAtMs[trackID] = nowMs log( @@ -4256,6 +6961,7 @@ extension BLETestManager: CBPeripheralDelegate { force: trackChanged, reason: trackChanged ? "trackInfo" : "trackInfoRefresh" ) + scheduleLastNowPlayingSnapshotSave(reason: "trackInfo", force: true) } private func clearNowPlayingForNoActiveTrack() { @@ -4273,6 +6979,9 @@ extension BLETestManager: CBPeripheralDelegate { resetFullLyricsTransfer() requestedFullLyricsTrackIDs.removeAll() completedFullLyricsTrackIDs.removeAll() + currentFullLyricsCacheEntry = nil + currentFullLyricsCacheDescriptor = nil + fullLyricsCacheValidationRetryTrackIDs.removeAll() fullLyricsBinaryFallbackTrackIDs.removeAll() fullLyricsBinaryRetryCounts.removeAll() fullLyricsBinaryTransfer = nil @@ -4285,12 +6994,22 @@ 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 + currentTrackGenerationSessionId = "" title = "-" artist = "-" album = "-" @@ -4302,6 +7021,7 @@ extension BLETestManager: CBPeripheralDelegate { lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = nil isPlaying = false + updateProgressTimerState() positionMs = 0 displayPositionMs = 0 seekPositionMs = 0 @@ -4309,12 +7029,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 +7098,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 +7168,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 } @@ -4532,17 +7296,51 @@ extension BLETestManager: CBPeripheralDelegate { } self.log(detail) self.log("[LyricsPerf] request fullLyrics trackId=\(trackID)") - var extra: [String: Any] = [ - "trackId": trackID, - "positionMs": self.displayPositionMs, - "includeWordsAroundCurrent": true - ] - if self.serverSupportsFullLyricsZlib, - !self.fullLyricsBinaryFallbackTrackIDs.contains(trackID) { - extra["format"] = "zlib-json-v1" + let validationDescriptor: FullLyricsCacheValidationDescriptor? + if !force, + let entry = self.currentFullLyricsCacheEntry, + entry.trackId == trackID, + self.negotiatedV3Features.contains(.mediaCacheValidationV1) { + validationDescriptor = entry.validationDescriptor + } else { + validationDescriptor = nil } - self.sendCommand( - cmd: "GET_FULL_LYRICS", + var extra: [String: Any] + if let descriptor = validationDescriptor { + extra = [ + "id": trackID, + "p": self.displayPositionMs, + "w": true + ] + descriptor.requestFields.forEach { extra[$0.key] = $0.value } + if self.serverSupportsFullLyricsZlib, + !self.fullLyricsBinaryFallbackTrackIDs.contains(trackID) { + extra["f"] = "z" + } + RealtimeTraceStore.shared.record( + stage: "cacheValidationRequest", + trackId: trackID, + generation: self.currentTrackGeneration, + payloadType: "fullLyrics", + result: "sent" + ) + self.log( + "[FullLyricsCacheValidation] request trackId=\(trackID) " + + "lines=\(descriptor.lineCount)" + ) + } else { + extra = [ + "trackId": trackID, + "positionMs": self.displayPositionMs, + "includeWordsAroundCurrent": true + ] + if self.serverSupportsFullLyricsZlib, + !self.fullLyricsBinaryFallbackTrackIDs.contains(trackID) { + extra["format"] = "zlib-json-v1" + } + } + self.sendCommand( + cmd: "GET_FULL_LYRICS", extra: extra ) self.scheduleFullLyricsRequestStartTimeout(trackID: trackID) @@ -4641,6 +7439,15 @@ extension BLETestManager: CBPeripheralDelegate { generation: Self.int64Value(object["generation"]), expectedCount: count ) + RealtimeTraceStore.shared.record( + stage: "lyricWindowStartReceived", + trackId: trackID, + generation: Self.optionalInt64Value(object["generation"]), + transferId: transferID, + payloadType: "lyricWindow", + chunkCount: count, + result: "accepted" + ) } private func handleLyricWindowChunk(_ object: [String: Any]) { @@ -4684,6 +7491,24 @@ extension BLETestManager: CBPeripheralDelegate { trackID: transfer.trackId, isFinal: false ) + RealtimeTraceStore.shared.record( + stage: "lyricWindowEndReceived", + trackId: transfer.trackId, + generation: transfer.generation, + transferId: transfer.transferId, + payloadType: "lyricWindow", + chunkCount: transfer.expectedCount, + result: "success" + ) + RealtimeTraceStore.shared.record( + stage: "lyricWindowPublished", + trackId: transfer.trackId, + generation: transfer.generation, + transferId: transfer.transferId, + payloadType: "lyricWindow", + result: "published" + ) + mediaLoadingState.lyric = .windowReady(lineCount: transfer.chunks.count) log("[LyricWindow-iOS] published lines=\(transfer.chunks.count)") } @@ -4730,7 +7555,20 @@ extension BLETestManager: CBPeripheralDelegate { expectedLineCount: count, expectedCRC32: crc ) + RealtimeTraceStore.shared.record( + stage: "fullLyricsStartReceived", + trackId: trackID, + generation: Self.optionalInt64Value(object["generation"] ?? object["g"]), + transferId: transferID, + payloadType: "fullLyricsBinary", + chunkCount: chunks, + result: "accepted" + ) isFullLyricsReceiving = true + mediaLoadingState.lyric = .fullLyrics( + received: 0, + expected: fullLyricsExpectedCount + ) scheduleFullLyricsBinaryTimeout(trackID: trackID, transferID: transferID) log( "[FullLyricsV2-iOS] start transferId=\(transferID) " + @@ -4739,28 +7577,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 +7608,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 +7781,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 +7803,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 +7871,10 @@ extension BLETestManager: CBPeripheralDelegate { "received=\(fullLyricsChunks.count)/\(fullLyricsExpectedCount) " + "words=\(words.count)" ) + mediaLoadingState.lyric = .fullLyrics( + received: fullLyricsChunks.count, + expected: fullLyricsExpectedCount + ) } publishPartialFullLyricsIfNeeded(trackID: trackID) } @@ -5075,6 +7936,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 +7960,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 ?? "")" @@ -5151,6 +8027,127 @@ extension BLETestManager: CBPeripheralDelegate { } } + private func handleFullLyricsCacheMetadata(_ object: [String: Any]) { + let trackID = object["trackId"] as? String ?? object["id"] as? String ?? "" + let generation = Self.int64Value(object["generation"] ?? object["g"]) + guard trackID == currentTrackID, + generation == currentTrackGeneration, + let descriptor = FullLyricsCacheValidationDescriptor( + object: object, + trackId: trackID, + currentTrackId: currentTrackID, + generation: generation, + currentGeneration: currentTrackGeneration + ) else { + log( + "[FullLyricsCacheValidation] metadata rejected trackId=\(trackID) " + + "generation=\(generation)" + ) + return + } + currentFullLyricsCacheDescriptor = descriptor + log( + "[FullLyricsCacheValidation] metadata accepted trackId=\(trackID) " + + "lines=\(descriptor.lineCount) pendingFullTransfer=true" + ) + } + + private func handleFullLyricsNotModified(_ object: [String: Any]) { + let trackID = object["trackId"] as? String ?? object["id"] as? String ?? "" + let generation = Self.int64Value(object["generation"] ?? object["g"]) + guard trackID == currentTrackID, + generation == currentTrackGeneration else { + RealtimeTraceStore.shared.record( + stage: "cacheValidationMiss", + trackId: trackID, + generation: generation, + payloadType: "fullLyrics", + result: "rejected", + reason: "stale_identity_or_generation" + ) + log( + "[FullLyricsCacheValidation] stale not-modified ignored " + + "trackId=\(trackID) generation=\(generation)" + ) + return + } + guard let descriptor = FullLyricsCacheValidationDescriptor( + object: object, + trackId: trackID, + currentTrackId: currentTrackID, + generation: generation, + currentGeneration: currentTrackGeneration + ), + let entry = currentFullLyricsCacheEntry, + entry.trackId == trackID, + entry.validationDescriptor == descriptor, + fullLyricsTrackId == trackID, + !fullLyrics.isEmpty else { + rejectFullLyricsCacheValidation(trackID: trackID, reason: "local_cache_mismatch") + return + } + + currentFullLyricsCacheDescriptor = descriptor + fullLyricsRequestStartTimeouts.removeValue(forKey: trackID)?.cancel() + fullLyricsRequestStartRetryCounts.removeValue(forKey: trackID) + requestedFullLyricsTrackIDs.remove(trackID) + completedFullLyricsTrackIDs.insert(trackID) + fullLyricsUnavailableTrackIDs.remove(trackID) + mediaLoadingState.lyric = .ready(lineCount: fullLyrics.count) + RealtimeTraceStore.shared.record( + stage: "cacheValidationHit", + trackId: trackID, + generation: generation, + payloadType: "fullLyrics", + result: "hit" + ) + RealtimeTraceStore.shared.record( + stage: "cachedLyricsPublished", + trackId: trackID, + generation: generation, + payloadType: "fullLyrics", + result: "published" + ) + RealtimeTraceStore.shared.record( + stage: "fullLyricsTransferSkipped", + trackId: trackID, + generation: generation, + payloadType: "fullLyrics", + result: "skipped", + reason: "cache_validation_hit" + ) + scheduleLastNowPlayingSnapshotSave(reason: "fullLyrics cache validated", force: true) + log( + "[FullLyricsCacheValidation] hit published trackId=\(trackID) " + + "lines=\(fullLyrics.count)" + ) + } + + private func rejectFullLyricsCacheValidation(trackID: String, reason: String) { + RealtimeTraceStore.shared.record( + stage: "cacheValidationMiss", + trackId: trackID, + generation: currentTrackGeneration, + payloadType: "fullLyrics", + result: "rejected", + reason: reason + ) + fullLyricsRequestStartTimeouts.removeValue(forKey: trackID)?.cancel() + requestedFullLyricsTrackIDs.remove(trackID) + currentFullLyricsCacheEntry = nil + FullLyricsCacheStore.shared.remove(trackId: trackID) + guard !fullLyricsCacheValidationRetryTrackIDs.contains(trackID) else { + log( + "[FullLyricsCacheValidation] fallback suppressed reason=retry_limit " + + "trackId=\(trackID)" + ) + return + } + fullLyricsCacheValidationRetryTrackIDs.insert(trackID) + log("[FullLyricsCacheValidation] rejected reason=\(reason) trackId=\(trackID)") + requestFullLyricsIfNeeded(force: true, after: 0.1) + } + private func handleFullLyricsUnavailable(_ object: [String: Any]) { let trackID = object["trackId"] as? String ?? "" guard trackID == currentTrackID else { return } @@ -5175,6 +8172,9 @@ extension BLETestManager: CBPeripheralDelegate { ) lyricDiagnosticLastUpdatedAt = Date() requestLyricDiagnosticIfNeeded() + mediaLoadingState.lyric = .failed( + reason: reason.isEmpty ? status : reason + ) log("[FullLyrics] unavailable reason=\(reason)") } @@ -5185,20 +8185,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 +8227,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 +8240,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 +8253,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 + RealtimeTraceStore.shared.record( + stage: "fullLyricsPublished", + trackId: trackID, + generation: currentTrackGeneration, + payloadType: isFinal ? "fullLyricsFinal" : "fullLyricsPartial", + result: "published" + ) let nowMs = currentTimeMs() var traceDetail = "[LyricTrace-iOS] id=\(trackID) stage=uiPublished " + @@ -5408,6 +8502,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,19 +8519,312 @@ 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.currentFullLyricsCacheEntry = entry + 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))" + ) + RealtimeTraceStore.shared.record( + stage: "cachedLyricsPublished", + trackId: trackID, + generation: self.currentTrackGeneration, + payloadType: "fullLyrics", + result: "published" + ) + } + } + + private func saveFullLyricsCacheIfCurrent(reason: String) { + guard !currentTrackID.isEmpty, + fullLyricsTrackId == currentTrackID, + !fullLyrics.isEmpty, + !isShowingLastNowPlayingSnapshot else { + return + } + let cachedLines = fullLyrics.map(Self.cacheLine(from:)) + // The remote QRC fingerprint covers Sony's complete word-timing model, + // while this transfer intentionally carries words only around the + // current line. Validate the transmitted shape here and persist a + // separate local checksum for corruption detection. + let descriptor = currentFullLyricsCacheDescriptor?.matchesCachedLines(cachedLines) == true + ? currentFullLyricsCacheDescriptor + : nil + let localContentFingerprint = descriptor.map { _ in + FullLyricsCacheEntry.localContentFingerprint( + title: title, + artist: artist, + lines: cachedLines + ) + } + let entry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: currentTrackID, + title: title, + artist: artist, + album: album, + lines: cachedLines, + savedAt: Date(), + fingerprint: descriptor?.fingerprint, + localContentFingerprint: localContentFingerprint, + schemaVersion: descriptor?.schemaVersion, + expectedLineCount: descriptor?.lineCount, + expectedTranslationLineCount: descriptor?.translationLineCount, + expectedRomanizationLineCount: descriptor?.romanizationLineCount + ) + currentFullLyricsCacheEntry = entry + 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]) { let trackID = object["trackId"] as? String ?? "" + let lineIndex = Self.intValue(object["line"]) + 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 handoff = realtimeHandoffCorrelation.context(for: trackID) + RealtimeTraceStore.shared.record( + stage: "currentWordReceived", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "received", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY" + ) let sameTrack = isSameTrackId(incoming: trackID, current: currentTrackID) guard !trackID.isEmpty, sameTrack else { currentWordDropCount += 1 + RealtimeTraceStore.shared.record( + stage: "currentWordRejected", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "rejected", + reason: "TRACK_MISMATCH", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY", + failureReason: "TRACK_MISMATCH" + ) log( "[Lyrics-iOS] currentWord discarded stale trackId=\(trackID) " + "current=\(currentTrackID)" @@ -5453,14 +8848,134 @@ extension BLETestManager: CBPeripheralDelegate { ) } - let lineIndex = Self.intValue(object["line"]) - let wordIndex = Self.intValue(object["word"]) - let remotePositionMs = Self.int64Value(object["position"]) - let timestampMs = Self.int64Value(object["timestamp"]) + guard generation <= 0 || currentTrackGeneration <= 0 || + generation == currentTrackGeneration else { + currentWordDropCount += 1 + RealtimeTraceStore.shared.record( + stage: "currentWordRejected", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "rejected", + reason: "GENERATION_MISMATCH", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY", + failureReason: "GENERATION_MISMATCH" + ) + return + } + let nowMs = currentTimeMs() - currentWordLineIndex = lineIndex - currentWordIndex = wordIndex + let previousGeneration = currentWordFence.generation + let previousSequence = currentWordFence.sequence + let previousPositionMs = currentWordFence.positionMs + let orderingDecision = currentWordFence.decision( + generation: generation, + sequence: sequence, + positionMs: remotePositionMs + ) + guard orderingDecision == .accept else { + currentWordDropCount += 1 + RealtimeTraceStore.shared.record( + stage: "currentWordRejected", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "rejected", + reason: orderingDecision.rawValue, + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY", + failureReason: orderingDecision.rawValue + ) + log( + "[CurrentWordFence] ordered discard trackId=\(trackID) " + + "generation=\(generation) seq=\(sequence) " + + "position=\(remotePositionMs) " + + "lastGeneration=\(previousGeneration) " + + "lastSeq=\(previousSequence) lastPosition=\(previousPositionMs)" + ) + return + } + RealtimeTraceStore.shared.record( + stage: "currentWordAccepted", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "accepted", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY" + ) + + let anchorResolution = resolveRemotePlaybackAnchor( + object: object, + remotePositionMs: remotePositionMs, + timestampMs: timestampMs, + isPlaying: isPlaying + ) + guard let calibratedPositionMs = calibratedRemotePosition( + from: anchorResolution, + remotePositionMs: remotePositionMs, + source: "currentWord" + ) else { + currentWordDropCount += 1 + RealtimeTraceStore.shared.record( + stage: "currentWordRejected", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "rejected", + reason: "ANCHOR_STALE", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: lineIndex, + wordTimingStatus: wordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY", + failureReason: "ANCHOR_STALE" + ) + 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 + RealtimeTraceStore.shared.record( + stage: "currentWordPublished", + trackId: trackID, + generation: generation, + payloadType: "currentWord", + result: "published", + handoffId: handoff?.handoffId, + triggerType: handoff?.triggerType.rawValue, + positionAnchorMs: Self.optionalInt64Value(object["sampleMono"]), + lineIndex: effectiveLineIndex, + wordTimingStatus: effectiveWordIndex >= 0 ? "AVAILABLE" : "LINE_ONLY" + ) currentWordPushCount += 1 if lastCurrentWordReceivedAtMs > 0 { currentWordIntervalTotalMs += max(nowMs - lastCurrentWordReceivedAtMs, 0) @@ -5473,42 +8988,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 +9061,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 +9153,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) - } - .frame(maxWidth: .infinity, minHeight: 176, maxHeight: 176) - .contentShape(Rectangle()) - .onTapGesture { - if bleManager.fullLyrics.isEmpty { - bleManager.sendGetFullLyrics(force: true) - } - showFullLyrics = true + PlaybackProgressStoreView(manager: manager) + PlaybackControlsStoreView(manager: manager, mode: .regular) + VolumeControlStoreView(manager: manager) } - - 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("更多") + } + } + + private var presentation: BLEConnectionPresentationState { + manager.connectionStore.presentation + } + 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 + } + } + private var statusPresentation: ConnectionStatusPresentation { + ConnectionStatusPresentation.resolve(presentation) + } + 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() } - .foregroundStyle(.white) - .animation(.easeInOut(duration: 0.2), value: bleManager.connectionDisplayState) } +} + +private struct PlayerContentStoreRouter: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool + + 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 + ) + } + } +} - 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) +private struct ConnectionGuideStoreView: View { + let presentation: BLEConnectionPresentationState + let onConnect: () -> Void - VStack(spacing: 4) { - Text(nowPlayingInfo.title) - .font(.system(size: 32, weight: .bold, design: .rounded)) + 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) - .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 ? "播放中" : "已暂停") + 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) } - .font(.caption.weight(.semibold)) - .foregroundStyle( - bleManager.isPlaying - ? Color.green.opacity(0.95) - : Color.white.opacity(0.62) + .buttonStyle(PressScaleButtonStyle(pressedScale: 0.97)) + .disabled(isPrimaryActionDisabled) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 430) + .padding(.horizontal, 24) + } + + 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 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 struct PlayerStoreSections: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool + + var body: some View { + VStack(spacing: metrics.sectionSpacing) { + TrackInfoStoreView(manager: manager, metrics: metrics) + LyricsPreviewStoreView( + manager: manager, + metrics: metrics, + showFullLyrics: $showFullLyrics ) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background(.white.opacity(0.095), in: Capsule()) - .overlay { - Capsule().stroke(.white.opacity(0.08), lineWidth: 1) + PlaybackProgressStoreView(manager: manager) + PlaybackControlsStoreView(manager: manager, mode: metrics.mode) + VolumeControlStoreView(manager: manager) + } + } +} + +private struct TrackInfoStoreView: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + + private var mode: PlayerLayoutMode { metrics.mode } + + 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 + } } - .animation(.spring(response: 0.28, dampingFraction: 0.78), value: bleManager.isPlaying) } - .foregroundStyle(.white) .frame(maxWidth: .infinity) + .onChange(of: manager.playbackStore.metadata) { oldValue, newValue in + guard oldValue != newValue else { return } + manager.recordNowPlayingStateConsumed() + } } - private var albumArtView: some View { - ZStack { - if let image = bleManager.albumArtImage { + 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) - .antialiased(true) .scaledToFill() } else { DefaultAlbumArtView() } } - .frame(width: albumArtSize, height: albumArtSize) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .frame(width: artworkSize, height: artworkSize) + .clipShape(RoundedRectangle(cornerRadius: mode.isCompact ? 20 : 25, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) + RoundedRectangle(cornerRadius: mode.isCompact ? 20 : 25) + .stroke(.white.opacity(0.12)) } - .shadow(color: .black.opacity(0.28), radius: 22, y: 12) + .shadow(color: .black.opacity(0.34), radius: 20, 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) + 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) } - 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("打开完整歌词") + .frame(maxWidth: .infinity, alignment: centered ? .center : .leading) } - - 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)) - } - .padding(.horizontal, 4) - .animation( - bleManager.isSeeking ? nil : .linear(duration: 0.18), - value: bleManager.positionMs - ) + private func display(_ value: String, fallback: String) -> String { + value == "-" || value.isEmpty ? fallback : value } +} - private var playbackControls: some View { - HStack(spacing: 26) { - playerControlButton( - title: "上一首", - systemImage: "backward.fill", - size: 52, - fontSize: 22, - action: bleManager.sendPrevious - ) - - 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 - ) - } - .frame(maxWidth: .infinity) - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.46) - .animation(.spring(response: 0.28, dampingFraction: 0.72), value: bleManager.isPlaying) - } +private struct LyricsPreviewStoreView: View { + let manager: BLETestManager + let metrics: PlayerLayoutMetrics + @Binding var showFullLyrics: Bool + @ObservedObject private var preferences = PreferencesStore.shared - private var volumeSection: some View { - HStack(spacing: 12) { - Image(systemName: volumeIcon) - .font(.headline) - .frame(width: 24) + private var mode: PlayerLayoutMode { metrics.mode } - Slider( - value: Binding( - get: { - Double( - bleManager.isVolumeSeeking - ? bleManager.volumeSeekValue - : bleManager.volumeCurrent - ) - }, - set: { value in - bleManager.updateVolumeSeekValue(value) + 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) } - ), - in: 0...Double(max(bleManager.volumeMax, 1)), - step: 1, - onEditingChanged: { editing in - if editing { - bleManager.beginVolumeSeeking() - } else { - bleManager.finishVolumeSeeking() + 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) } - ) - .tint(.white.opacity(0.92)) - .disabled(!isConnected || bleManager.volumeMax <= 0) - - 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) + .frame( + maxWidth: .infinity, + minHeight: metrics.lyricHeight, + maxHeight: metrics.lyricHeight + ) + DarkLyricSideDots(color: visualState.accentColor) + } + .contentShape(Rectangle()) } - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.52) - .animation(.easeInOut(duration: 0.16), value: displayedVolume) - } - - private var connectionColor: Color { - switch bleManager.connectionDisplayState { - case "connected": - return .green - case "reconnecting": - return .orange - case "disconnected": - return .secondary - default: - return .secondary + .buttonStyle(.plain) + .accessibilityLabel("当前歌词,点按打开完整歌词") + .onChange(of: displayedPosition) { _, value in + manager.logKaraokeOffset(rawPositionMs: value) } } - private var connectionStatusTitle: String { - switch bleManager.connectionDisplayState { - case "connected": - return "Sony 已连接" - case "reconnecting": - return "正在重连" - case "disconnected": - return "未连接" - default: - return "未连接" - } + 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 isConnected: Bool { - bleManager.connectionDisplayState == "connected" + private var karaokePosition: Int64 { + manager.karaokePositionMs(rawPositionMs: displayedPosition) } - - private var isDebugMode: Bool { - preferences.appExperienceMode == .debug + private var currentIndex: Int { + LyricTimelineHelper.currentIndex(lines: lines, positionMs: karaokePosition) ?? -1 } - - private var displayedPositionMs: Int64 { - bleManager.isSeeking ? bleManager.seekPositionMs : bleManager.displayPositionMs + private var lineProgress: Double { + LyricTimelineHelper.lineProgress( + lines: lines, + index: currentIndex, + positionMs: karaokePosition + ) } - - private var karaokePositionMs: Int64 { - bleManager.karaokePositionMs(rawPositionMs: displayedPositionMs) + 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 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 + private var currentLineFont: Font { + if mode == .accessibility { + return .system(.title2, design: .rounded, weight: .semibold) + } + return .system( + size: mode.isCompact ? 21 : 27, + weight: .semibold, + design: .rounded ) } - - private var lyricDisplayMode: LyricDisplayMode { - preferences.lyricDisplayMode + private func lineModel(offset: Int) -> LyricLine? { + let index = currentIndex + offset + return lines.indices.contains(index) ? lines[index] : nil } - - private func requestOptionalLyricsIfNeeded() { - bleManager.requestFullLyricsOptionalFieldsIfNeeded(displayMode: lyricDisplayMode) + private func line(offset: Int) -> String { + if let model = lineModel(offset: offset) { + let text = model.text.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? " " : text + } + if offset == 0 { + let text = manager.lyricsStore.live.text.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "暂无歌词" : text + } + return " " } - - private var lyricDisplayModeBinding: Binding { - Binding( - get: { lyricDisplayMode }, - set: { preferences.lyricDisplayMode = $0 } - ) + private func openFullLyrics() { + if document.lines.isEmpty { manager.sendGetFullLyrics(force: true) } + manager.requestFullLyricsOptionalFieldsIfNeeded(displayMode: preferences.lyricDisplayMode) + showFullLyrics = true } +} - private var currentLyricText: String { - let trimmed = bleManager.lyric.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "暂无歌词" : trimmed - } +private struct PlaybackProgressStoreView: View { + let manager: BLETestManager - private var currentFullLyricIndex: Int { - LyricTimelineHelper.currentIndex( - lines: currentTrackFullLyrics, - positionMs: karaokePositionMs - ) ?? -1 + var body: some View { + let timeline = manager.playbackStore.timeline + HStack(spacing: 10) { + Text(format(displayedPosition, duration: timeline.durationMs)) + .frame(width: 44, alignment: .leading) + CompactPlayerSlider( + value: Binding( + get: { Double(displayedPosition) }, + set: manager.updateSeekPosition + ), + range: 0...Double(max(timeline.durationMs, 1)), + step: nil, + accentColor: playerVisualState(manager: manager).accentColor, + isEnabled: manager.connectionStore.presentation.isConnected && timeline.durationMs > 0, + accessibilityLabel: "播放进度", + accessibilityValue: "\(format(displayedPosition, duration: timeline.durationMs)),共 \(format(timeline.durationMs, duration: timeline.durationMs))", + onEditingChanged: { editing in + editing ? manager.beginSeeking() : manager.finishSeeking() + } + ) + Text(format(timeline.durationMs, duration: timeline.durationMs)) + .frame(width: 44, alignment: .trailing) + } + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white.opacity(0.72)) } - private var currentLyricProgress: Double { - LyricTimelineHelper.lineProgress( - lines: currentTrackFullLyrics, - index: currentFullLyricIndex, - positionMs: karaokePositionMs - ) + private var displayedPosition: Int64 { + let timeline = manager.playbackStore.timeline + return timeline.isSeeking ? timeline.seekPositionMs : timeline.displayPositionMs } - - private var lyricPreviewIdentity: String { - if currentTrackFullLyrics.isEmpty { - return currentLyricText - } - return "\(bleManager.fullLyricsTrackId)-\(currentFullLyricIndex)" + 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 func lyricPreviewLine(offset: Int) -> String { - guard let line = lyricPreviewLineModel(offset: offset) else { - return offset == 0 ? currentLyricText : " " - } - let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? " " : text - } +private struct CompactPlayerSlider: View { + let value: Binding + let range: ClosedRange + let step: Double? + let accentColor: Color + let isEnabled: Bool + let accessibilityLabel: String + let accessibilityValue: String + let onEditingChanged: (Bool) -> Void - private func lyricPreviewLineModel(offset: Int) -> LyricLine? { - let index = currentFullLyricIndex + offset - guard currentTrackFullLyrics.indices.contains(index) else { - return nil - } - return currentTrackFullLyrics[index] - } + var body: some View { + GeometryReader { proxy in + let thumbDiameter = CompactSliderPresentation.thumbDiameter + let trackWidth = max(proxy.size.width - thumbDiameter, 0) + let progress = CGFloat( + CompactSliderPresentation.normalizedProgress( + value: value.wrappedValue, + lowerBound: range.lowerBound, + upperBound: range.upperBound + ) + ) + let fillWidth = trackWidth * progress + let centerY = proxy.size.height / 2 + let thumbX = thumbDiameter / 2 + fillWidth - 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 - } - if lyricDisplayMode.showsRomanization, - let romanization = sanitizedSecondaryText(line.romanization) { - return romanization - } - return nil - } + ZStack(alignment: .topLeading) { + Capsule() + .fill(.white.opacity(0.18)) + .frame(width: trackWidth, height: CompactSliderPresentation.trackHeight) + .position(x: proxy.size.width / 2, y: centerY) - private var currentTrackFullLyrics: [LyricLine] { - bleManager.isFullLyricsCurrent ? bleManager.fullLyrics : [] - } + Capsule() + .fill(accentColor) + .frame(width: fillWidth, height: CompactSliderPresentation.trackHeight) + .position(x: thumbDiameter / 2 + fillWidth / 2, y: centerY) - private var albumArtIdentity: String { - if bleManager.albumArtImage == nil { - return "default-\(bleManager.title)-\(bleManager.artist)" + Circle() + .fill(.white) + .frame(width: thumbDiameter, height: thumbDiameter) + .overlay { + Circle().strokeBorder(accentColor.opacity(0.72), lineWidth: 1) + } + .shadow(color: .black.opacity(0.24), radius: 2, y: 1) + .position(x: thumbX, y: centerY) + .allowsHitTesting(false) + + interactiveSlider + .frame(width: proxy.size.width, height: proxy.size.height) + .opacity(0.001) + } } - return "art-\(bleManager.title)-\(bleManager.artist)-\(bleManager.album)-\(bleManager.artworkDisplayQuality.label)" + .frame(height: CompactSliderPresentation.interactionHeight) + .contentShape(Rectangle()) + .opacity(isEnabled ? 1 : 0.45) } - private var albumArtSize: CGFloat { - preferences.artworkDisplaySize.pointSize + @ViewBuilder + private var interactiveSlider: some View { + if let step { + Slider( + value: value, + in: range, + step: step, + onEditingChanged: onEditingChanged + ) + .disabled(!isEnabled) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } else { + Slider( + value: value, + in: range, + onEditingChanged: onEditingChanged + ) + .disabled(!isEnabled) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } } +} - 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: 8) { + Image(systemName: volumeIcon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white.opacity(0.92)) + .frame(width: 18) + .accessibilityHidden(true) + + GeometryReader { proxy in + ZStack(alignment: .leading) { + CompactPlayerSlider( + value: Binding( + get: { Double(displayedVolume) }, + set: manager.updateVolumeSeekValue + ), + range: 0...Double(max(volume.maximum, 1)), + step: 1, + accentColor: playerVisualState(manager: manager).accentColor, + isEnabled: isAvailable, + accessibilityLabel: "音量", + accessibilityValue: volume.maximum > 0 ? "\(displayedVolume),最大 \(volume.maximum)" : "尚未同步", + onEditingChanged: handleEditing + ) + + 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: CompactSliderPresentation.interactionHeight) } + .padding(.horizontal, 11) + .frame(height: 36) + .background(.black.opacity(0.13), in: RoundedRectangle(cornerRadius: 16)) + .overlay { RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.06)) } + .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 +1176,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 +1221,32 @@ 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) + } +} + +struct CompactSliderPresentation { + static let trackHeight: CGFloat = 3 + static let thumbDiameter: CGFloat = 11 + static let interactionHeight: CGFloat = 32 + + static func normalizedProgress( + value: Double, + lowerBound: Double, + upperBound: Double + ) -> Double { + guard upperBound > lowerBound else { return 0 } + return min(max((value - lowerBound) / (upperBound - lowerBound), 0), 1) } } @@ -1298,47 +1258,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 +1358,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 +1386,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 +1405,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 +1460,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 +1477,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 +1925,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..8f91c39 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/DebugToolsView.swift @@ -9,12 +9,14 @@ struct DebugToolsView: View { @State private var showLyricDiagnostic = false @State private var showNowPlayingDiagnostic = false @State private var showSystemHealthOverview = false + @State private var realtimeSummary = RealtimeTraceStore.shared.summary() var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { statusSection + realtimeTraceSection autoReconnectSection artworkEnhancementSection liveActivityControlSection @@ -39,6 +41,13 @@ struct DebugToolsView: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .onAppear { + bleManager.setUILogStreamingEnabled(true) + refreshRealtimeSummary() + } + .onDisappear { + bleManager.setUILogStreamingEnabled(false) + } .sheet(isPresented: $showLyricDiagnostic) { LyricDiagnosticView( bleManager: bleManager, @@ -59,6 +68,33 @@ struct DebugToolsView: View { } } + private var realtimeTraceSection: some View { + DebugCard { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Real-time SLO", systemImage: "waveform.path.ecg") + .font(.headline) + Spacer() + Button("刷新") { refreshRealtimeSummary() } + .buttonStyle(.bordered) + } + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { + debugRow("Events", "\(realtimeSummary.eventCount)") + debugRow("Missing result", "\(realtimeSummary.missingResultCount)") + debugRow("Latest mono", realtimeSummary.latestMonoMs.map { "\($0)ms" } ?? "-") + debugRow("Stages", "\(realtimeSummary.stageCounts.count)") + } + Text("仅显示聚合快照;手动刷新,不读取日志正文。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func refreshRealtimeSummary() { + realtimeSummary = RealtimeTraceStore.shared.summary() + } + private var statusSection: some View { DebugCard { VStack(alignment: .leading, spacing: 12) { @@ -151,7 +187,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 +260,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 +277,7 @@ struct DebugToolsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Target Size") + Text("目标尺寸") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) LazyVGrid( @@ -267,7 +303,7 @@ struct DebugToolsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Sharpness") + Text("锐度") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) LazyVGrid( @@ -334,7 +370,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 +392,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 +535,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..36e4bf8 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift @@ -0,0 +1,505 @@ +import CryptoKit +import Foundation + +struct FullLyricsCacheEntry: Codable, Equatable { + static let version = 3 + static let validationMetadataVersion = 2 + static let legacyVersion = 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 + /// Sony's stable QRC descriptor. It can cover word timing that is not part + /// of the bandwidth-limited iOS payload. + let fingerprint: String? + /// Digest of the exact lines persisted on iOS, used only for local file + /// integrity. It must not be sent to Sony as the QRC fingerprint. + let localContentFingerprint: String? + let schemaVersion: Int? + let expectedLineCount: Int? + let expectedTranslationLineCount: Int? + let expectedRomanizationLineCount: Int? + + init( + version: Int, + trackId: String, + title: String, + artist: String, + album: String, + lines: [Line], + savedAt: Date, + fingerprint: String? = nil, + localContentFingerprint: String? = nil, + schemaVersion: Int? = nil, + expectedLineCount: Int? = nil, + expectedTranslationLineCount: Int? = nil, + expectedRomanizationLineCount: Int? = nil + ) { + self.version = version + self.trackId = trackId + self.title = title + self.artist = artist + self.album = album + self.lines = lines + self.savedAt = savedAt + self.fingerprint = fingerprint + self.localContentFingerprint = localContentFingerprint + self.schemaVersion = schemaVersion + self.expectedLineCount = expectedLineCount + self.expectedTranslationLineCount = expectedTranslationLineCount + self.expectedRomanizationLineCount = expectedRomanizationLineCount + } + + var isValid: Bool { + (version == Self.version || + version == Self.validationMetadataVersion || + version == Self.legacyVersion) && + !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) + } + + var validationDescriptor: FullLyricsCacheValidationDescriptor? { + guard version == Self.version, + let fingerprint, + !fingerprint.isEmpty, + let localContentFingerprint, + !localContentFingerprint.isEmpty, + let schemaVersion, + let expectedLineCount, + let expectedTranslationLineCount, + let expectedRomanizationLineCount else { + return nil + } + let descriptor = FullLyricsCacheValidationDescriptor( + fingerprint: fingerprint, + schemaVersion: schemaVersion, + lineCount: expectedLineCount, + translationLineCount: expectedTranslationLineCount, + romanizationLineCount: expectedRomanizationLineCount + ) + guard descriptor.matchesCachedLines(lines), + Self.localContentFingerprint( + title: title, + artist: artist, + lines: lines + ) == localContentFingerprint else { + return nil + } + return descriptor + } + + static func localContentFingerprint( + title: String, + artist: String, + lines: [Line] + ) -> String { + FullLyricsCacheValidationDescriptor.contentFingerprint( + title: title, + artist: artist, + lines: lines + ) + } + + private func normalized(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +struct FullLyricsCacheValidationDescriptor: Codable, Equatable, Sendable { + let fingerprint: String + let schemaVersion: Int + let lineCount: Int + let translationLineCount: Int + let romanizationLineCount: Int + + init?( + object: [String: Any], + trackId: String, + currentTrackId: String, + generation: Int64, + currentGeneration: Int64 + ) { + let incomingTrackId = object["trackId"] as? String ?? object["id"] as? String ?? "" + let incomingGeneration = FullLyricsCacheValidationDescriptor.int64Value( + object["generation"] ?? object["g"] + ) + let fingerprint = object["fingerprint"] as? String ?? object["fp"] as? String ?? "" + let schemaVersion = FullLyricsCacheValidationDescriptor.intValue( + object["schemaVersion"] ?? object["sv"] + ) + let lineCount = FullLyricsCacheValidationDescriptor.intValue( + object["lineCount"] ?? object["n"] + ) + let translationLineCount = FullLyricsCacheValidationDescriptor.intValue( + object["translationLineCount"] ?? object["tc"] + ) + let romanizationLineCount = FullLyricsCacheValidationDescriptor.intValue( + object["romanizationLineCount"] ?? object["rc"] + ) + guard !trackId.isEmpty, + incomingTrackId == trackId, + incomingTrackId == currentTrackId, + generation > 0, + incomingGeneration == generation, + incomingGeneration == currentGeneration, + fingerprint.count == 24, + fingerprint.allSatisfy({ $0.isHexDigit }), + schemaVersion > 0, + lineCount > 0, + translationLineCount >= 0, + romanizationLineCount >= 0, + translationLineCount <= lineCount, + romanizationLineCount <= lineCount else { + return nil + } + self.fingerprint = fingerprint.lowercased() + self.schemaVersion = schemaVersion + self.lineCount = lineCount + self.translationLineCount = translationLineCount + self.romanizationLineCount = romanizationLineCount + } + + init( + fingerprint: String, + schemaVersion: Int, + lineCount: Int, + translationLineCount: Int, + romanizationLineCount: Int + ) { + self.fingerprint = fingerprint.lowercased() + self.schemaVersion = schemaVersion + self.lineCount = lineCount + self.translationLineCount = translationLineCount + self.romanizationLineCount = romanizationLineCount + } + + func matchesCachedLines(_ lines: [FullLyricsCacheEntry.Line]) -> Bool { + guard lines.count == lineCount else { return false } + let cachedTranslationCount = lines.filter { + Self.hasUsableSecondaryText($0.translation) + }.count + let cachedRomanizationCount = lines.filter { + Self.hasUsableSecondaryText($0.romanization) + }.count + return cachedTranslationCount == translationLineCount && + cachedRomanizationCount == romanizationLineCount + } + + func matchesCachedContent( + title: String, + artist: String, + lines: [FullLyricsCacheEntry.Line] + ) -> Bool { + matchesCachedLines(lines) && + Self.contentFingerprint(title: title, artist: artist, lines: lines) == fingerprint + } + + static func contentFingerprint( + title: String, + artist: String, + lines: [FullLyricsCacheEntry.Line] + ) -> String { + var digest = SHA256() + updateField(title.trimmingCharacters(in: .whitespacesAndNewlines), digest: &digest) + updateField(artist.trimmingCharacters(in: .whitespacesAndNewlines), digest: &digest) + for (index, line) in lines.enumerated() { + updateField(String(index), digest: &digest) + updateField(String(line.timeMs), digest: &digest) + updateField(String(line.durationMs), digest: &digest) + updateField(line.text, digest: &digest) + updateField(line.translation ?? "", digest: &digest) + updateField(line.romanization ?? "", digest: &digest) + updateField(String(line.words.count), digest: &digest) + for word in line.words { + updateField(String(word.startMs), digest: &digest) + updateField(String(word.durationMs), digest: &digest) + updateField(word.text, digest: &digest) + } + } + return digest.finalize().prefix(12).map { String(format: "%02x", $0) }.joined() + } + + var requestFields: [String: Any] { + [ + "fp": fingerprint, + "sv": schemaVersion, + "n": lineCount, + "tc": translationLineCount, + "rc": romanizationLineCount + ] + } + + private static func hasUsableSecondaryText(_ value: String?) -> Bool { + guard let value else { return false } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return !normalized.isEmpty && normalized != "null" && normalized != "undefined" + } + + private static func updateField(_ value: String, digest: inout SHA256) { + let valueData = Data(value.utf8) + var length = UInt32(valueData.count).bigEndian + withUnsafeBytes(of: &length) { digest.update(bufferPointer: $0) } + digest.update(data: valueData) + } + + private static func intValue(_ value: Any?) -> Int { + if let value = value as? Int { return value } + if let value = value as? Int64 { return Int(value) } + if let value = value as? NSNumber { return value.intValue } + if let value = value as? String { return Int(value) ?? 0 } + return 0 + } + + private static func int64Value(_ 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) ?? 0 } + return 0 + } +} + +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 + } + } + } + + static func estimatedCost(_ entry: FullLyricsCacheEntry) -> Int { + var totalCost: Int = 0 + for line in entry.lines { + var lineCost: Int = line.text.utf8.count + if let translation = line.translation { + lineCost += translation.utf8.count + } + if let romanization = line.romanization { + lineCost += romanization.utf8.count + } + for word in line.words { + lineCost += word.text.utf8.count + lineCost += 32 + } + totalCost += lineCost + } + return totalCost + } +} 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..afb5ffd --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -0,0 +1,2768 @@ +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) + } + + func testFullLyricsCacheEstimatedCostForEmptyAndOriginalOnlyLyrics() { + let empty = makeFullLyricsCacheEntry(lines: []) + XCTAssertEqual(FullLyricsCacheStore.estimatedCost(empty), 0) + + let originalOnly = makeFullLyricsCacheEntry( + lines: [ + .init( + index: 0, + timeMs: 0, + durationMs: 1_000, + text: "plain text", + translation: nil, + romanization: nil, + words: [] + ) + ] + ) + XCTAssertEqual( + FullLyricsCacheStore.estimatedCost(originalOnly), + "plain text".utf8.count + ) + } + + func testFullLyricsCacheEstimatedCostIncludesWordAndSecondaryText() { + let entry = makeFullLyricsCacheEntry( + lines: [ + .init( + index: 0, + timeMs: 120, + durationMs: 2_500, + text: "原文", + translation: "translation", + romanization: "yuan wen", + words: [ + .init(id: 0, startMs: 120, durationMs: 800, text: "原"), + .init(id: 1, startMs: 920, durationMs: 1_700, text: "文") + ] + ) + ] + ) + let expected: Int = "原文".utf8.count + + "translation".utf8.count + + "yuan wen".utf8.count + + "原".utf8.count + 32 + + "文".utf8.count + 32 + XCTAssertEqual(FullLyricsCacheStore.estimatedCost(entry), expected) + } + + func testFullLyricsCacheEstimatedCostUsesUTF8ForLongUnicodeAndEmoji() { + let unicode = String(repeating: "音乐🎵é", count: 512) + let entry = makeFullLyricsCacheEntry( + lines: [ + .init( + index: 0, + timeMs: 0, + durationMs: 1_000, + text: unicode, + translation: "🌍", + romanization: "🎧", + words: [ + .init(id: 0, startMs: 0, durationMs: 1_000, text: "👩‍🎤") + ] + ) + ] + ) + let expected: Int = unicode.utf8.count + "🌍".utf8.count + + "🎧".utf8.count + "👩‍🎤".utf8.count + 32 + XCTAssertEqual(FullLyricsCacheStore.estimatedCost(entry), expected) + } + + func testFullLyricsCacheEstimatedCostPreservesLegacyRuleForLargeLyrics() { + let lines: [FullLyricsCacheEntry.Line] = (0..<1_500).map { index in + .init( + index: index, + timeMs: Int64(index * 1_000), + durationMs: 1_000, + text: "line-\(index)-歌词", + translation: index.isMultiple(of: 2) ? "translation-\(index)" : nil, + romanization: index.isMultiple(of: 3) ? "romanization-\(index)" : nil, + words: [ + .init( + id: index, + startMs: Int64(index * 1_000), + durationMs: 1_000, + text: "word-\(index)" + ) + ] + ) + } + var legacyExpected: Int = 0 + for line in lines { + legacyExpected += line.text.utf8.count + legacyExpected += line.translation?.utf8.count ?? 0 + legacyExpected += line.romanization?.utf8.count ?? 0 + for word in line.words { + legacyExpected += word.text.utf8.count + 32 + } + } + XCTAssertEqual( + FullLyricsCacheStore.estimatedCost(makeFullLyricsCacheEntry(lines: lines)), + legacyExpected + ) + } + + @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 testFullLyricsCacheValidationRequiresExactContentAndGeneration() { + let lines = [ + FullLyricsCacheEntry.Line( + index: 0, + timeMs: 0, + durationMs: 1_000, + text: "line", + translation: "translation", + romanization: nil, + words: [] + ) + ] + let fingerprint = FullLyricsCacheValidationDescriptor.contentFingerprint( + title: "Song", + artist: "Artist", + lines: lines + ) + XCTAssertEqual(fingerprint, "3e1c8ce388901c3763a5b1c3") + let descriptor = FullLyricsCacheValidationDescriptor( + // The remote QRC descriptor may cover word timing omitted from the + // compact iOS cache, so it is intentionally independent here. + fingerprint: "ab" + String(repeating: "01", count: 11), + schemaVersion: 1, + lineCount: 1, + translationLineCount: 1, + romanizationLineCount: 0 + ) + let entry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: "track-1", + title: "Song", + artist: "Artist", + album: "Album", + lines: lines, + savedAt: Date(), + fingerprint: descriptor.fingerprint, + localContentFingerprint: fingerprint, + schemaVersion: descriptor.schemaVersion, + expectedLineCount: descriptor.lineCount, + expectedTranslationLineCount: descriptor.translationLineCount, + expectedRomanizationLineCount: descriptor.romanizationLineCount + ) + XCTAssertNotEqual(descriptor.fingerprint, fingerprint) + XCTAssertEqual(entry.validationDescriptor, descriptor) + XCTAssertEqual(descriptor.requestFields["n"] as? Int, 1) + + let tamperedEntry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: "track-1", + title: "Song", + artist: "Artist", + album: "Album", + lines: lines.map { + .init( + index: $0.index, + timeMs: $0.timeMs, + durationMs: $0.durationMs, + text: "tampered", + translation: $0.translation, + romanization: $0.romanization, + words: $0.words + ) + }, + savedAt: Date(), + fingerprint: descriptor.fingerprint, + localContentFingerprint: fingerprint, + schemaVersion: descriptor.schemaVersion, + expectedLineCount: descriptor.lineCount, + expectedTranslationLineCount: descriptor.translationLineCount, + expectedRomanizationLineCount: descriptor.romanizationLineCount + ) + XCTAssertNil(tamperedEntry.validationDescriptor) + + let payload: [String: Any] = [ + "id": "track-1", + "g": 7, + "fp": descriptor.fingerprint, + "sv": 1, + "n": 1, + "tc": 1, + "rc": 0 + ] + XCTAssertEqual( + FullLyricsCacheValidationDescriptor( + object: payload, + trackId: "track-1", + currentTrackId: "track-1", + generation: 7, + currentGeneration: 7 + ), + descriptor + ) + XCTAssertNil( + FullLyricsCacheValidationDescriptor( + object: payload, + trackId: "track-1", + currentTrackId: "track-1", + generation: 7, + currentGeneration: 8 + ) + ) + + let incompleteEntry = FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: "track-1", + title: "Song", + artist: "Artist", + album: "Album", + lines: lines.map { + .init( + index: $0.index, + timeMs: $0.timeMs, + durationMs: $0.durationMs, + text: $0.text, + translation: nil, + romanization: nil, + words: $0.words + ) + }, + savedAt: Date(), + fingerprint: descriptor.fingerprint, + localContentFingerprint: fingerprint, + schemaVersion: descriptor.schemaVersion, + expectedLineCount: descriptor.lineCount, + expectedTranslationLineCount: descriptor.translationLineCount, + expectedRomanizationLineCount: descriptor.romanizationLineCount + ) + XCTAssertNil(incompleteEntry.validationDescriptor) + + var compactRequest: [String: Any] = [ + "cmd": "GET_FULL_LYRICS", + "seq": 9, + "id": "0123456789abcdef01234567", + "p": 123_456, + "w": true, + "f": "z" + ] + descriptor.requestFields.forEach { compactRequest[$0.key] = $0.value } + let encodedRequest = try? JSONSerialization.data(withJSONObject: compactRequest) + XCTAssertNotNil(encodedRequest) + XCTAssertLessThanOrEqual(encodedRequest?.count ?? .max, 182) + } + + func testWriteTimeoutDoesNotAdvanceQueueAndSuspendsInBackground() { + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: false, + transportReady: true, + linkRecentlyActive: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .suspendUntilForeground + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + linkRecentlyActive: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .extendWithoutAdvancingQueue + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + linkRecentlyActive: true, + timeoutCountAfterIncrement: 2, + reconnectThreshold: 2 + ), + .reconnectPreservingPresentation + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + linkRecentlyActive: false, + timeoutCountAfterIncrement: 2, + reconnectThreshold: 2 + ), + .reconnect + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: false, + linkRecentlyActive: false, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .reconnect + ) + } + + func testControlWriteSupersedesOnlyUnprotectedPlaybackRefreshes() { + XCTAssertTrue( + CommandWriteQueuePolicy.shouldDropPending( + existingCommand: "GET_PLAYBACK_STATE", + existingIsProtected: false, + incomingCommand: "NEXT", + incomingIsControl: true + ) + ) + XCTAssertTrue( + CommandWriteQueuePolicy.shouldDropPending( + existingCommand: "GET_PLAYBACK_STATE", + existingIsProtected: false, + incomingCommand: "GET_PLAYBACK_STATE", + incomingIsControl: false + ) + ) + XCTAssertFalse( + CommandWriteQueuePolicy.shouldDropPending( + existingCommand: "GET_PLAYBACK_STATE", + existingIsProtected: true, + incomingCommand: "PREVIOUS", + incomingIsControl: true + ) + ) + XCTAssertFalse( + CommandWriteQueuePolicy.shouldDropPending( + existingCommand: "GET_FULL_LYRICS", + existingIsProtected: false, + incomingCommand: "NEXT", + incomingIsControl: true + ) + ) + } + + func testTrackSkipFallbackWaitsForAuthoritativeIdentityWindow() { + XCTAssertEqual(CommandRefreshPolicy.fallbackDelay(for: "NEXT"), 1.0) + XCTAssertEqual(CommandRefreshPolicy.fallbackDelay(for: "PREVIOUS"), 1.0) + XCTAssertEqual(CommandRefreshPolicy.fallbackDelay(for: "PLAY_PAUSE"), 0.5) + XCTAssertEqual(CommandRefreshPolicy.fallbackDelay(for: "SEEK_TO"), 0.5) + } + + func testClockSyncProbeDefersOnlyForActiveLargeMediaReception() { + XCTAssertTrue( + ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: true, + artworkTransferState: "idle" + ) + ) + XCTAssertTrue( + ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: false, + artworkTransferState: "receiving" + ) + ) + XCTAssertTrue( + ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: false, + artworkTransferState: "DECODING" + ) + ) + for state in ["idle", "failed", "timeout"] { + XCTAssertFalse( + ClockSyncProbeMediaPolicy.shouldDefer( + isFullLyricsReceiving: false, + artworkTransferState: state + ) + ) + } + } + + 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 + ) + XCTAssertEqual( + CompactSliderPresentation.normalizedProgress( + value: 35, + lowerBound: 10, + upperBound: 60 + ), + 0.5, + accuracy: 0.001 + ) + XCTAssertEqual( + CompactSliderPresentation.normalizedProgress( + value: -10, + lowerBound: 0, + upperBound: 100 + ), + 0, + accuracy: 0.001 + ) + XCTAssertEqual( + CompactSliderPresentation.normalizedProgress( + value: 140, + lowerBound: 0, + upperBound: 100 + ), + 1, + accuracy: 0.001 + ) + XCTAssertLessThan(CompactSliderPresentation.thumbDiameter, 16) + XCTAssertGreaterThanOrEqual(CompactSliderPresentation.interactionHeight, 28) + } + + 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)) + XCTAssertFalse(v3.v3Features.contains(.mediaCacheValidationV1)) + XCTAssertEqual(v3.sessionId, "sony-session-1") + XCTAssertEqual(v3.v3Features.intersection(.all).rawValue, 7) + + let cacheValidation = BLEProtocolV3Parser.capabilitiesAck(from: [ + "protocolVersion": 3, + "f2": 0, + "f3": 1 << 3 + ]) + XCTAssertTrue(cacheValidation.v3Features.contains(.mediaCacheValidationV1)) + } + + 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 testTrackIdentityGenerationPolicyRejectsRollbackAndConflict() { + XCTAssertEqual( + TrackIdentityGenerationPolicy.decision( + currentTrackId: "track-new", + currentGeneration: 41, + currentSessionId: "session-1", + incomingTrackId: "track-old", + incomingGeneration: 40, + incomingSessionId: "session-1" + ), + .rejectStale + ) + XCTAssertEqual( + TrackIdentityGenerationPolicy.decision( + currentTrackId: "track-a", + currentGeneration: 41, + currentSessionId: "session-1", + incomingTrackId: "track-b", + incomingGeneration: 41, + incomingSessionId: "session-1" + ), + .rejectConflict + ) + } + + func testTrackIdentityGenerationPolicyAllowsNewSessionAndLegacyPayload() { + XCTAssertEqual( + TrackIdentityGenerationPolicy.decision( + currentTrackId: "track-a", + currentGeneration: 41, + currentSessionId: "session-1", + incomingTrackId: "track-b", + incomingGeneration: 1, + incomingSessionId: "session-2" + ), + .acceptSessionReset + ) + XCTAssertEqual( + TrackIdentityGenerationPolicy.decision( + currentTrackId: "legacy-a", + currentGeneration: 0, + currentSessionId: "", + incomingTrackId: "legacy-b", + incomingGeneration: 0, + incomingSessionId: "" + ), + .accept + ) + } + + 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:))) + } + } + } + + func testRealtimeTraceUsesMonotonicClockAndFixedRingCapacity() { + var samples: [Int64] = [100, 90, 110, 120] + let buffer = RealtimeTraceBuffer(capacity: 3) { + samples.removeFirst() + } + + buffer.append(stage: "one") + buffer.append(stage: "two") + buffer.append(stage: "three") + buffer.append(stage: "four") + + let snapshot = buffer.snapshot() + XCTAssertEqual(snapshot.map(\.stage), ["two", "three", "four"]) + XCTAssertEqual(snapshot.map(\.monoMs), [100, 110, 120]) + XCTAssertEqual(snapshot.map(\.sequence), [2, 3, 4]) + } + + func testRealtimeTraceStableLineAndMissingStageSummary() { + var lines: [String] = [] + let store = RealtimeTraceStore( + capacity: 4, + enabled: true, + clock: { 42 }, + logSink: { lines.append($0) } + ) + store.record( + stage: "playbackStatePublished", + trackId: "track 1", + generation: 7, + result: "ok" + ) + store.record(stage: "currentWordPublished", result: nil) + + XCTAssertEqual(store.summary().eventCount, 2) + XCTAssertEqual(store.summary().missingResultCount, 1) + XCTAssertEqual(store.summary().stageCounts["currentWordPublished"], 1) + XCTAssertEqual(lines.count, 2) + XCTAssertTrue(lines[0].contains("trackId=track_1")) + XCTAssertTrue(lines[0].contains("payloadType=-")) + XCTAssertFalse(lines[0].contains("lyrics=")) + XCTAssertFalse(lines[0].contains("bytes=")) + } + + func testRealtimeHandoffCorrelatesCommandAndTrackWithoutProtocolFields() { + var correlation = RealtimeHandoffCorrelation() + let command = correlation.registerCommand( + sequence: 9, + command: "NEXT", + monoMs: 100 + ) + let accepted = correlation.acceptTrack( + trackId: "track-a", + generation: 4, + monoMs: 140 + ) + + XCTAssertEqual(command?.handoffId, "command-9") + XCTAssertEqual(accepted.handoffId, "command-9") + XCTAssertEqual(accepted.triggerType, .iosNext) + XCTAssertEqual(correlation.context(for: "track-a"), accepted) + XCTAssertNil(correlation.context(for: "track-b")) + } + + func testRealtimeHandoffExpiresPendingCommand() { + var correlation = RealtimeHandoffCorrelation() + _ = correlation.registerCommand(sequence: 2, command: "PREVIOUS", monoMs: 10) + + let accepted = correlation.acceptTrack( + trackId: "track-b", + generation: 5, + monoMs: 20_000 + ) + + XCTAssertEqual(accepted.triggerType, .unknown) + XCTAssertNotEqual(accepted.handoffId, "command-2") + } + + func testCurrentWordOrderingDecisionClassifiesRejectReasons() { + var fence = CurrentWordOrderingFence() + XCTAssertEqual( + fence.decision(generation: 2, sequence: 2, positionMs: 2_000), + .accept + ) + XCTAssertEqual( + fence.decision(generation: 2, sequence: 2, positionMs: 2_000), + .duplicate + ) + XCTAssertEqual( + fence.decision(generation: 1, sequence: 3, positionMs: 2_100), + .generationMismatch + ) + XCTAssertEqual( + fence.decision(generation: 2, sequence: 1, positionMs: 2_100), + .sequenceOld + ) + XCTAssertEqual( + fence.decision(generation: 2, sequence: 3, positionMs: 1_900), + .positionStale + ) + } + + func testRealtimePercentilesAndUntrustedClockPolicy() { + let values: [Int64] = [10, 20, 30, 40, 50] + let p50 = try? XCTUnwrap( + RealtimeTraceStatistics.percentile(values, percentile: 50) + ) + let p95 = try? XCTUnwrap( + RealtimeTraceStatistics.percentile(values, percentile: 95) + ) + XCTAssertEqual(p50, 30) + XCTAssertEqual(p95 ?? 0, 48, accuracy: 0.001) + XCTAssertNil(RealtimeTraceStatistics.percentile([], percentile: 50)) + XCTAssertNil( + RealtimeLatencyPolicy.crossDeviceDuration( + startMonoMs: 10, + endMonoMs: 30, + clockTrusted: false + ) + ) + XCTAssertEqual( + RealtimeLatencyPolicy.crossDeviceDuration( + startMonoMs: 10, + endMonoMs: 30, + clockTrusted: true + ), + 20 + ) + XCTAssertNil( + RealtimeLatencyPolicy.crossDeviceDuration( + startMonoMs: 30, + endMonoMs: 10, + clockTrusted: true + ) + ) + } + + @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 makeFullLyricsCacheEntry( + lines: [FullLyricsCacheEntry.Line] + ) -> FullLyricsCacheEntry { + FullLyricsCacheEntry( + version: FullLyricsCacheEntry.version, + trackId: "cost-test", + title: "Song", + artist: "Artist", + album: "Album", + lines: lines, + savedAt: Date(timeIntervalSince1970: 1_723_500_000) + ) + } + + 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..85bc635 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 @@ -57,11 +57,23 @@ import com.example.playeragent.media.QrcV2RebuildProgress import com.example.playeragent.media.QrcV2RebuildStatus import com.example.playeragent.media.QrcWatcherStatus import com.example.playeragent.service.PlayerAgentForegroundService +import com.example.playeragent.service.PlayerNotificationListenerService 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 +81,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 +94,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 +113,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 +126,29 @@ 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") + } + private val qqMusicArtworkListener: (String) -> Unit = { event -> + mainHandler.post { + if (!::currentAlbumArtImageView.isInitialized) { + return@post + } + val playback = lastSonyPlayerUiState.playback + if (lastPlayerUiSongKey.isBlank() || + playback.title.isBlank() || + playback.title == "-" + ) { + return@post + } + appendLog("[PlayerUI] artwork source event=$event") + refreshCurrentAlbumArtForSong( + songKey = lastPlayerUiSongKey, + expectedTitle = playback.title, + expectedArtist = playback.artist.takeUnless { it == "-" }.orEmpty() + ) + } + } @Volatile private var debugStatsLoading = false @Volatile private var lastDebugStatsLoadAt = 0L private var debugStatsLoadRunnable: Runnable? = null @@ -117,6 +156,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 +242,6 @@ class MainActivity : Activity() { refreshControlServiceStatus() appendLog("[PlayerAgent] UI ready") appendLog("[UI] first lightweight render ready costMs=${System.currentTimeMillis() - startedAt}") - scheduleDebugStatsLoad() refreshCurrentMedia() } @@ -218,6 +257,12 @@ class MainActivity : Activity() { } else { registerReceiver(logReceiver, filter) } + PlayerNotificationListenerService.removeQqMusicArtworkListener( + qqMusicArtworkListener + ) + PlayerNotificationListenerService.addQqMusicArtworkListener( + qqMusicArtworkListener + ) refreshControlServiceStatus() } @@ -235,6 +280,9 @@ class MainActivity : Activity() { override fun onStop() { super.onStop() stopControlServiceStatusRefresh() + PlayerNotificationListenerService.removeQqMusicArtworkListener( + qqMusicArtworkListener + ) unregisterReceiver(logReceiver) } @@ -244,6 +292,7 @@ class MainActivity : Activity() { artworkDiscoveryManager?.shutdown() controllerScannerManager?.stopScan() rfcommClientManager?.close() + uiMediaExecutor.shutdownNow() super.onDestroy() } @@ -270,122 +319,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 +932,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 +1052,7 @@ class MainActivity : Activity() { val serviceRunning = PlayerAgentForegroundService.isRunning() setControlServiceStatus(if (serviceRunning) "运行中" else "未启动") setBleStatus(PlayerAgentForegroundService.bleHealthSnapshot()) + refreshProductUi() } private fun setControlServiceStatus(status: String) { @@ -1066,7 +1102,7 @@ class MainActivity : Activity() { } private fun refreshCurrentMedia() { - Thread { + uiMediaExecutor.execute { val state = try { PlaybackStateReader( context = this, @@ -1080,45 +1116,35 @@ class MainActivity : Activity() { null } - val albumArt = try { - AlbumArtTestManager( - context = this, - logger = ::appendThreadSafeLog - ).readCurrentNotificationAlbumArt() - } catch (_: Exception) { - null - } - runOnUiThread { if (state != null) { + val title = state.optString("title") + val artist = state.optString("artist") + val album = state.optString("album") + val songKey = playerUiSongKey(title, artist, album) + val songChanged = songKey != lastPlayerUiSongKey renderPlayerUiState( - title = state.optString("title"), - artist = state.optString("artist"), - album = state.optString("album"), + title = title, + artist = artist, + album = album, lyric = state.optString("lyric"), positionMs = state.optLong("position"), durationMs = state.optLong("duration"), allowAlbumArtRefresh = false ) - } - - if (albumArt != null) { - lastPlayerUiAlbumArtStatus = - "READY ${albumArt.bitmap.width}x${albumArt.bitmap.height}" - currentAlbumArtImageView.setImageBitmap(albumArt.bitmap) - currentAlbumArtTextView.text = - "封面:${albumArt.bitmap.width} x ${albumArt.bitmap.height}" - } else { - lastPlayerUiAlbumArtStatus = "SOURCE_NOT_PROVIDED" - currentAlbumArtImageView - .setImageResource(android.R.drawable.ic_media_play) - currentAlbumArtTextView.text = "封面:未找到" + if (songChanged) { + showAlbumArtLoading() + } + if (title.isNotBlank()) { + refreshCurrentAlbumArtForSong( + songKey = songKey, + expectedTitle = title, + expectedArtist = artist + ) + } } refreshMaintenanceUiState() } - }.apply { - name = "MainStatusRefreshThread" - start() } } @@ -1160,8 +1186,21 @@ class MainActivity : Activity() { val safeTitle = title.ifBlank { "-" } val safeArtist = artist.ifBlank { "-" } val safeAlbum = album.ifBlank { "-" } - val songKey = "$safeTitle|$safeArtist|$safeAlbum" + val songKey = playerUiSongKey(title, artist, album) 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( @@ -1169,39 +1208,74 @@ class MainActivity : Activity() { "artist=$safeArtist album=$safeAlbum" ) if (allowAlbumArtRefresh) { - lastPlayerUiAlbumArtStatus = "LOADING" - currentAlbumArtImageView.setImageResource(android.R.drawable.ic_media_play) - currentAlbumArtTextView.text = "封面:加载中..." - refreshCurrentAlbumArtForSong(songKey) + showAlbumArtLoading() + if (title.isNotBlank()) { + refreshCurrentAlbumArtForSong( + songKey = songKey, + expectedTitle = title, + expectedArtist = artist + ) + } } } - 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 { + "" + } + } + 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 + } } - currentLyricTextView.text = "当前歌词:$lyricText\nLyric status:$statusText$hint" - if (lyricText != lastPlayerUiLyric) { + if (lyricText != lastPlayerUiLyric && lyricText != "暂无歌词") { lastPlayerUiLyric = lyricText appendLog("[PlayerUI] lyric changed text=$lyricText") } - refreshMaintenanceUiState() + lastSonyPlayerUiState = nextUiState + refreshProductUi() + if (songChanged) refreshMaintenanceUiState() + } + + private fun playerUiSongKey(title: String, artist: String, album: String): String { + return "${title.ifBlank { "-" }}|${artist.ifBlank { "-" }}|${album.ifBlank { "-" }}" + } + + private fun showAlbumArtLoading() { + lastPlayerUiAlbumArtStatus = "LOADING" + currentAlbumArtImageView.setImageResource(android.R.drawable.ic_media_play) + currentAlbumArtTextView.text = "封面:加载中..." } - private fun refreshCurrentAlbumArtForSong(songKey: String) { + private fun refreshCurrentAlbumArtForSong( + songKey: String, + expectedTitle: String, + expectedArtist: String + ) { val generation = ++albumArtRefreshGeneration - Thread { + uiMediaExecutor.execute { val albumArt = try { AlbumArtTestManager( context = this, logger = ::appendThreadSafeLog - ).readCurrentNotificationAlbumArt() + ).readCurrentNotificationAlbumArt( + expectedTitle = expectedTitle, + expectedArtist = expectedArtist + ) } catch (exception: Exception) { appendThreadSafeLog( "[PlayerUI] album art refresh failed=${exception.message}" @@ -1223,9 +1297,15 @@ class MainActivity : Activity() { appendLog( "[PlayerUI] album art changed exists=true " + "width=${albumArt.bitmap.width} " + - "height=${albumArt.bitmap.height}" + "height=${albumArt.bitmap.height}" ) } else { + if (lastPlayerUiAlbumArtStatus.startsWith("READY")) { + appendLog( + "[PlayerUI] album art retained reason=transient_exact_source_miss" + ) + return@runOnUiThread + } lastPlayerUiAlbumArtStatus = "SOURCE_NOT_PROVIDED" currentAlbumArtImageView .setImageResource(android.R.drawable.ic_media_play) @@ -1233,10 +1313,8 @@ class MainActivity : Activity() { appendLog("[PlayerUI] album art changed exists=false") } refreshMaintenanceUiState() + refreshProductUi() } - }.apply { - name = "MainAlbumArtRefreshThread" - start() } } @@ -1482,7 +1560,10 @@ class MainActivity : Activity() { val albumArt = AlbumArtTestManager( this, ::appendThreadSafeLog - ).readCurrentNotificationAlbumArt() + ).readCurrentNotificationAlbumArt( + expectedTitle = title, + expectedArtist = artist + ) if (albumArt == null) { appendThreadSafeLog("[ArtworkDiscovery] skipped reason=no notification artwork") return@Thread @@ -2032,10 +2113,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 +2172,7 @@ class MainActivity : Activity() { enabled -> "通知读取权限:无障碍已授权,等待连接" else -> "通知读取权限:未授权" } + refreshProductUi() } private fun ensureLyricStorageAccess(): Boolean { @@ -2136,7 +2222,7 @@ class MainActivity : Activity() { private fun clearLog() { LogBuffer.clear() - logTextView.text = "" + if (::logAdapter.isInitialized) logAdapter.replace(emptyList()) appendLog("[PlayerAgent] Log cleared") } @@ -2175,6 +2261,7 @@ class MainActivity : Activity() { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == REQUEST_PERMISSIONS) { maybeAutoStartControlService("permissions_result") + refreshProductUi() } } @@ -2201,13 +2288,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..6da8468 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,11 @@ import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.content.Context +import android.content.ComponentCallbacks2 +import android.content.pm.ApplicationInfo 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 +23,9 @@ 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.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator +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 +36,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 +47,34 @@ 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 + +internal object PostControlBroadcastPolicy { + fun shouldIncludeTrackMedia( + command: String, + trackIdBeforeControl: String, + observedTrackId: String + ): Boolean { + if (command != "NEXT" && command != "PREVIOUS") return false + if (observedTrackId.isBlank()) return false + return trackIdBeforeControl.isBlank() || observedTrackId != trackIdBeforeControl + } +} + +internal object StatusMessageDeliveryPolicy { + fun canUseLatestInterleavedSlot(messageType: String): Boolean { + return messageType == "volumeState" + } +} class BleGattServerManager( context: Context, @@ -57,11 +84,22 @@ 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 ) { + init { + RealtimeTrace.configure( + logger = logger, + enabled = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + ) + } + 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,7 +110,10 @@ class BleGattServerManager( context = appContext, logger = logger, reactiveMediaController = reactiveMediaController, - onLyricsReady = ::handleLyricsReady + executionHub = executionHub, + onLyricsReady = ::handleLyricsReady, + enableTrackIdentityFastLane = true, + onTrackIdentityReady = ::handleTrackIdentityReady ) private val albumArtTestManager = AlbumArtTestManager( context = appContext, @@ -84,37 +125,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 +159,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 +194,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 +211,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 +247,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 +291,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 +339,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 +366,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 +374,7 @@ class BleGattServerManager( } override fun onNotificationSent(device: BluetoothDevice?, status: Int) { - notifyQueue.onNotificationSent(status) + notifyQueue.onNotificationSent(device?.address, status) } override fun onCharacteristicWriteRequest( @@ -373,17 +432,68 @@ 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 } val command = request.optString("cmd") val seq = request.optString("seq").ifBlank { "unknown" } + val commandSeq = seq.toLongOrNull() + val handoffTrace = TrackHandoffTraceCoordinator.observeCommand( + commandSeq = commandSeq, + commandType = command, + nowMs = receiveElapsedMs + ) + RealtimeTrace.record( + stage = "commandReceived", + monoMs = receiveElapsedMs, + commandSeq = commandSeq, + commandType = command, + processingMs = (SystemClock.elapsedRealtime() - receiveElapsedMs) + .coerceAtLeast(0L), + result = "received", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) + RealtimeTrace.record( + stage = "commandValidated", + commandSeq = commandSeq, + commandType = command, + processingMs = (SystemClock.elapsedRealtime() - parseStartedAtMs) + .coerceAtLeast(0L), + result = "valid", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) logger( "[CTRL-Sony] command parsed seq=$seq cmd=$command " + "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) { + // Stop issuing new notify packets before the ATT response. A dense + // artwork transfer can otherwise fill Sony's L2CAP hold queue and + // make sendResponse() look successful locally while iOS never gets + // its write callback. + notifyQueue.onCommandWriteReceived(device?.address.orEmpty()) val responseStartMs = SystemClock.elapsedRealtime() logger( "[CTRL-Sony] sendResponse begin seq=$seq cmd=$command " + @@ -401,8 +511,8 @@ class BleGattServerManager( "costMs=${SystemClock.elapsedRealtime() - responseStartMs} ok=$ok" ) if (ok) { - notifyQueue.onCommandResponseSent() - recordCommandSuccess(command) + notifyQueue.onCommandResponseSent(device?.address.orEmpty()) + recordCommandSuccess(command, device?.address) } } val dispatchExecutor = when (command) { @@ -416,17 +526,46 @@ class BleGattServerManager( return@execute } val handleStartedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "mediaControlDispatchStart", + monoMs = handleStartedAtMs, + commandSeq = commandSeq, + commandType = command, + queueWaitMs = (handleStartedAtMs - receiveElapsedMs).coerceAtLeast(0L), + result = "started", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) logger( "[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 + } + val dispatchResult = runCatching { + handleCommand(sourceDevice, command, request, seq) + } .onFailure { exception -> logger( "[CTRL-Sony] handle async failed seq=$seq cmd=$command " + "error=${exception.message}" ) } + val handleEndedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "mediaControlDispatchEnd", + monoMs = handleEndedAtMs, + commandSeq = commandSeq, + commandType = command, + processingMs = (handleEndedAtMs - handleStartedAtMs).coerceAtLeast(0L), + result = if (dispatchResult.isSuccess) "success" else "failure", + reason = if (dispatchResult.isSuccess) null else "handler_exception", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) logger( "[CTRL-Sony] handle async end seq=$seq cmd=$command " + "costMs=${SystemClock.elapsedRealtime() - handleStartedAtMs}" @@ -465,24 +604,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 +668,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 +827,100 @@ 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()) + } + } + } + } + + private fun handleTrackIdentityReady(source: JSONObject) { + if (!started) return + onPlaybackUiState(source) + if (subscribedDevices.isEmpty()) return + val trackId = buildAlbumArtProtocolId(source) + val generation = mediaWireGeneration(trackId) + sendTrackInfo(source) + sendAlbumArtIfSongChanged(source) + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(trackId) + RealtimeTrace.record( + stage = "trackFastLanePublished", + trackId = trackId.takeIf { it.isNotBlank() }, + generation = generation.takeIf { it > 0L }, + payloadType = "trackInfo", + result = "published", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) + } + + private fun publishLyricsReadyPlaybackIfCurrent(ready: LyricsReadyGateSnapshot) { + val currentTrack = CurrentTrackRuntimeCache.trackSnapshot() + val currentTrackId = currentTrack?.trackId.orEmpty() + val currentGeneration = currentTrack?.currentTrackGeneration ?: 0L + val currentLyric = currentTrack?.currentLine.orEmpty() + if (!CurrentLyricFastPublishPolicy.shouldPublish( + ready = ready, + currentTrackId = currentTrackId, + currentGeneration = currentGeneration, + currentLyric = currentLyric, + hasSubscribers = subscribedDevices.isNotEmpty() + ) + ) { + logger( + "[CurrentLyricFastPublish] skipped reason=identity_or_content_mismatch " + + "readyGeneration=${ready.generation} currentGeneration=$currentGeneration" + ) + return + } + val source = JSONObject() + .put("title", currentTrack?.title.orEmpty()) + .put("artist", currentTrack?.artist.orEmpty()) + .put("album", currentTrack?.album.orEmpty()) + .put("playing", currentTrack?.isPlaying == true) + .put("position", currentTrack?.positionMs ?: 0L) + .put("positionSampleElapsedMs", currentTrack?.positionAnchorElapsedMs ?: 0L) + .put("speed", currentTrack?.playbackSpeed?.toDouble() ?: 1.0) + .put("duration", currentTrack?.durationMs ?: 0L) + .put("lyric", currentLyric) + val sent = sendCompactPlaybackState(source) + logger( + "[CurrentLyricFastPublish] result=${if (sent) "sent" else "failed"} " + + "generation=$currentGeneration" + ) + } + @SuppressLint("MissingPermission") @Synchronized fun close() { @@ -660,20 +936,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 +969,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 +998,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 +1055,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 +1084,7 @@ class BleGattServerManager( lastCommandSuccessAt = lastCommandSuccessAtMs, lastNotifySuccessAt = lastNotifySuccessAtMs, lastNotifyFailureAt = lastNotifyFailureAtMs, - notifyFailureCount = notifyFailureCount, + notifyFailureCount = currentNotifyFailureCount, lastRecoveryAt = lastRecoveryAt, recoveryCount = recoveryCount, reason = reason @@ -791,31 +1100,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 subscribedWithoutSuccessOlderThan(ageMs: Long): Boolean { - val subscribedAt = lastSubscribedAtMs - return subscribedDevices.isNotEmpty() && - hasOutboundWork() && - lastSuccessHeartbeatAtMs() == 0L && - subscribedAt > 0L && - SystemClock.elapsedRealtime() - subscribedAt > 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 notifyFailureCount(): Int = notifyFailureCount + /** + * 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 + } + + @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 +1225,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 +1274,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 +1306,75 @@ 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) + val trackIdBeforeControl = if (command == "NEXT" || command == "PREVIOUS") { + currentTrackSnapshot()?.trackId.orEmpty() + } else { + "" + } + 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) + } else if (command == "PREVIOUS") { + playbackStateReader.notifyManualPreviousHint(seq) } mediaCommandExecutor.execute(command, seq) + if (command in MULTI_CONTROLLER_DEDUP_COMMANDS) { + scheduleAuthoritativeStateBroadcast(command, trackIdBeforeControl) + } } "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 +1396,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,69 +1446,180 @@ 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, + trackIdBeforeControl: String = "" + ) { + val delayMs = when (command) { + "PLAY_PAUSE" -> 100L + "SEEK_TO" -> 150L + "NEXT", "PREVIOUS" -> 220L + else -> 120L + } + albumArtHandler.postDelayed({ + if (!started || subscribedDevices.isEmpty()) return@postDelayed + val source = playbackStateReader.readPlaybackState() + val observedTrackId = source.optString("trackId") + val includeTrackMedia = PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = command, + trackIdBeforeControl = trackIdBeforeControl, + observedTrackId = observedTrackId + ) + if ((command == "NEXT" || command == "PREVIOUS") && !includeTrackMedia) { + logger( + "[ControlHandoff] media deferred cmd=$command " + + "reason=identity_unchanged" + ) + } + if (command == "NEXT" || command == "PREVIOUS") { + val handoffTrace = observedTrackId.takeIf { it.isNotBlank() } + ?.let(TrackHandoffTraceCoordinator::contextFor) + RealtimeTrace.record( + stage = if (includeTrackMedia) { + "postControlMediaPublished" + } else { + "postControlMediaDeferred" + }, + commandType = command, + trackId = observedTrackId.takeIf { it.isNotBlank() }, + result = if (includeTrackMedia) "published" else "deferred", + reason = if (includeTrackMedia) "identity_changed" else "identity_unchanged", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) + } + publishPlaybackState(source, includeAlbumArt = includeTrackMedia) + }, 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() + TrackHandoffTraceCoordinator.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 ) } private fun sendPlaybackState(includeAlbumArt: Boolean = false) { val source = playbackStateReader.readPlaybackState() + publishPlaybackState(source, includeAlbumArt) + } + + private fun publishPlaybackState(source: JSONObject, includeAlbumArt: Boolean) { onPlaybackUiState(source) if (includeAlbumArt) { sendTrackInfo(source) @@ -1057,6 +1630,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 +1677,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 +1720,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 +1738,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() @@ -1226,9 +1829,12 @@ class BleGattServerManager( "[PredictiveLyrics] metrics candidates=${predictiveMetrics.candidateCount} " + "queueCandidates=${predictiveMetrics.mediaSessionQueueCandidateCount} " + "manualNextHints=${predictiveMetrics.manualNextHintCount} " + + "manualPreviousHints=${predictiveMetrics.manualPreviousHintCount} " + "historyCandidates=${predictiveMetrics.historyTransitionCandidateCount} " + "selected=${predictiveMetrics.selectedCount} " + "rejected=${predictiveMetrics.rejectedCount} " + + "expired=${predictiveMetrics.expiredCount} " + + "invalidated=${predictiveMetrics.invalidatedCount} " + "preloadStart=${predictiveMetrics.preloadStartCount} " + "preloadHit=${predictiveMetrics.preloadHitCount} " + "preloadMiss=${predictiveMetrics.preloadMissCount} " + @@ -1269,13 +1875,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 +1924,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} " + @@ -1312,17 +1964,44 @@ class BleGattServerManager( } val messageType = readMessageType(message) - if ((messageType == "playbackState" || - messageType == "trackInfo" || - messageType == "volumeState" || - messageType == "currentWord") && - notifyQueue.hasLongJobActiveOrQueued() + val runtimeTrace = CurrentTrackRuntimeCache.currentWordEligibilitySnapshot() + val traceTrackId = originalObject?.optString("trackId") + ?.takeIf { it.isNotBlank() } + ?: runtimeTrace.trackId.takeIf { it.isNotBlank() } + val traceGeneration = originalObject + ?.optLong("generation", 0L) + ?.takeIf { it > 0L } + ?: runtimeTrace.generation.takeIf { it > 0L } + val handoffTrace = traceTrackId?.let(TrackHandoffTraceCoordinator::contextFor) + val traceContext = NotifyTraceContext( + trackId = traceTrackId, + generation = traceGeneration, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = originalObject + ?.optLong("sampleMono", 0L) + ?.takeIf { it > 0L } + ?: runtimeTrace.positionAnchorMs.takeIf { it > 0L }, + lineIndex = originalObject + ?.optInt("line", -1) + ?.takeIf { it >= 0 } + ?: runtimeTrace.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = runtimeTrace.wordTimingStatus, + hasCurrentLyric = messageType == "playbackState" && + originalObject?.optString("lyric").orEmpty().isNotBlank() + ) + // Track identity must enter the normal P0 queue so it can preempt a long + // transfer at the next packet boundary. Only replaceable volume state may + // use the latest-only interleaved slot. + if (StatusMessageDeliveryPolicy.canUseLatestInterleavedSlot(messageType) && + notifyQueue.hasLongJobActiveOrQueued(device.address) ) { notifyQueue.setLatestInterleavedShort( device = device, type = messageType, value = value, - delayAfterMs = SHORT_MESSAGE_DELAY_MS + delayAfterMs = SHORT_MESSAGE_DELAY_MS, + traceContext = traceContext ) return true } @@ -1331,11 +2010,96 @@ class BleGattServerManager( device = device, type = messageType, value = value, - delayAfterMs = SHORT_MESSAGE_DELAY_MS + delayAfterMs = SHORT_MESSAGE_DELAY_MS, + traceContext = traceContext ) 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 capabilities = connectionCommandCoordinator.capabilities(device.address) + val decorated = v3SessionCoordinator.decorateIfEnabled( + address = device.address, + value = value, + enabled = capabilities.statusMetaV1 + ) + 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 +2111,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 +2135,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 +2166,7 @@ class BleGattServerManager( } try { + lastAutoPushReadAtMs = SystemClock.elapsedRealtime() val source = playbackStateReader.readPlaybackState() onPlaybackUiState(source) val songChanged = logAutoPushStateChanges(source) @@ -1397,7 +2177,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 +2277,7 @@ class BleGattServerManager( return } logger("[CurrentWordPush] started") - currentWordExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "BleCurrentWordPushThread") - } + currentWordExecutor = executionHub.currentWord scheduleCurrentWordPush(CURRENT_WORD_INITIAL_DELAY_MS) } @@ -1507,6 +2285,23 @@ class BleGattServerManager( private fun scheduleCurrentWordPush(delayMs: Long) { val executor = currentWordExecutor ?: return currentWordPushTask?.cancel(false) + val safeDelayMs = delayMs.coerceAtLeast(0L) + val eligibility = CurrentTrackRuntimeCache.currentWordEligibilitySnapshot() + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(eligibility.trackId) + RealtimeTrace.record( + stage = "currentWordSchedulerCreateRequested", + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + processingMs = safeDelayMs, + result = "requested", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus + ) currentWordPushTask = executor.schedule( { if (subscribedDevices.isEmpty()) { @@ -1527,17 +2322,44 @@ class BleGattServerManager( logger("[CurrentWordPush] paused; boundary task suspended") } }, - delayMs.coerceAtLeast(0L), + safeDelayMs, TimeUnit.MILLISECONDS ) + RealtimeTrace.record( + stage = "currentWordSchedulerScheduled", + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + processingMs = safeDelayMs, + result = "scheduled", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus + ) + RealtimeTrace.record( + stage = "currentWordFirstBoundaryCalculated", + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + processingMs = (eligibility.nextBoundaryDelayMs ?: safeDelayMs).coerceAtLeast(0L), + result = "calculated", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus + ) } @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") @@ -1552,20 +2374,26 @@ class BleGattServerManager( if (songChanged) { val oldSongKey = lastAutoPushSongKey.orEmpty() lastAutoPushSongKey = songKey - currentWordPushEngine.reset() + notifyQueue.onMediaGenerationChanged() + currentWordPushEngine.observeGeneration( + CurrentTrackRuntimeCache.currentGeneration() + ) notifyQueue.cancelJobTypes(setOf("currentWord"), "track changed") logger("[SongChange] detected title=$title") logger("[SongChange] old=$oldSongKey") logger("[SongChange] new=$songKey") logger("[BLE-A][AutoPush] song changed title=$title") - scheduleCurrentWordPush(CURRENT_WORD_TRACK_SWITCH_DELAY_MS) + // A matching lyrics-ready callback is the event barrier for the + // new generation. If it never arrives, this bounded task still + // re-evaluates the exact track/generation/anchor eligibility. + scheduleCurrentWordPush(CURRENT_WORD_TRACK_SWITCH_FALLBACK_MS) } if (playing != lastAutoPushPlaying) { lastAutoPushPlaying = playing logger("[BLE-A][AutoPush] play state changed playing=$playing") - if (playing) { + if (playing && !songChanged) { scheduleCurrentWordPush(0L) - } else { + } else if (!playing) { currentWordPushTask?.cancel(false) currentWordPushTask = null } @@ -1581,7 +2409,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 +2427,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 +2459,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 +2512,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 +2528,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 +2542,7 @@ class BleGattServerManager( val artist = source.optString("artist") val album = source.optString("album") val trackId = buildAlbumArtProtocolId(source) + val generation = mediaWireGeneration(trackId) val candidates = listOf( TrackInfoLimit(30, 30, 20, includeAlbum = true), TrackInfoLimit(30, 30, 0, includeAlbum = false), @@ -1693,6 +2554,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 +2664,7 @@ class BleGattServerManager( currentAlbumArtId = protocolId currentAlbumArtPlaybackState = JSONObject(playbackState.toString()) albumArtSourceRetryCounts.clear() + albumArtUnavailableProtocolIds.clear() CurrentTrackRuntimeCache.updateAlbumArt( trackId = protocolId, albumArtId = protocolId, @@ -1809,6 +2672,13 @@ class BleGattServerManager( logger = logger ) TrackCapabilityTracker.onAlbumArtLoadStart(protocolId) + RealtimeTrace.record( + stage = "albumArtDetected", + trackId = protocolId, + generation = CurrentTrackRuntimeCache.currentGeneration(), + payloadType = "albumArt", + result = "detected" + ) val pending = PendingAlbumArt( cacheKey = cacheKey, @@ -1818,6 +2688,13 @@ class BleGattServerManager( logger("[AlbumArtFastPath] track_changed start id=$protocolId") val cached = albumArtCacheEntry(protocolId, cacheKey) if (cached != null) { + RealtimeTrace.record( + stage = "albumArtCacheHit", + trackId = protocolId, + generation = CurrentTrackRuntimeCache.currentGeneration(), + payloadType = "albumArt", + result = "hit" + ) logger("[AlbumArt] cache hit id=$protocolId source=${cached.source}") logger( "[AlbumArtFastPath] cache hit id=$protocolId source=${cached.source} " + @@ -1848,18 +2725,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 +2755,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 +2768,10 @@ class BleGattServerManager( } } - private fun enqueueAlbumArtOfferOrPending(pending: PendingAlbumArt) { + private fun enqueueAlbumArtOfferOrPending( + pending: PendingAlbumArt, + targetDevice: BluetoothDevice? = null + ) { if (!ALBUM_ART_ENABLED) { return } @@ -1906,26 +2779,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 +2798,67 @@ 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()) - logger("[AlbumArt] offer id=$protocolId") - logger("[AlbumArtDebug] offer sent id=$protocolId") - notifyQueue.enqueueShort( - device = device, - type = "albumArtOffer", - value = offer, - delayAfterMs = SHORT_MESSAGE_DELAY_MS - ) + 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}") + RealtimeTrace.record( + stage = "albumArtOfferEnqueued", + trackId = protocolId, + generation = CurrentTrackRuntimeCache.currentGeneration(), + payloadType = "albumArtOffer", + result = "queued" + ) + notifyQueue.enqueueShort( + device = device, + type = "albumArtOffer", + value = offer, + delayAfterMs = SHORT_MESSAGE_DELAY_MS + ) + } } @Synchronized @@ -1968,6 +2866,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 +3042,7 @@ class BleGattServerManager( } return@synchronized } - if (isLikelyPlaceholderAlbumArt(albumArt.bitmap)) { + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(albumArt.bitmap)) { reactiveMediaController.markAlbumArtFinished( trackId = pending.protocolId, generation = mediaGeneration, @@ -2173,6 +3074,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 +3122,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 +3154,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 +3194,7 @@ class BleGattServerManager( synchronized(albumArtCache) { albumArtCache[albumArtPrimaryCacheKey(protocolId)] = entry albumArtCache[albumArtFallbackCacheKey(cacheKey)] = entry + trimAlbumArtCacheLocked() } logger( "[AlbumArtCache] put id=$protocolId source=$source " + @@ -2275,6 +3202,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 +3257,7 @@ class BleGattServerManager( } private fun failPendingAlbumArtRequests(protocolId: String, reason: String) { + albumArtUnavailableProtocolIds.add(protocolId) val pending = albumArtPendingRequests.values .filter { it.protocolId == protocolId } .toList() @@ -2326,17 +3284,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 +3314,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 +3367,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 +3387,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,18 +3427,43 @@ class BleGattServerManager( ) val useBinaryAlbumArt = (quality == AlbumArtQuality.PREVIEW || quality == AlbumArtQuality.HQ) && - clientSupportsBinaryAlbumArt + connectionCommandCoordinator.capabilities(device.address).binaryAlbumArt val maximumPayload = if (useBinaryAlbumArt) { albumArtMaximumPayloadFor(device) } else { minOf(albumArtMaximumPayloadFor(device), MAX_ALBUM_JSON_BYTES) } + // Capture one authoritative wire generation for the entire transfer. + // ReactiveMediaController's generation remains an internal task fence + // and can have a different offset after a service restart. + val artworkGeneration = mediaWireGeneration(protocolId) + val encodeStartedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "previewEncodeStart", + monoMs = encodeStartedAtMs, + trackId = protocolId, + generation = artworkGeneration, + payloadType = quality.wireValue, + result = "started" + ) val preparation = prepareAlbumArt( bitmap = bitmap, deviceMaximumPayload = maximumPayload, protocolId = protocolId, quality = quality, - binaryTransport = useBinaryAlbumArt + binaryTransport = useBinaryAlbumArt, + wireGeneration = artworkGeneration + ) + val encodeEndedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "previewEncodeEnd", + monoMs = encodeEndedAtMs, + trackId = protocolId, + generation = artworkGeneration, + payloadType = quality.wireValue, + processingMs = (encodeEndedAtMs - encodeStartedAtMs).coerceAtLeast(0L), + result = if (preparation.prepared != null) "success" else "failure", + reason = if (preparation.prepared != null) null else "encode_failed" ) val preparedAlbumArt = preparation.prepared if (preparedAlbumArt == null) { @@ -2470,6 +3495,7 @@ class BleGattServerManager( val albumPackets = preparedAlbumArt.packets if (useBinaryAlbumArt) { rememberAlbumArtBinaryTransfer( + device = device, protocolId = protocolId, quality = quality, packets = albumPackets.packets @@ -2508,6 +3534,19 @@ class BleGattServerManager( } val startAt = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = if (quality == AlbumArtQuality.HQ) { + "hqSendStart" + } else { + "previewSendStart" + }, + monoMs = startAt, + trackId = protocolId, + generation = artworkGeneration, + payloadType = quality.wireValue, + chunkCount = totalChunks, + result = "started" + ) if (quality == AlbumArtQuality.HQ) { logger("[AlbumArtHQ] send start chunks=$totalChunks") } else if (useBinaryAlbumArt) { @@ -2518,6 +3557,10 @@ class BleGattServerManager( "quality=${quality.wireValue} chunks=$totalChunks" ) } + sendMediaLoadState( + device, "artwork", "transferring", "transfer_preparing", false, + protocolId, artworkGeneration + ) notifyQueue.enqueueLongJob( type = ALBUM_ART_JOB_TYPE, device = device, @@ -2537,8 +3580,21 @@ class BleGattServerManager( }, onComplete = { albumArtRequestsInFlight.remove(requestKey) - albumArtRequestsCompleted.add(requestKey) + albumArtRequestCompletedAtMs[requestKey] = SystemClock.elapsedRealtime() val costMs = SystemClock.elapsedRealtime() - startAt + RealtimeTrace.record( + stage = if (quality == AlbumArtQuality.HQ) { + "hqSendEnd" + } else { + "previewSendEnd" + }, + trackId = protocolId, + generation = artworkGeneration, + payloadType = quality.wireValue, + processingMs = costMs.coerceAtLeast(0L), + chunkCount = totalChunks, + result = "success" + ) if (quality == AlbumArtQuality.HQ) { logger("[AlbumArtHQ] send end costMs=$costMs") } else if (useBinaryAlbumArt) { @@ -2557,6 +3613,10 @@ class BleGattServerManager( "costMs=$costMs" ) TrackCapabilityTracker.onAlbumArtSent(protocolId) + sendMediaLoadState( + device, "artwork", "ready", "transfer_complete", false, + protocolId, artworkGeneration + ) sendPendingAlbumArtIfAny() CurrentTrackRuntimeCache.updateAlbumArt( trackId = protocolId, @@ -2567,6 +3627,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") } @@ -2586,16 +3650,39 @@ class BleGattServerManager( deviceMaximumPayload: Int, protocolId: String, quality: AlbumArtQuality, - binaryTransport: Boolean + binaryTransport: Boolean, + wireGeneration: Long ): AlbumArtPreparation { val encodedCacheKey = listOf( protocolId, 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, + wireGeneration = wireGeneration + ) + } + } + + private fun prepareAlbumArtLocked( + bitmap: Bitmap, + deviceMaximumPayload: Int, + protocolId: String, + quality: AlbumArtQuality, + binaryTransport: Boolean, + encodedCacheKey: String, + wireGeneration: Long + ): AlbumArtPreparation { getEncodedAlbumArt(encodedCacheKey)?.let { cached -> val cachedPackets = if (binaryTransport) { buildBinaryAlbumArtPackets( @@ -2603,6 +3690,7 @@ class BleGattServerManager( protocolId, quality, cached.bytes, + wireGeneration, when (quality) { AlbumArtQuality.PREVIEW -> ALBUM_ART_PREVIEW_MAX_CHUNKS AlbumArtQuality.HQ -> ALBUM_ART_HQ_MAX_CHUNKS @@ -2706,6 +3794,7 @@ class BleGattServerManager( protocolId = protocolId, quality = quality, bytes = compressed.bytes, + wireGeneration = wireGeneration, maximumChunks = maximumChunks ) } else { @@ -2842,6 +3931,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, @@ -3047,6 +4151,7 @@ class BleGattServerManager( protocolId: String, quality: AlbumArtQuality, bytes: ByteArray, + wireGeneration: Long, maximumChunks: Int ): AlbumArtPackets? { val maxPayload = deviceMaximumPayload @@ -3064,14 +4169,13 @@ class BleGattServerManager( } val transferId = UUID.randomUUID().toString().replace("-", "").take(12) val crc32 = BleTransferCodec.crc32Hex(bytes) - val generation = reactiveMediaController.generation() val start = JSONObject() .put("type", "albumArtBinaryStart") .put("id", protocolId) .put("quality", quality.wireValue) .put("transferId", transferId) .put("crc32", crc32) - .put("generation", generation) + .put("generation", wireGeneration) .put("size", bytes.size) .put("chunks", totalChunks) .put("format", "jpg") @@ -3083,7 +4187,7 @@ class BleGattServerManager( .put("quality", quality.wireValue) .put("transferId", transferId) .put("crc32", crc32) - .put("generation", generation) + .put("generation", wireGeneration) .toString() .toByteArray(Charsets.UTF_8) if (start.size > maxPayload || end.size > maxPayload) { @@ -3188,6 +4292,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 " + @@ -3261,6 +4380,16 @@ class BleGattServerManager( return trimmed.take(ALBUM_ART_ID_HASH_BYTES) } + private fun mediaWireGeneration(protocolTrackId: String): Long { + val runtimeTrack = playbackStateReader.runtimeCacheSnapshot().track + return MediaWireGenerationPolicy.resolve( + protocolTrackId = protocolTrackId, + runtimeTrackId = runtimeTrack?.trackId, + runtimeGeneration = runtimeTrack?.currentTrackGeneration, + fallbackGeneration = reactiveMediaController.generation() + ) + } + private fun sha256(bytes: ByteArray): String { return MessageDigest.getInstance("SHA-256") .digest(bytes) @@ -3283,69 +4412,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 +4515,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 +4533,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 +4549,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 +4571,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 +4596,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 +4611,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 +4650,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 +4671,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 +4684,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 +4702,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 +4727,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 +4749,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 +4771,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 +4805,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 +4819,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 +4841,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 +4857,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 +4896,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,9 +5002,14 @@ 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") + .ifBlank { request.optString("id") } if (requestedTrackId.isNotBlank()) { lyricTrace( stage = "fullLyricsSendSkip", @@ -3923,12 +5018,13 @@ 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)) { + .ifBlank { request.optString("id") } + if (notifyQueue.hasJobTypeActiveOrQueued(FULL_LYRICS_JOB_TYPE, device.address)) { logger( "[FullLyrics] request skipped reason=transfer_in_progress " + "trackId=$requestedTrackId" @@ -3944,7 +5040,12 @@ class BleGattServerManager( lyricTrace( stage = "fullLyricsRequest", trackId = requestedTrackId, - extra = mapOf("positionMs" to request.optLong("positionMs", 0L).toString()) + extra = mapOf( + "positionMs" to request.optLong( + "positionMs", + request.optLong("p", 0L) + ).toString() + ) ) } val source = playbackSourceForRequestedTrack(requestedTrackId) @@ -3958,6 +5059,15 @@ class BleGattServerManager( val runtimeGeneration = runtimeSnapshot.track?.currentTrackGeneration ?: 0L val readyGate = playbackStateReader.lyricsReadyGateSnapshot() val allLines = playbackStateReader.runtimeLyricLinesSnapshot() + if (runtimeLineCount > 0) { + RealtimeTrace.record( + stage = "lyricCacheHit", + trackId = traceId, + generation = runtimeGeneration, + payloadType = "fullLyrics", + result = "hit" + ) + } val candidateLines = allLines .filter { it.text.isNotBlank() } .take(MAX_FULL_LYRICS_LINES) @@ -3989,11 +5099,13 @@ class BleGattServerManager( "runtimeGeneration" to runtimeGeneration.toString() ) ) - val includeWordsAroundCurrent = - request.optBoolean("includeWordsAroundCurrent", false) + val includeWordsAroundCurrent = request.optBoolean( + "includeWordsAroundCurrent", + request.optBoolean("w", false) + ) val requestedPositionMs = request.optLong( "positionMs", - source.optLong("position", 0L) + request.optLong("p", source.optLong("position", 0L)) ) val currentLineIndex = if (includeWordsAroundCurrent) { findCurrentLyricIndex(lines, requestedPositionMs) @@ -4016,6 +5128,7 @@ class BleGattServerManager( val diagnostic = playbackStateReader.lyricDiagnosticSnapshot() if (!readyGate.lyricsReady || candidateLines.isEmpty()) { rememberPendingFullLyricsRequest( + device = device, request = request, requestedTrackId = requestedTrackId, protocolTrackId = trackId, @@ -4031,20 +5144,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 +5168,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 +5257,101 @@ class BleGattServerManager( TrackCapabilityTracker.onFullLyricsSent(traceId, trackId, 0) return } - clearMatchingPendingFullLyrics(requestedTrackId, trackId, runtimeGeneration) + clearMatchingPendingFullLyrics( + device.address, + requestedTrackId, + trackId, + runtimeGeneration + ) val maximumPayload = maximumPayloadFor(device) - val requestedFormat = request.optString("format") + val requestedFormat = request.optString("format").ifBlank { + if (request.optString("f") == "z") FULL_LYRICS_ZLIB_FORMAT else "" + } + val negotiatedCapabilities = connectionCommandCoordinator.capabilities(device.address) + val cacheDescriptor = FullLyricsCacheValidation.describe(title, artist, lines) + val cacheValidationRequest = parseFullLyricsCacheValidationRequest(request) + val cacheValidationDecision = if (request.optBoolean("forceRefresh", false)) { + FullLyricsCacheValidationDecision.REQUEST_MISSING + } else { + FullLyricsCacheValidation.decide( + capabilityEnabled = negotiatedCapabilities.mediaCacheValidationV1, + request = cacheValidationRequest, + actual = cacheDescriptor + ) + } + if (cacheValidationDecision == FullLyricsCacheValidationDecision.HIT && + sendFullLyricsCacheStatus( + device = device, + type = "fullLyricsNotModified", + trackId = trackId, + generation = readyGate.generation, + descriptor = cacheDescriptor + ) + ) { + val bytesSaved = FullLyricsCacheValidation.estimatedPayloadBytes(lines) + sendMediaLoadState( + device, "lyrics", "ready", "cache_validation_hit", false, + trackId, readyGate.generation + ) + RealtimeTrace.record( + stage = "cacheValidationHit", + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyrics", + processingMs = SystemClock.elapsedRealtime() - buildStartedAtMs, + result = "hit" + ) + RealtimeTrace.record( + stage = "fullLyricsTransferSkipped", + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyrics", + result = "skipped", + reason = "cache_validation_hit bytesSaved=$bytesSaved" + ) + logger( + "[FullLyricsCacheValidation] hit trackId=$trackId " + + "lines=${lines.size} bytesSaved=$bytesSaved" + ) + return + } + if (cacheValidationRequest != null) { + RealtimeTrace.record( + stage = "cacheValidationMiss", + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyrics", + result = "miss", + reason = cacheValidationDecision.name.lowercase() + ) + logger( + "[FullLyricsCacheValidation] miss trackId=$trackId " + + "reason=${cacheValidationDecision.name.lowercase()}" + ) + } + if (negotiatedCapabilities.mediaCacheValidationV1) { + sendFullLyricsCacheStatus( + device = device, + type = "fullLyricsCacheMetadata", + trackId = trackId, + generation = readyGate.generation, + descriptor = cacheDescriptor + ) + } + sendMediaLoadState( + device = device, + resource = "lyrics", + stage = "preparing", + reason = "transfer_preparing", + retryable = false, + trackId = trackId, + generation = runtimeGeneration + ) 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 +5366,12 @@ class BleGattServerManager( buildStartedAtMs = buildStartedAtMs ) ) { - clearMatchingPendingFullLyrics(requestedTrackId, trackId, runtimeGeneration) + clearMatchingPendingFullLyrics( + device.address, + requestedTrackId, + trackId, + runtimeGeneration + ) return } val packets = mutableListOf() @@ -4209,6 +5466,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", @@ -4217,6 +5478,23 @@ class BleGattServerManager( generation = readyGate.generation, extra = mapOf("lines" to lines.size.toString()) ) + RealtimeTrace.record( + stage = "fullLyricsEnqueued", + monoMs = sendStartedAtMs, + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyricsJson", + chunkCount = packets.size, + result = "queued" + ) + RealtimeTrace.record( + stage = "fullLyricsSendStart", + monoMs = sendStartedAtMs, + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyricsJson", + result = "started" + ) notifyQueue.enqueueLongJob( type = FULL_LYRICS_JOB_TYPE, device = device, @@ -4224,11 +5502,25 @@ class BleGattServerManager( maxSendDurationMs = FULL_LYRICS_MAX_SEND_MS, shouldCancel = { !isLyricsTransferCurrent(trackId, readyGate.generation) }, onComplete = { + val sendEndedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "fullLyricsSendEnd", + monoMs = sendEndedAtMs, + trackId = traceId, + generation = readyGate.generation, + payloadType = "fullLyricsJson", + processingMs = (sendEndedAtMs - sendStartedAtMs).coerceAtLeast(0L), + result = "success" + ) logger( "[FullLyricsPerf] send end costMs=" + "${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 +5530,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 +5558,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 +5666,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 +5673,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 +5696,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 +5733,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( @@ -4424,6 +5777,25 @@ class BleGattServerManager( "chunks" to binaryChunks.size.toString() ) ) + RealtimeTrace.record( + stage = "fullLyricsEnqueued", + monoMs = sendStartedAtMs, + trackId = traceId, + generation = generation, + transferId = transferId, + payloadType = "fullLyricsBinary", + chunkCount = packets.size, + result = "queued" + ) + RealtimeTrace.record( + stage = "fullLyricsSendStart", + monoMs = sendStartedAtMs, + trackId = traceId, + generation = generation, + transferId = transferId, + payloadType = "fullLyricsBinary", + result = "started" + ) notifyQueue.enqueueLongJob( type = FULL_LYRICS_JOB_TYPE, device = device, @@ -4432,6 +5804,21 @@ class BleGattServerManager( maxSendDurationMs = FULL_LYRICS_MAX_SEND_MS, shouldCancel = { !isLyricsTransferCurrent(trackId, generation) }, onComplete = { + val sendEndedAtMs = SystemClock.elapsedRealtime() + RealtimeTrace.record( + stage = "fullLyricsSendEnd", + monoMs = sendEndedAtMs, + trackId = traceId, + generation = generation, + transferId = transferId, + payloadType = "fullLyricsBinary", + processingMs = (sendEndedAtMs - sendStartedAtMs).coerceAtLeast(0L), + result = "success" + ) + sendMediaLoadState( + device, "lyrics", "ready", "transfer_complete", false, + trackId, generation + ) logger( "[FullLyricsV2] send end trackId=$trackId " + "costMs=${SystemClock.elapsedRealtime() - sendStartedAtMs}" @@ -4448,14 +5835,112 @@ class BleGattServerManager( ) ) TrackCapabilityTracker.onFullLyricsSent(traceId, trackId, lines.size) + }, + onFailure = { + sendMediaLoadState( + device, "lyrics", "failed", "transfer_failed", true, + trackId, generation + ) + } + ) + return true + } + + 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.translation.orEmpty().hashCode().toLong()) + mix(line.romanization.orEmpty().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 parseFullLyricsCacheValidationRequest( + request: JSONObject + ): FullLyricsCacheValidationRequest? { + val fingerprint = request.optString("knownFingerprint") + .ifBlank { request.optString("fp") } + .trim() + if (fingerprint.isBlank() || + (!request.has("knownSchemaVersion") && !request.has("sv")) || + (!request.has("knownLineCount") && !request.has("n")) || + (!request.has("knownTranslationLineCount") && !request.has("tc")) || + (!request.has("knownRomanizationLineCount") && !request.has("rc")) + ) { + return null + } + return FullLyricsCacheValidationRequest( + fingerprint = fingerprint, + schemaVersion = if (request.has("knownSchemaVersion")) { + request.optInt("knownSchemaVersion", -1) + } else { + request.optInt("sv", -1) + }, + lineCount = if (request.has("knownLineCount")) { + request.optInt("knownLineCount", -1) + } else { + request.optInt("n", -1) + }, + translationLineCount = if (request.has("knownTranslationLineCount")) { + request.optInt("knownTranslationLineCount", -1) + } else { + request.optInt("tc", -1) + }, + romanizationLineCount = if (request.has("knownRomanizationLineCount")) { + request.optInt("knownRomanizationLineCount", -1) + } else { + request.optInt("rc", -1) } ) + } + + private fun sendFullLyricsCacheStatus( + device: BluetoothDevice, + type: String, + trackId: String, + generation: Long, + descriptor: FullLyricsCacheDescriptor + ): Boolean { + val value = JSONObject() + .put("type", type) + .put("id", trackId) + .put("g", generation) + .put("fp", descriptor.fingerprint) + .put("sv", descriptor.schemaVersion) + .put("n", descriptor.lineCount) + .put("tc", descriptor.translationLineCount) + .put("rc", descriptor.romanizationLineCount) + if (value.toString().toByteArray(Charsets.UTF_8).size > maximumPayloadFor(device)) { + logger("[FullLyricsCacheValidation] status skipped type=$type reason=metadata_exceeds_mtu") + return false + } + sendShortJsonIfFits(device, type, value) return true } - private fun sendLyricWindow(request: JSONObject) { - if (!clientCapabilitiesNegotiated || !clientSupportsLyricWindow) return - val device = subscribedDevices.values.firstOrNull() ?: return + 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 +5963,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 +5976,20 @@ class BleGattServerManager( ) logger( "[LyricWindow] pending trackId=$trackId " + - "generation=${pendingLyricWindowRequest?.generation} reason=$reason" + "generation=${pendingLyricWindowRequests[device.address]?.generation} " + + "device=${device.address} reason=$reason" + ) + RealtimeTrace.record( + stage = "lyricWindowPendingQueued", + trackId = requestedTrackId.ifBlank { trackId }, + generation = gate.generation, + payloadType = "lyricWindow", + result = "queued", + 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) @@ -4551,12 +6046,39 @@ class BleGattServerManager( end, FULL_LYRICS_JSON_NOTIFICATION_DELAY_MS ) + RealtimeTrace.record( + stage = "lyricWindowEnqueued", + trackId = trackId, + generation = gate.generation, + transferId = transferId, + payloadType = "lyricWindow", + chunkCount = packets.size, + result = "queued" + ) + RealtimeTrace.record( + stage = "lyricWindowSendStart", + trackId = trackId, + generation = gate.generation, + transferId = transferId, + payloadType = "lyricWindow", + result = "started" + ) notifyQueue.enqueueLongJob( type = LYRIC_WINDOW_JOB_TYPE, device = device, packets = packets, priority = BleNotifyQueue.Priority.P1_INTERACTIVE, - shouldCancel = { !isLyricsTransferCurrent(trackId, gate.generation) } + shouldCancel = { !isLyricsTransferCurrent(trackId, gate.generation) }, + onComplete = { + RealtimeTrace.record( + stage = "lyricWindowSendEnd", + trackId = trackId, + generation = gate.generation, + transferId = transferId, + payloadType = "lyricWindow", + result = "success" + ) + } ) logger("[LyricWindow] send trackId=$trackId first=$first count=${window.size}") } @@ -4603,20 +6125,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 +6183,7 @@ class BleGattServerManager( } private fun rememberAlbumArtBinaryTransfer( + device: BluetoothDevice, protocolId: String, quality: AlbumArtQuality, packets: List @@ -4671,17 +6194,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 +6312,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 +6592,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 +6633,7 @@ class BleGattServerManager( } private fun rememberPendingFullLyricsRequest( + device: BluetoothDevice, request: JSONObject, requestedTrackId: String, protocolTrackId: String, @@ -5110,7 +6644,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, @@ -5123,6 +6658,14 @@ class BleGattServerManager( "requested=$requestedTrackId generation=$generation " + "state=${readyGate.state} reason=$reason" ) + RealtimeTrace.record( + stage = "pendingQueued", + trackId = requestedTrackId.ifBlank { protocolTrackId }, + generation = generation, + payloadType = "fullLyrics", + result = "queued", + reason = reason + ) lyricTrace( stage = "pendingQueued", trackId = requestedTrackId.ifBlank { protocolTrackId }, @@ -5137,11 +6680,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 +6693,51 @@ 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) { + RealtimeTrace.record( + stage = "lyricReady", + trackId = snapshot.trackId, + generation = snapshot.generation, + payloadType = "lyrics", + result = if (snapshot.lyricsReady) "ready" else "not_ready", + reason = snapshot.reason, + wordTimingStatus = snapshot.wordTimingStatus, + cacheSource = snapshot.cacheSource, + failureReason = snapshot.failureReason.takeIf { it.isNotBlank() }, + handoffId = TrackHandoffTraceCoordinator.contextFor(snapshot.trackId)?.handoffId, + triggerType = TrackHandoffTraceCoordinator.contextFor(snapshot.trackId) + ?.triggerType + ?.name + ) + publishLyricsReadyPlaybackIfCurrent(snapshot) + schedulePlaybackUiRefresh("lyrics_ready") flushPendingLyricWindow(snapshot) if (!snapshot.lyricsReady) { return @@ -5188,84 +6753,112 @@ 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) - ) + RealtimeTrace.record( + stage = "pendingFlush", + trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, + generation = snapshot.generation, + payloadType = "fullLyrics", + processingMs = (SystemClock.elapsedRealtime() - pending.createdAtMs) + .coerceAtLeast(0L), + result = "flushed" + ) + 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}" + ) + RealtimeTrace.record( + stage = "lyricWindowPendingFlush", + trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, + generation = snapshot.generation, + payloadType = "lyricWindow", + processingMs = (SystemClock.elapsedRealtime() - pending.createdAtMs) + .coerceAtLeast(0L), + result = "flushed" + ) + 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 +6894,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 +7026,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 +7155,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_TRACK_SWITCH_FALLBACK_MS = 250L 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 +7178,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 +7205,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 +7232,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 +7293,7 @@ class BleGattServerManager( ) private data class PendingFullLyricsRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, @@ -5685,6 +7302,7 @@ class BleGattServerManager( ) private data class PendingLyricWindowRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, @@ -5692,26 +7310,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 +7321,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..826a08a --- /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 = 15L + private const val JSON_MIN_DELAY_MS = 2L + private const val BINARY_MIN_DELAY_MS = 1L + private const val ARTWORK_MIN_DELAY_MS = 15L + 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..7ca70f9 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,85 @@ 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.diagnostics.RealtimeTrace 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 + } +} + +data class NotifyTraceContext( + val trackId: String? = null, + val generation: Long? = null, + val handoffId: String? = null, + val triggerType: String? = null, + val positionAnchorMs: Long? = null, + val lineIndex: Int? = null, + val wordTimingStatus: String? = null, + val hasCurrentLyric: Boolean = false +) + +internal class CommandResponseQuietWindows( + private val quietWindowMs: Long +) { + private val quietUntilMsByAddress = LinkedHashMap() + + /** Keep the first deadline so a command burst cannot extend the window forever. */ + fun reserve(deviceAddress: String, nowMs: Long): Long { + if (deviceAddress.isBlank()) return 0L + val existing = quietUntilMsByAddress[deviceAddress] + if (existing != null && existing > nowMs) { + return existing + } + return (nowMs + quietWindowMs).also { + quietUntilMsByAddress[deviceAddress] = it + } + } + + fun remainingDelayMs( + deviceAddress: String, + packetType: String, + nowMs: Long + ): Long { + if (!BleNotifyQueue.isCommandResponseSensitivePacket(packetType)) { + return 0L + } + val quietUntilMs = quietUntilMsByAddress[deviceAddress] ?: return 0L + if (quietUntilMs <= nowMs) { + quietUntilMsByAddress.remove(deviceAddress) + return 0L + } + return quietUntilMs - nowMs + } + + fun remove(deviceAddress: String) { + quietUntilMsByAddress.remove(deviceAddress) + } + + fun clear() { + quietUntilMsByAddress.clear() + } +} class BleNotifyQueue( private val serverProvider: () -> BluetoothGattServer?, @@ -20,12 +93,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 @@ -41,13 +122,42 @@ class BleNotifyQueue( private var notifyTimeoutRunnable: Runnable? = null private var drainingCancelledCallback = false private var cancelledCallbackDrainRunnable: Runnable? = null - private var commandResponseQuietUntilMs = 0L + private val commandResponseQuietWindows = + CommandResponseQuietWindows(COMMAND_RESPONSE_QUIET_MS) + private var activeNotifyStartedAtMs = 0L + private var activeNotifyDeviceAddress: String? = null + private var activeNotifyPacketType: String? = null + private var activeNotifyTraceContext: NotifyTraceContext? = null + + fun resetLinkProfile(address: String, mtu: Int) { + runOnQueueThread { resetLinkProfileOnQueue(address, mtu) } + } + + @Synchronized + private fun resetLinkProfileOnQueue(address: String, mtu: Int) { + if (address.isBlank()) return + commandResponseQuietWindows.remove(address) + 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, type: String, value: ByteArray, - delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS + delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS, + traceContext: NotifyTraceContext? = null ) { enqueueJob( SendJob( @@ -60,7 +170,8 @@ class BleNotifyQueue( delayAfterMs = delayAfterMs ) ), - isLongJob = false + isLongJob = false, + traceContext = traceContext ) ) } @@ -71,16 +182,32 @@ class BleNotifyQueue( * iOS client never receives its write callback during a dense album-art transfer. */ @Synchronized - fun onCommandResponseSent() { - commandResponseQuietUntilMs = maxOf( - commandResponseQuietUntilMs, - SystemClock.elapsedRealtime() + COMMAND_RESPONSE_QUIET_MS - ) + fun onCommandWriteReceived(deviceAddress: String) { + reserveCommandResponseWindow(deviceAddress, SystemClock.elapsedRealtime()) + } + + @Synchronized + fun onCommandResponseSent(deviceAddress: String) { + reserveCommandResponseWindow(deviceAddress, SystemClock.elapsedRealtime()) + } + + private fun reserveCommandResponseWindow(deviceAddress: String, nowMs: Long) { + if (deviceAddress.isBlank()) return + val quietUntilMs = commandResponseQuietWindows.reserve(deviceAddress, nowMs) if (!notificationInFlight && !drainingCancelledCallback) { - handler.postDelayed({ sendNextPacket() }, COMMAND_RESPONSE_QUIET_MS) + handler.postDelayed( + { sendNextPacket() }, + (quietUntilMs - nowMs).coerceAtLeast(0L) + ) } } + @Synchronized + fun onMediaGenerationChanged() { + commandResponseQuietWindows.clear() + handler.post { sendNextPacket() } + } + fun enqueueLongJob( type: String, device: BluetoothDevice, @@ -89,7 +216,8 @@ class BleNotifyQueue( maxSendDurationMs: Long? = null, shouldCancel: (() -> Boolean)? = null, onComplete: (() -> Unit)? = null, - onFailure: (() -> Unit)? = null + onFailure: (() -> Unit)? = null, + traceContext: NotifyTraceContext? = null ) { if (packets.isEmpty()) { return @@ -104,26 +232,74 @@ class BleNotifyQueue( maxSendDurationMs = maxSendDurationMs, shouldCancel = shouldCancel, onComplete = onComplete, - onFailure = onFailure + onFailure = onFailure, + traceContext = traceContext ) ) } - @Synchronized fun setLatestInterleavedShort( device: BluetoothDevice, type: String, value: ByteArray, - delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS + delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS, + traceContext: NotifyTraceContext? = null + ) { + runOnQueueThread { + setLatestInterleavedShortOnQueue( + device, + type, + value, + delayAfterMs, + traceContext + ) + } + } + + @Synchronized + private fun setLatestInterleavedShortOnQueue( + device: BluetoothDevice, + type: String, + value: ByteArray, + delayAfterMs: Long, + traceContext: NotifyTraceContext? ) { - latestInterleavedPackets[type] = InterleavedPacket( + latestInterleavedPackets[interleavedPacketKey(device.address, type)] = InterleavedPacket( device = device, packet = Packet( type = type, value = value, delayAfterMs = delayAfterMs - ) + ), + traceContext = traceContext + ) + RealtimeTrace.record( + stage = "notifyEnqueued", + payloadType = type, + chunkCount = 1, + result = "queued_interleaved", + trackId = traceContext?.trackId, + generation = traceContext?.generation, + handoffId = traceContext?.handoffId, + triggerType = traceContext?.triggerType, + positionAnchorMs = traceContext?.positionAnchorMs, + lineIndex = traceContext?.lineIndex, + wordTimingStatus = traceContext?.wordTimingStatus + ) + recordPlaybackSpecificTrace( + stage = "playbackEnqueued", + packetType = type, + context = traceContext, + result = "queued_interleaved" ) + if (type == "playbackState" && traceContext?.hasCurrentLyric == true) { + recordContextTrace( + stage = "lyricCurrentLineEnqueued", + payloadType = "playbackState", + context = traceContext, + result = "queued_interleaved" + ) + } logInterleavedEventThrottled( isSavedEvent = true, message = "[BleNotifyQueue] long job active, $type saved as latest" @@ -131,22 +307,67 @@ 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) + (removedJobs + listOfNotNull(current.takeIf { activeJobRemoved })).forEach { job -> + RealtimeTrace.record( + stage = "notifyCancelled", + payloadType = job.type, + queueWaitMs = (SystemClock.elapsedRealtime() - job.enqueuedAtMs).coerceAtLeast(0L), + result = "cancelled", + reason = reason + ) + } + 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 +376,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 +400,84 @@ 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 callbackTraceContext = activeNotifyTraceContext + val callbackRttMs = if (activeNotifyStartedAtMs > 0L) { + SystemClock.elapsedRealtime() - activeNotifyStartedAtMs + } else { + 0L + } + RealtimeTrace.record( + stage = "notifyCallback", + payloadType = callbackType ?: job.type, + processingMs = callbackRttMs, + chunkIndex = activePacketIndex, + chunkCount = job.packets.size, + result = if (status == BluetoothGatt.GATT_SUCCESS) "success" else "failure", + reason = if (status == BluetoothGatt.GATT_SUCCESS) null else "callback_failed", + trackId = callbackTraceContext?.trackId, + generation = callbackTraceContext?.generation, + handoffId = callbackTraceContext?.handoffId, + triggerType = callbackTraceContext?.triggerType, + positionAnchorMs = callbackTraceContext?.positionAnchorMs, + lineIndex = callbackTraceContext?.lineIndex, + wordTimingStatus = callbackTraceContext?.wordTimingStatus + ) + recordPlaybackSpecificTrace( + stage = "playbackNotifyCallback", + packetType = callbackType ?: job.type, + context = callbackTraceContext, + processingMs = callbackRttMs, + result = if (status == BluetoothGatt.GATT_SUCCESS) "success" else "failure", + reason = if (status == BluetoothGatt.GATT_SUCCESS) null else "callback_failed" + ) + if (status == BluetoothGatt.GATT_SUCCESS && + callbackType == "playbackState" && + callbackTraceContext?.hasCurrentLyric == true + ) { + recordContextTrace( + stage = "lyricCurrentLineSent", + payloadType = "playbackState", + context = callbackTraceContext, + processingMs = callbackRttMs, + result = "sent" + ) + } + if (callbackType == "albumArtOffer" && status == BluetoothGatt.GATT_SUCCESS) { + RealtimeTrace.record( + stage = "albumArtOfferSent", + payloadType = "albumArtOffer", + processingMs = callbackRttMs, + result = "success" + ) + } + 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 +485,78 @@ 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) { + commandResponseQuietWindows.remove(address) + 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 @@ -254,30 +564,38 @@ class BleNotifyQueue( activePacketIndex = 0 activeJobStartedAtMs = 0L notificationInFlight = false - commandResponseQuietUntilMs = 0L + commandResponseQuietWindows.clear() 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 activeJob = null activePacketIndex = 0 notificationInFlight = false - commandResponseQuietUntilMs = 0L + commandResponseQuietWindows.clear() activeRequestType = null activeRequestId += 1 + clearActiveNotifyMetrics() cancelNotifyTimeout() drainingCancelledCallback = false cancelledCallbackDrainRunnable = null @@ -294,9 +612,57 @@ class BleNotifyQueue( ) } - @Synchronized private fun enqueueJob(job: SendJob) { + runOnQueueThread { enqueueJobOnQueue(job) } + } + + @Synchronized + private fun enqueueJobOnQueue(job: SendJob) { + if (!job.isLongJob && shouldCoalesceShortType(job.type)) { + val coalesced = jobs.filter { + !it.isLongJob && + it.type == job.type && + it.device.address == job.device.address + } + jobs.removeAll(coalesced.toSet()) + coalesced.forEach { + RealtimeTrace.record( + stage = "notifyPreempted", + payloadType = it.type, + result = "coalesced", + reason = "latest_value_wins" + ) + } + } jobs.addLast(job) + RealtimeTrace.record( + stage = "notifyEnqueued", + monoMs = job.enqueuedAtMs, + payloadType = job.type, + chunkCount = job.packets.size, + result = "queued", + trackId = job.traceContext?.trackId, + generation = job.traceContext?.generation, + handoffId = job.traceContext?.handoffId, + triggerType = job.traceContext?.triggerType, + positionAnchorMs = job.traceContext?.positionAnchorMs, + lineIndex = job.traceContext?.lineIndex, + wordTimingStatus = job.traceContext?.wordTimingStatus + ) + recordPlaybackSpecificTrace( + stage = "playbackEnqueued", + packetType = job.type, + context = job.traceContext, + result = "queued" + ) + if (job.type == "playbackState" && job.traceContext?.hasCurrentLyric == true) { + recordContextTrace( + stage = "lyricCurrentLineEnqueued", + payloadType = "playbackState", + context = job.traceContext, + result = "queued" + ) + } sendNextPacket() } @@ -305,55 +671,42 @@ class BleNotifyQueue( if (notificationInFlight || drainingCancelledCallback) { return } - val quietDelayMs = remainingQuietDelayMs( - commandResponseQuietUntilMs, - SystemClock.elapsedRealtime() - ) - if (quietDelayMs > 0L) { - handler.postDelayed({ sendNextPacket() }, quietDelayMs) - return - } if (activeJob == null) { - activeJob = pollNextJob() ?: return - activePacketIndex = activeJob?.nextPacketIndex ?: 0 - activeJobStartedAtMs = activeJob?.startedAtMs - ?.takeIf { it > 0L } - ?: SystemClock.elapsedRealtime() - activeJob?.startedAtMs = activeJobStartedAtMs - activeJob?.takeIf { it.isLongJob }?.let { - if (LogConfig.DEBUG_VERBOSE_LOG) { - verboseLogger( - "[BleNotifyQueue] job start " + - "type=${it.type} chunks=${it.chunkCount}" - ) - } - } + activateJob(pollNextJob() ?: return) } var job = activeJob ?: return if (shouldYieldToPendingJob(job)) { + RealtimeTrace.record( + stage = "notifyPreempted", + payloadType = job.type, + chunkIndex = activePacketIndex, + chunkCount = job.packets.size, + result = "yielded", + reason = "higher_priority_pending" + ) job.nextPacketIndex = activePacketIndex job.packetsSinceYield = 0 jobs.addFirst(job) activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - activeJob = pollNextJob() ?: return + activateJob(pollNextJob() ?: return) job = activeJob ?: return - activePacketIndex = job.nextPacketIndex - activeJobStartedAtMs = job.startedAtMs - .takeIf { it > 0L } - ?: SystemClock.elapsedRealtime() - job.startedAtMs = activeJobStartedAtMs } if (job.shouldCancel?.invoke() == true) { - markJobFailed(job, "cancelled") - job.onFailure?.invoke() + RealtimeTrace.record( + stage = "notifyCancelled", + payloadType = job.type, + result = "cancelled", + reason = "stale_generation" + ) + failJob(job, "cancelled") activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } val maxSendDurationMs = job.maxSendDurationMs @@ -365,12 +718,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 +741,17 @@ class BleNotifyQueue( interleavedPacketInFlight = true interleavedPacketType = interleaved.packet.type notificationInFlight = true + markNotifyStarted( + interleaved.device.address, + interleaved.packet.type, + interleaved.traceContext + ) + recordPlaybackSpecificTrace( + stage = "playbackNotifyStart", + packetType = interleaved.packet.type, + context = interleaved.traceContext, + result = "requested" + ) logInterleavedEventThrottled( isSavedEvent = false, message = "[BleNotifyQueue] latest ${interleaved.packet.type} flushed during long job" @@ -400,7 +763,8 @@ class BleNotifyQueue( interleaved.packet.value ) if (!requested) { - onNotifyFailure( + dispatchNotifyFailure( + interleaved.device.address, interleaved.packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -408,6 +772,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null notificationInFlight = false + clearActiveNotifyMetrics() interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS handler.postDelayed( { sendNextPacket() }, @@ -418,7 +783,9 @@ class BleNotifyQueue( } return } else { - latestInterleavedPackets[interleaved.packet.type] = interleaved + latestInterleavedPackets[ + interleavedPacketKey(interleaved.device.address, interleaved.packet.type) + ] = interleaved } } } @@ -426,7 +793,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,17 +809,53 @@ 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 } val packet = job.packets[activePacketIndex] + val nowMs = SystemClock.elapsedRealtime() + val quietDelayMs = commandResponseQuietWindows.remainingDelayMs( + deviceAddress = job.device.address, + packetType = packet.type, + nowMs = nowMs + ) + if (quietDelayMs > 0L) { + // Keep this packet queued, but continue draining realtime state or + // another controller whose response window is unrelated. + job.nextPacketIndex = activePacketIndex + jobs.addFirst(job) + activeJob = null + activePacketIndex = 0 + activeJobStartedAtMs = 0L + val runnable = pollNextJob { candidate -> + val candidatePacket = candidate.packets.getOrNull(candidate.nextPacketIndex) + candidatePacket == null || + commandResponseQuietWindows.remainingDelayMs( + deviceAddress = candidate.device.address, + packetType = candidatePacket.type, + nowMs = nowMs + ) == 0L + } + if (runnable != null) { + activateJob(runnable) + handler.post { sendNextPacket() } + } else { + handler.postDelayed({ sendNextPacket() }, quietDelayMs) + } + return + } val server = serverProvider() val characteristic = characteristicProvider() if (server == null || characteristic == null) { @@ -461,12 +864,35 @@ class BleNotifyQueue( activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } notificationInFlight = true activeRequestType = packet.type + markNotifyStarted(job.device.address, packet.type, job.traceContext) + RealtimeTrace.record( + stage = "notifySendStart", + payloadType = packet.type, + queueWaitMs = (SystemClock.elapsedRealtime() - job.enqueuedAtMs).coerceAtLeast(0L), + chunkIndex = activePacketIndex, + chunkCount = job.packets.size, + result = "requested", + trackId = job.traceContext?.trackId, + generation = job.traceContext?.generation, + handoffId = job.traceContext?.handoffId, + triggerType = job.traceContext?.triggerType, + positionAnchorMs = job.traceContext?.positionAnchorMs, + lineIndex = job.traceContext?.lineIndex, + wordTimingStatus = job.traceContext?.wordTimingStatus + ) + recordPlaybackSpecificTrace( + stage = "playbackNotifyStart", + packetType = packet.type, + context = job.traceContext, + queueWaitMs = (SystemClock.elapsedRealtime() - job.enqueuedAtMs).coerceAtLeast(0L), + result = "requested" + ) val requested = notify(server, characteristic, job.device, packet.value) if (job.type == "albumArt") { logAlbumArtPacketProgress(job, packet) @@ -507,9 +933,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 +949,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) } @@ -539,11 +967,30 @@ class BleNotifyQueue( return@synchronized } val job = activeJob + RealtimeTrace.record( + stage = "notifyTimeout", + payloadType = type, + processingMs = if (activeNotifyStartedAtMs > 0L) { + SystemClock.elapsedRealtime() - activeNotifyStartedAtMs + } else { + NOTIFY_CALLBACK_TIMEOUT_MS + }, + chunkIndex = activePacketIndex, + chunkCount = job?.packets?.size, + result = "timeout", + reason = "callback_timeout" + ) 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 +999,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null activeRequestType = null + clearActiveNotifyMetrics() beginCancelledCallbackDrain("notify callback timeout") } } @@ -570,6 +1018,7 @@ class BleNotifyQueue( drainingCancelledCallback = true notificationInFlight = false activeRequestType = null + clearActiveNotifyMetrics() cancelledCallbackDrainRunnable?.let(handler::removeCallbacks) val drain = Runnable { synchronized(this) { @@ -598,11 +1047,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 +1121,93 @@ 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 pollNextJob(): SendJob? { - val priority = jobs.minOfOrNull { it.priority.rank } ?: return null - val selected = jobs.firstOrNull { it.priority.rank == priority } ?: return null + 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 activateJob(selected: SendJob) { + activeJob = selected + activePacketIndex = selected.nextPacketIndex + activeJobStartedAtMs = selected.startedAtMs + .takeIf { it > 0L } + ?: SystemClock.elapsedRealtime() + selected.startedAtMs = activeJobStartedAtMs + RealtimeTrace.record( + stage = "notifyDequeued", + payloadType = selected.type, + queueWaitMs = (SystemClock.elapsedRealtime() - selected.enqueuedAtMs) + .coerceAtLeast(0L), + chunkIndex = selected.nextPacketIndex, + chunkCount = selected.packets.size, + result = "selected", + trackId = selected.traceContext?.trackId, + generation = selected.traceContext?.generation, + handoffId = selected.traceContext?.handoffId, + triggerType = selected.traceContext?.triggerType, + positionAnchorMs = selected.traceContext?.positionAnchorMs, + lineIndex = selected.traceContext?.lineIndex, + wordTimingStatus = selected.traceContext?.wordTimingStatus + ) + recordPlaybackSpecificTrace( + stage = "playbackDequeued", + packetType = selected.type, + context = selected.traceContext, + queueWaitMs = (SystemClock.elapsedRealtime() - selected.enqueuedAtMs) + .coerceAtLeast(0L), + result = "selected" + ) + if (selected.isLongJob && LogConfig.DEBUG_VERBOSE_LOG) { + verboseLogger( + "[BleNotifyQueue] job start " + + "type=${selected.type} chunks=${selected.chunkCount}" + ) + } + } + + private fun pollNextJob( + eligible: (SendJob) -> Boolean = { true } + ): SendJob? { + val candidates = jobs.filter(eligible) + val priority = candidates.minOfOrNull { it.priority.rank } ?: return null + val priorityValue = Priority.entries.first { it.rank == priority } + val previousAddress = lastServedDeviceByPriority[priorityValue] + val selected = candidates.firstOrNull { + it.priority.rank == priority && it.device.address != previousAddress + } ?: candidates.firstOrNull { it.priority.rank == priority } ?: return null jobs.remove(selected) + lastServedDeviceByPriority[priorityValue] = selected.device.address return selected } @@ -685,9 +1216,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 +1236,137 @@ 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, + traceContext: NotifyTraceContext? + ) { + activeNotifyStartedAtMs = SystemClock.elapsedRealtime() + activeNotifyDeviceAddress = address + activeNotifyPacketType = type + activeNotifyTraceContext = traceContext + } + + private fun clearActiveNotifyMetrics() { + activeNotifyStartedAtMs = 0L + activeNotifyDeviceAddress = null + activeNotifyPacketType = null + activeNotifyTraceContext = null + } + + private fun recordPlaybackSpecificTrace( + stage: String, + packetType: String, + context: NotifyTraceContext?, + queueWaitMs: Long? = null, + processingMs: Long? = null, + result: String, + reason: String? = null + ) { + if (packetType != "playbackState") return + recordContextTrace( + stage = stage, + payloadType = packetType, + context = context, + queueWaitMs = queueWaitMs, + processingMs = processingMs, + result = result, + reason = reason + ) + } + + private fun recordContextTrace( + stage: String, + payloadType: String, + context: NotifyTraceContext?, + queueWaitMs: Long? = null, + processingMs: Long? = null, + result: String, + reason: String? = null + ) { + RealtimeTrace.record( + stage = stage, + trackId = context?.trackId, + generation = context?.generation, + payloadType = payloadType, + queueWaitMs = queueWaitMs, + processingMs = processingMs, + result = result, + reason = reason, + handoffId = context?.handoffId, + triggerType = context?.triggerType, + positionAnchorMs = context?.positionAnchorMs, + lineIndex = context?.lineIndex, + wordTimingStatus = context?.wordTimingStatus + ) + } + + private fun interleaveIntervalFor(jobType: String): Int { + return realtimeInterleaveIntervalFor(jobType) + } + private fun logInterleavedEventThrottled( isSavedEvent: Boolean, message: String @@ -805,6 +1419,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, @@ -821,7 +1458,8 @@ class BleNotifyQueue( private data class InterleavedPacket( val device: BluetoothDevice, - val packet: Packet + val packet: Packet, + val traceContext: NotifyTraceContext? = null ) private data class SendJob( @@ -838,7 +1476,11 @@ class BleNotifyQueue( var failureLogged: Boolean = false, var nextPacketIndex: Int = 0, var startedAtMs: Long = 0L, - var packetsSinceYield: Int = 0 + var packetsSinceYield: Int = 0, + val enqueuedAtMs: Long = SystemClock.elapsedRealtime(), + val terminalCallbackGate: BleQueueTerminalCallbackGate = + BleQueueTerminalCallbackGate(), + val traceContext: NotifyTraceContext? = null ) { val chunkCount: Int get() = packets.count { @@ -847,6 +1489,7 @@ class BleNotifyQueue( || it.type == "mediaFieldDumpChunk" || it.type == "trackInfoChunk" || it.type == "fullLyricsChunk" + || it.type == "fullLyricsBinaryChunk" || it.type == "historyPayloadChunk" } @@ -870,16 +1513,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 +1525,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 +1536,22 @@ class BleNotifyQueue( "lyricWindowChunk", "lyricWindowEnd" ) + private val COALESCIBLE_SHORT_TYPES = setOf( + "playbackState", + "currentWord", + "volumeState", + "albumArtOffer" + ) + private val COMMAND_RESPONSE_SENSITIVE_PACKET_TYPES = setOf( + "albumArtBinaryChunk", + "albumArtChunk", + "fullLyricsBinaryChunk", + "fullLyricsChunk", + "lyricSecondaryPart", + "logChunk", + "mediaFieldDumpChunk", + "historyPayloadChunk" + ) fun priorityFor(type: String): Priority { return when (type) { @@ -936,6 +1585,41 @@ class BleNotifyQueue( internal fun remainingQuietDelayMs(quietUntilMs: Long, nowMs: Long): Long { return (quietUntilMs - nowMs).coerceAtLeast(0L) } + + internal fun isCommandResponseSensitivePacket(type: String): Boolean { + return type in COMMAND_RESPONSE_SENSITIVE_PACKET_TYPES + } + + 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..5a1ca7d --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleV3SessionCoordinator.kt @@ -0,0 +1,131 @@ +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 MEDIA_CACHE_VALIDATION_V1 = 1 shl 3 + const val ALL = STATUS_META_V1 or STRUCTURED_ERROR_V1 or MEDIA_LOAD_STATE_V1 or + MEDIA_CACHE_VALIDATION_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 decorateIfEnabled(address: String, value: JSONObject, enabled: Boolean): JSONObject { + return if (enabled) decorate(address, value) else value + } + + 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/CurrentLyricFastPublishPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentLyricFastPublishPolicy.kt new file mode 100644 index 0000000..8ee1858 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentLyricFastPublishPolicy.kt @@ -0,0 +1,21 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.LyricsReadyGateSnapshot + +object CurrentLyricFastPublishPolicy { + fun shouldPublish( + ready: LyricsReadyGateSnapshot, + currentTrackId: String, + currentGeneration: Long, + currentLyric: String, + hasSubscribers: Boolean + ): Boolean { + if (!ready.lyricsReady || !hasSubscribers || currentLyric.isBlank()) return false + if (ready.trackId.isBlank() || currentTrackId.isBlank()) return false + if (ready.generation <= 0L || currentGeneration != ready.generation) return false + return ready.trackId.take(TRACK_ID_COMPARE_LENGTH) == + currentTrackId.take(TRACK_ID_COMPARE_LENGTH) + } + + private const val TRACK_ID_COMPARE_LENGTH = 12 +} 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..02171d3 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt @@ -1,7 +1,10 @@ package com.example.playeragent.ble import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator import com.example.playeragent.media.CurrentTrackRuntimeCache +import com.example.playeragent.media.CurrentWordEligibilitySnapshot import com.example.playeragent.media.CurrentWordState import com.example.playeragent.media.TrackCapabilityTracker import org.json.JSONObject @@ -17,7 +20,15 @@ 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 currentWordEligibility: (Long) -> CurrentWordEligibilitySnapshot = { + elapsedRealtimeMs -> + CurrentTrackRuntimeCache.currentWordEligibilitySnapshot( + elapsedRealtimeMs = elapsedRealtimeMs + ) + }, + private val elapsedRealtime: () -> Long = { SystemClock.elapsedRealtime() } ) { private val lock = Any() private var lastPushedKey: String = "" @@ -29,14 +40,66 @@ class CurrentWordPushEngine( private var lastPushCostMs: Long = 0L 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 + + fun observeGeneration(generation: Long) { + if (generation <= 0L) return + synchronized(lock) { + if (generation != lastObservedGeneration) { + prepareGenerationLocked(generation) + } + } + } + @Synchronized fun pushCurrentWord( reason: String = "diff", force: Boolean = false ): CurrentWordState? { - val startedAt = SystemClock.elapsedRealtime() - val state = CurrentTrackRuntimeCache.currentWordState() ?: run { + val startedAt = elapsedRealtime() + val eligibility = currentWordEligibility(startedAt) + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(eligibility.trackId) + RealtimeTrace.record( + stage = "currentWordEligibilityEvaluated", + monoMs = startedAt, + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + result = if (eligibility.eligible) "eligible" else "not_eligible", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus, + failureReason = eligibility.reason.takeUnless { eligibility.eligible } + ) + RealtimeTrace.record( + stage = if (eligibility.eligible) { + "currentWordTimingEligible" + } else { + "currentWordNotEligible" + }, + monoMs = startedAt, + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + result = if (eligibility.eligible) "eligible" else "not_eligible", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus, + failureReason = eligibility.reason.takeUnless { eligibility.eligible } + ) + if (!eligibility.eligible) { + recordSkip(eligibility.reason.lowercase()) + return null + } + val state = eligibility.state ?: run { recordSkip("missing") return null } @@ -49,7 +112,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,32 +123,35 @@ class CurrentWordPushEngine( } synchronized(lock) { - val now = SystemClock.elapsedRealtime() + val now = elapsedRealtime() if (state.trackGeneration != lastObservedGeneration) { - lastObservedGeneration = state.trackGeneration - generationFirstSeenAtMs = now - } - val generationAgeMs = now - generationFirstSeenAtMs - if (!force && generationAgeMs < TRACK_SWITCH_BASELINE_HOLDOFF_MS) { - recordSkipLocked( - "track_switch_baseline_pending", - now, - state, - key, - extra = " generationAgeMs=$generationAgeMs" - ) - logger( - "[CurrentWordFence] skip reason=track_switch_baseline_pending " + - "trackId=$outgoingTrackId generation=${state.trackGeneration} " + - "generationAgeMs=$generationAgeMs " + - "holdoffMs=$TRACK_SWITCH_BASELINE_HOLDOFF_MS" - ) - return null + prepareGenerationLocked(state.trackGeneration) } if (!force && key == lastPushedKey) { 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 +161,120 @@ class CurrentWordPushEngine( } } + val schedulerCreatedAt = elapsedRealtime() + RealtimeTrace.record( + stage = "currentWordEligible", + monoMs = schedulerCreatedAt, + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + result = "eligible", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus + ) + RealtimeTrace.record( + stage = "currentWordSchedulerCreated", + monoMs = schedulerCreatedAt, + trackId = eligibility.trackId.takeIf { it.isNotBlank() }, + generation = eligibility.generation.takeIf { it > 0L }, + payloadType = "currentWord", + result = "activated", + reason = eligibility.reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = eligibility.positionAnchorMs.takeIf { it > 0L }, + lineIndex = eligibility.lineIndex.takeIf { it >= 0 }, + wordTimingStatus = eligibility.wordTimingStatus + ) + val sequenceAndFirst = synchronized(lock) { + val first = nextSequence == 0L + nextSequence += 1L + nextSequence to first + } + val sequence = sequenceAndFirst.first + val isFirstForGeneration = sequenceAndFirst.second 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) + } + + RealtimeTrace.record( + stage = if (isFirstForGeneration) { + "currentWordImmediateSnapshotEnqueued" + } else { + "currentWordBoundaryEnqueued" + }, + trackId = outgoingTrackId, + generation = state.trackGeneration, + payloadType = "currentWord", + result = "enqueued", + reason = reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = state.sampleElapsedMs, + lineIndex = state.lineIndex, + wordTimingStatus = if (state.hasWordTiming) "AVAILABLE" else "LINE_ONLY" + ) + val sendStartedAt = elapsedRealtime() + RealtimeTrace.record( + stage = "currentWordSendStart", + monoMs = sendStartedAt, + trackId = outgoingTrackId, + generation = state.trackGeneration, + payloadType = "currentWord", + result = "started", + reason = reason, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = state.sampleElapsedMs, + lineIndex = state.lineIndex, + wordTimingStatus = if (state.hasWordTiming) "AVAILABLE" else "LINE_ONLY" + ) val sent = sendStatusMessage(payload.toString()) + val sendEndedAt = elapsedRealtime() + RealtimeTrace.record( + stage = "currentWordSendEnd", + monoMs = sendEndedAt, + trackId = outgoingTrackId, + generation = state.trackGeneration, + payloadType = "currentWord", + processingMs = (sendEndedAt - sendStartedAt).coerceAtLeast(0L), + result = if (sent) "success" else "failure", + reason = if (sent) reason else "send_failed", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = state.sampleElapsedMs, + lineIndex = state.lineIndex, + wordTimingStatus = if (state.hasWordTiming) "AVAILABLE" else "LINE_ONLY", + failureReason = if (sent) null else "UNKNOWN" + ) 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 +282,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 @@ -146,7 +310,25 @@ class CurrentWordPushEngine( lastPushedKey = "" lastPushElapsedMs = 0L lastObservedGeneration = -1L - generationFirstSeenAtMs = 0L + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 + } + } + + private fun prepareGenerationLocked(generation: Long) { + lastObservedGeneration = generation + lastPushedKey = "" + lastPushElapsedMs = 0L + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 + } + + fun resetTimeline() { + synchronized(lock) { + lastPushedKey = "" + lastPushedPositionMs = -1L } } @@ -176,7 +358,7 @@ class CurrentWordPushEngine( private fun recordSkip(reason: String) { synchronized(lock) { - recordSkipLocked(reason, SystemClock.elapsedRealtime()) + recordSkipLocked(reason, elapsedRealtime()) } } @@ -188,6 +370,19 @@ class CurrentWordPushEngine( extra: String = "" ) { skipCount += 1 + RealtimeTrace.record( + stage = if (reason == "same word" || reason == "rate limited") { + "currentWordCoalesced" + } else { + "currentWordDropped" + }, + monoMs = now, + trackId = state?.trackId, + generation = state?.trackGeneration, + payloadType = "currentWord", + result = "skipped", + reason = reason.replace(' ', '_') + ) if (now - lastSkipLogAtMs >= SKIP_LOG_INTERVAL_MS) { lastSkipLogAtMs = now val detail = if (state != null) { @@ -211,8 +406,9 @@ class CurrentWordPushEngine( private companion object { private const val MIN_CURRENT_WORD_INTERVAL_MS = 60L - 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/FullLyricsCacheValidation.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/FullLyricsCacheValidation.kt new file mode 100644 index 0000000..7e67858 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/FullLyricsCacheValidation.kt @@ -0,0 +1,116 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.LyricManager +import java.security.MessageDigest + +/** + * Exact, content-addressed description of lyrics that are safe to reuse on a controller. + * The digest intentionally covers secondary lyrics and word timing so a base-only cache + * can never suppress a transfer for richer content. + */ +internal data class FullLyricsCacheDescriptor( + val fingerprint: String, + val schemaVersion: Int, + val lineCount: Int, + val translationLineCount: Int, + val romanizationLineCount: Int +) + +internal data class FullLyricsCacheValidationRequest( + val fingerprint: String, + val schemaVersion: Int, + val lineCount: Int, + val translationLineCount: Int, + val romanizationLineCount: Int +) + +internal enum class FullLyricsCacheValidationDecision { + HIT, + CAPABILITY_DISABLED, + REQUEST_MISSING, + FINGERPRINT_MISMATCH, + SCHEMA_MISMATCH, + LINE_COUNT_MISMATCH, + SECONDARY_COUNT_MISMATCH +} + +internal object FullLyricsCacheValidation { + const val SCHEMA_VERSION = 1 + private const val FINGERPRINT_BYTES = 12 + + fun describe( + title: String, + artist: String, + lines: List + ): FullLyricsCacheDescriptor { + val digest = MessageDigest.getInstance("SHA-256") + updateField(digest, title.trim()) + updateField(digest, artist.trim()) + lines.forEachIndexed { index, line -> + updateField(digest, index.toString()) + updateField(digest, line.timeMs.toString()) + updateField(digest, line.durationMs.toString()) + updateField(digest, line.text) + updateField(digest, line.translation.orEmpty()) + updateField(digest, line.romanization.orEmpty()) + updateField(digest, line.words.size.toString()) + line.words.forEach { word -> + updateField(digest, word.startMs.toString()) + updateField(digest, word.durationMs.toString()) + updateField(digest, word.text) + } + } + return FullLyricsCacheDescriptor( + fingerprint = digest.digest() + .take(FINGERPRINT_BYTES) + .joinToString("") { "%02x".format(it.toInt() and 0xff) }, + schemaVersion = SCHEMA_VERSION, + lineCount = lines.size, + translationLineCount = lines.count { !it.translation.isNullOrBlank() }, + romanizationLineCount = lines.count { !it.romanization.isNullOrBlank() } + ) + } + + fun decide( + capabilityEnabled: Boolean, + request: FullLyricsCacheValidationRequest?, + actual: FullLyricsCacheDescriptor + ): FullLyricsCacheValidationDecision { + if (!capabilityEnabled) return FullLyricsCacheValidationDecision.CAPABILITY_DISABLED + request ?: return FullLyricsCacheValidationDecision.REQUEST_MISSING + if (request.schemaVersion != actual.schemaVersion) { + return FullLyricsCacheValidationDecision.SCHEMA_MISMATCH + } + if (request.lineCount != actual.lineCount) { + return FullLyricsCacheValidationDecision.LINE_COUNT_MISMATCH + } + if (request.translationLineCount != actual.translationLineCount || + request.romanizationLineCount != actual.romanizationLineCount + ) { + return FullLyricsCacheValidationDecision.SECONDARY_COUNT_MISMATCH + } + if (!request.fingerprint.equals(actual.fingerprint, ignoreCase = true)) { + return FullLyricsCacheValidationDecision.FINGERPRINT_MISMATCH + } + return FullLyricsCacheValidationDecision.HIT + } + + fun estimatedPayloadBytes(lines: List): Int { + return lines.sumOf { line -> + line.text.toByteArray(Charsets.UTF_8).size + + line.translation.orEmpty().toByteArray(Charsets.UTF_8).size + + line.romanization.orEmpty().toByteArray(Charsets.UTF_8).size + + line.words.sumOf { it.text.toByteArray(Charsets.UTF_8).size + 24 } + + 48 + } + } + + private fun updateField(digest: MessageDigest, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + digest.update((bytes.size ushr 24).toByte()) + digest.update((bytes.size ushr 16).toByte()) + digest.update((bytes.size ushr 8).toByte()) + digest.update(bytes.size.toByte()) + digest.update(bytes) + } +} 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..d19bc6a --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt @@ -0,0 +1,240 @@ +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 mediaCacheValidationV1: Boolean + get() = f3 and BleV3Features.MEDIA_CACHE_VALIDATION_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/MediaWireGenerationPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaWireGenerationPolicy.kt new file mode 100644 index 0000000..cdbcd5a --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaWireGenerationPolicy.kt @@ -0,0 +1,30 @@ +package com.example.playeragent.ble + +/** + * Selects the generation exposed on the BLE media protocol. + * + * The reactive controller owns a process-local task generation while the + * runtime cache owns the authoritative current-track generation used by + * CurrentWord, lyrics, and media load state. The counters can have different + * offsets after a service/GATT restart, so protocol messages must not mix them. + */ +internal object MediaWireGenerationPolicy { + fun resolve( + protocolTrackId: String, + runtimeTrackId: String?, + runtimeGeneration: Long?, + fallbackGeneration: Long + ): Long { + val protocolId = protocolTrackId.trim() + val runtimeId = runtimeTrackId.orEmpty().trim() + val matchingIdentity = protocolId.isNotEmpty() && runtimeId.isNotEmpty() && + (runtimeId == protocolId || + runtimeId.startsWith(protocolId) || + protocolId.startsWith(runtimeId)) + return if (matchingIdentity && runtimeGeneration != null && runtimeGeneration > 0L) { + runtimeGeneration + } else { + fallbackGeneration + } + } +} 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/diagnostics/RealtimeTrace.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/diagnostics/RealtimeTrace.kt new file mode 100644 index 0000000..5c6bb89 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/diagnostics/RealtimeTrace.kt @@ -0,0 +1,229 @@ +package com.example.playeragent.diagnostics + +import android.os.SystemClock +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +data class RealtimeTraceEvent( + val sequence: Long, + val side: String = "sony", + val stage: String, + val monoMs: Long, + val commandSeq: Long? = null, + val commandType: String? = null, + val trackId: String? = null, + val generation: Long? = null, + val transferId: String? = null, + val payloadType: String? = null, + val queueWaitMs: Long? = null, + val processingMs: Long? = null, + val chunkIndex: Int? = null, + val chunkCount: Int? = null, + val result: String? = null, + val reason: String? = null, + val handoffId: String? = null, + val triggerType: String? = null, + val positionAnchorMs: Long? = null, + val lineIndex: Int? = null, + val wordTimingStatus: String? = null, + val cacheSource: String? = null, + val failureReason: String? = null +) { + fun logLine(): String { + val fields = listOf( + "side" to side, + "stage" to stage, + "monoMs" to monoMs.toString(), + "handoffId" to handoffId, + "triggerType" to triggerType, + "commandSeq" to commandSeq?.toString(), + "commandType" to commandType, + "trackId" to trackId, + "generation" to generation?.toString(), + "transferId" to transferId, + "payloadType" to payloadType, + "queueWaitMs" to queueWaitMs?.toString(), + "processingMs" to processingMs?.toString(), + "chunkIndex" to chunkIndex?.toString(), + "chunkCount" to chunkCount?.toString(), + "result" to result, + "reason" to reason, + "positionAnchorMs" to positionAnchorMs?.toString(), + "lineIndex" to lineIndex?.toString(), + "wordTimingStatus" to wordTimingStatus, + "cacheSource" to cacheSource, + "failureReason" to failureReason + ) + return "[RealtimeTrace] " + fields + .filter { (_, value) -> value != null } + .joinToString(" ") { (key, value) -> "$key=${safe(value.orEmpty())}" } + } + + private fun safe(value: String): String = value + .take(96) + .map { character -> + if (character.isLetterOrDigit() || character in "-_.:/") character else '_' + } + .joinToString("") +} + +class RealtimeTraceBuffer( + val capacity: Int = 2_048, + private val clock: () -> Long = { SystemClock.elapsedRealtime() } +) { + init { + require(capacity > 0) { "capacity must be positive" } + } + + private val storage = arrayOfNulls(capacity) + private var nextIndex = 0 + private var storedCount = 0 + private var nextSequence = 0L + private var lastMonoMs = 0L + + @Synchronized + fun append( + stage: String, + monoMs: Long? = null, + commandSeq: Long? = null, + commandType: String? = null, + trackId: String? = null, + generation: Long? = null, + transferId: String? = null, + payloadType: String? = null, + queueWaitMs: Long? = null, + processingMs: Long? = null, + chunkIndex: Int? = null, + chunkCount: Int? = null, + result: String? = null, + reason: String? = null, + handoffId: String? = null, + triggerType: String? = null, + positionAnchorMs: Long? = null, + lineIndex: Int? = null, + wordTimingStatus: String? = null, + cacheSource: String? = null, + failureReason: String? = null + ): RealtimeTraceEvent { + val sampled = monoMs ?: clock() + val stableMonoMs = maxOf(sampled, lastMonoMs) + lastMonoMs = stableMonoMs + nextSequence += 1L + val event = RealtimeTraceEvent( + sequence = nextSequence, + stage = stage, + monoMs = stableMonoMs, + commandSeq = commandSeq, + commandType = commandType, + trackId = trackId, + generation = generation, + transferId = transferId, + payloadType = payloadType, + queueWaitMs = queueWaitMs, + processingMs = processingMs, + chunkIndex = chunkIndex, + chunkCount = chunkCount, + result = result, + reason = reason, + handoffId = handoffId, + triggerType = triggerType, + positionAnchorMs = positionAnchorMs, + lineIndex = lineIndex, + wordTimingStatus = wordTimingStatus, + cacheSource = cacheSource, + failureReason = failureReason + ) + storage[nextIndex] = event + nextIndex = (nextIndex + 1) % capacity + storedCount = minOf(storedCount + 1, capacity) + return event + } + + @Synchronized + fun snapshot(): List { + if (storedCount == 0) return emptyList() + val start = if (storedCount == capacity) nextIndex else 0 + return (0 until storedCount).mapNotNull { offset -> + storage[(start + offset) % capacity] + } + } + + @Synchronized + fun clear() { + storage.fill(null) + nextIndex = 0 + storedCount = 0 + lastMonoMs = 0L + } +} + +object RealtimeTrace { + @Volatile + var enabled: Boolean = false + + private val buffer = RealtimeTraceBuffer() + private val sink = AtomicReference<(String) -> Unit>({}) + private val logExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "realtime-trace-log").apply { isDaemon = true } + } + + fun configure(logger: (String) -> Unit, enabled: Boolean = this.enabled) { + sink.set(logger) + this.enabled = enabled + } + + fun record( + stage: String, + monoMs: Long? = null, + commandSeq: Long? = null, + commandType: String? = null, + trackId: String? = null, + generation: Long? = null, + transferId: String? = null, + payloadType: String? = null, + queueWaitMs: Long? = null, + processingMs: Long? = null, + chunkIndex: Int? = null, + chunkCount: Int? = null, + result: String? = null, + reason: String? = null, + handoffId: String? = null, + triggerType: String? = null, + positionAnchorMs: Long? = null, + lineIndex: Int? = null, + wordTimingStatus: String? = null, + cacheSource: String? = null, + failureReason: String? = null + ): RealtimeTraceEvent? { + if (!enabled) return null + val event = buffer.append( + stage = stage, + monoMs = monoMs, + commandSeq = commandSeq, + commandType = commandType, + trackId = trackId, + generation = generation, + transferId = transferId, + payloadType = payloadType, + queueWaitMs = queueWaitMs, + processingMs = processingMs, + chunkIndex = chunkIndex, + chunkCount = chunkCount, + result = result, + reason = reason, + handoffId = handoffId, + triggerType = triggerType, + positionAnchorMs = positionAnchorMs, + lineIndex = lineIndex, + wordTimingStatus = wordTimingStatus, + cacheSource = cacheSource, + failureReason = failureReason + ) + logExecutor.execute { sink.get().invoke(event.logLine()) } + return event + } + + fun snapshot(): List = buffer.snapshot() + + fun clear() = buffer.clear() +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/diagnostics/TrackHandoffTraceCoordinator.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/diagnostics/TrackHandoffTraceCoordinator.kt new file mode 100644 index 0000000..209d262 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/diagnostics/TrackHandoffTraceCoordinator.kt @@ -0,0 +1,165 @@ +package com.example.playeragent.diagnostics + +import android.os.SystemClock + +enum class TrackHandoffTriggerType { + IOS_NEXT, + IOS_PREVIOUS, + SONY_MEDIA_SESSION_NEXT, + NATURAL_AUTOPLAY, + SONY_LOCAL_CONTROL, + UNKNOWN +} + +data class TrackHandoffTraceContext( + val handoffId: String, + val triggerType: TrackHandoffTriggerType, + val commandSeq: Long? = null, + val commandType: String? = null, + val createdMonoMs: Long, + val trackId: String? = null +) + +/** + * Privacy-safe, bounded correlation state for Phase 4 diagnostics. + * + * This coordinator never drives playback or media publication. It only binds a + * pending NEXT/PREVIOUS command to the next distinct MediaSession identity so + * trace events can be joined without changing a BLE header or payload. + */ +object TrackHandoffTraceCoordinator { + private const val PENDING_TTL_MS = 15_000L + private const val NOTIFICATION_FOLLOWUP_WINDOW_MS = 1_200L + private val lock = Any() + private var nextLocalId = 0L + private var pending: TrackHandoffTraceContext? = null + private var active: TrackHandoffTraceContext? = null + private var lastObservedTrackId = "" + private var lastNotificationIdentityKey = "" + + fun observeCommand( + commandSeq: Long?, + commandType: String, + nowMs: Long = SystemClock.elapsedRealtime() + ): TrackHandoffTraceContext? { + val trigger = when (commandType) { + "NEXT" -> TrackHandoffTriggerType.IOS_NEXT + "PREVIOUS" -> TrackHandoffTriggerType.IOS_PREVIOUS + else -> return null + } + return synchronized(lock) { + TrackHandoffTraceContext( + handoffId = commandSeq?.let { "command-$it" } ?: localIdLocked(nowMs), + triggerType = trigger, + commandSeq = commandSeq, + commandType = commandType, + createdMonoMs = nowMs + ).also { pending = it } + } + } + + fun observeNotificationMetadata( + identityKey: String, + nowMs: Long = SystemClock.elapsedRealtime() + ) { + val context = synchronized(lock) { + val existing = validPendingLocked(nowMs) + val activeFollowup = active?.takeIf { + nowMs - it.createdMonoMs <= NOTIFICATION_FOLLOWUP_WINDOW_MS + } + if (identityKey.isBlank() || identityKey == lastNotificationIdentityKey) { + existing ?: activeFollowup + } else { + lastNotificationIdentityKey = identityKey + existing + ?: activeFollowup + ?: TrackHandoffTraceContext( + handoffId = localIdLocked(nowMs), + triggerType = TrackHandoffTriggerType.UNKNOWN, + createdMonoMs = nowMs + ).also { pending = it } + } + } + RealtimeTrace.record( + stage = "notificationMetadataObserved", + monoMs = nowMs, + commandSeq = context?.commandSeq, + commandType = context?.commandType, + handoffId = context?.handoffId, + triggerType = context?.triggerType?.name, + payloadType = "notificationMetadata", + result = "observed" + ) + } + + fun observeMediaSessionMetadata( + trackId: String, + positionAnchorMs: Long, + nowMs: Long = SystemClock.elapsedRealtime() + ): TrackHandoffTraceContext? { + if (trackId.isBlank()) return null + val update = synchronized(lock) { + if (trackId == lastObservedTrackId) return@synchronized null + lastObservedTrackId = trackId + val base = validPendingLocked(nowMs) ?: TrackHandoffTraceContext( + handoffId = localIdLocked(nowMs), + triggerType = TrackHandoffTriggerType.UNKNOWN, + createdMonoMs = nowMs + ) + pending = null + base.copy(trackId = trackId).also { active = it } + } ?: return null + RealtimeTrace.record( + stage = "mediaSessionMetadataObserved", + monoMs = nowMs, + commandSeq = update.commandSeq, + commandType = update.commandType, + trackId = trackId, + handoffId = update.handoffId, + triggerType = update.triggerType.name, + positionAnchorMs = positionAnchorMs, + payloadType = "mediaSessionMetadata", + result = "observed" + ) + RealtimeTrace.record( + stage = "trackIdentityCandidate", + monoMs = nowMs, + commandSeq = update.commandSeq, + commandType = update.commandType, + trackId = trackId, + handoffId = update.handoffId, + triggerType = update.triggerType.name, + payloadType = "mediaSessionMetadata", + result = "candidate" + ) + return update + } + + fun contextFor(trackId: String): TrackHandoffTraceContext? = synchronized(lock) { + active?.takeIf { it.trackId == trackId } + } + + fun pendingContext(nowMs: Long = SystemClock.elapsedRealtime()): TrackHandoffTraceContext? = + synchronized(lock) { validPendingLocked(nowMs) } + + fun clear() { + synchronized(lock) { + pending = null + active = null + lastObservedTrackId = "" + lastNotificationIdentityKey = "" + } + } + + private fun validPendingLocked(nowMs: Long): TrackHandoffTraceContext? { + val candidate = pending ?: return null + if (nowMs - candidate.createdMonoMs <= PENDING_TTL_MS) return candidate + pending = null + return null + } + + private fun localIdLocked(nowMs: Long): String { + nextLocalId += 1L + return "sony-$nowMs-$nextLocalId" + } +} 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/AlbumArtIdentityPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtIdentityPolicy.kt new file mode 100644 index 0000000..48fe05a --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtIdentityPolicy.kt @@ -0,0 +1,31 @@ +package com.example.playeragent.media + +object AlbumArtIdentityPolicy { + fun matches( + expectedTitle: String, + expectedArtist: String, + actualTitle: String, + actualArtist: String + ): Boolean { + val expectedTitleValue = expectedTitle.trim() + if (expectedTitleValue.isEmpty()) { + return true + } + + val actualTitleValue = actualTitle.trim() + if (!actualTitleValue.equals(expectedTitleValue, ignoreCase = true)) { + return false + } + + val expectedArtistValue = expectedArtist.trim() + if (expectedArtistValue.isEmpty()) { + return true + } + val actualArtistValue = actualArtist.trim() + if (actualArtistValue.isEmpty()) { + return false + } + return actualArtistValue.contains(expectedArtistValue, ignoreCase = true) || + expectedArtistValue.contains(actualArtistValue, ignoreCase = true) + } +} 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..8ea2586 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, expectedArtist) } + 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(expectedTitle, expectedArtist) + 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? { @@ -147,7 +173,10 @@ class AlbumArtTestManager( ) } - private fun readMediaMetadataCandidates(): List { + private fun readMediaMetadataCandidates( + expectedTitle: String, + expectedArtist: String + ): List { val manager = mediaSessionManager ?: run { logger("[AlbumArtSource] metadata unavailable reason=MediaSessionManager") return emptyList() @@ -167,6 +196,27 @@ class AlbumArtTestManager( } val controller = selectQqMusicController(controllers) val metadata = controller?.metadata + val metadataTitle = metadataString( + metadata, + MediaMetadata.METADATA_KEY_TITLE, + MediaMetadata.METADATA_KEY_DISPLAY_TITLE + ) + val metadataArtist = metadataString( + metadata, + MediaMetadata.METADATA_KEY_ARTIST, + MediaMetadata.METADATA_KEY_ALBUM_ARTIST, + MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE + ) + if (!AlbumArtIdentityPolicy.matches( + expectedTitle = expectedTitle, + expectedArtist = expectedArtist, + actualTitle = metadataTitle, + actualArtist = metadataArtist + ) + ) { + logger("[AlbumArtSource] metadata rejected reason=identity_mismatch") + return emptyList() + } return listOfNotNull( albumArtCandidate( source = "metadataAlbumArt", @@ -186,21 +236,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 +279,19 @@ class AlbumArtTestManager( ) } + private fun notificationFingerprint( + notification: StatusBarNotification, + expectedTitle: String, + expectedArtist: 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() + val expectedScope = + "${expectedTitle.trim().lowercase()}|${expectedArtist.trim().lowercase()}" + return "${notification.key}|${notification.postTime}|$title|$artist|$expectedScope" + } + private fun albumArtCandidate( source: String, bitmap: Bitmap?, @@ -291,13 +359,25 @@ class AlbumArtTestManager( val extras = notification.notification.extras val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() val artist = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty() - title.isBlank() || - (title.equals(expectedTitle, ignoreCase = true) && - (expectedArtist.isBlank() || artist.isBlank() || - artist.contains(expectedArtist, ignoreCase = true))) + AlbumArtIdentityPolicy.matches( + expectedTitle = expectedTitle, + expectedArtist = expectedArtist, + actualTitle = title, + actualArtist = artist + ) } } + private fun metadataString(metadata: MediaMetadata?, vararg keys: String): String { + keys.forEach { key -> + val value = metadata?.getString(key) + if (!value.isNullOrBlank()) { + return value + } + } + return "" + } + private fun selectQqMusicController( controllers: List ): android.media.session.MediaController? { @@ -381,57 +461,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 +473,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..a2f4470 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt @@ -1,6 +1,7 @@ package com.example.playeragent.media import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace import com.example.playeragent.logging.LogConfig import org.json.JSONObject @@ -46,12 +47,75 @@ 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" } +data class CurrentWordEligibilitySnapshot( + val eligible: Boolean, + val reason: String, + val trackId: String = "", + val generation: Long = 0L, + val positionMs: Long = 0L, + val positionAnchorMs: Long = 0L, + val lineIndex: Int = -1, + val wordTimingStatus: String = "NOT_READY", + val nextBoundaryDelayMs: Long? = null, + val state: CurrentWordState? = null +) + +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) + } + } +} + +internal object RuntimeLyricLineProjectionPolicy { + fun currentLineText( + lines: List, + positionMs: Long + ): String { + var low = 0 + var high = lines.lastIndex + var lineIndex = -1 + while (low <= high) { + val mid = (low + high) ushr 1 + if (lines[mid].timeMs <= positionMs) { + lineIndex = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + while (lineIndex >= 0 && lines[lineIndex].text.isBlank()) { + lineIndex -= 1 + } + return lines.getOrNull(lineIndex)?.text.orEmpty() + } +} + object CurrentTrackRuntimeCache { private val lock = Any() @@ -72,8 +136,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 +176,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, @@ -133,10 +201,18 @@ 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 { + val projectedPosition = PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = previous.positionMs, + positionAnchorElapsedMs = previous.positionAnchorElapsedMs, + nowElapsedMs = startedAt, + durationMs = previous.durationMs, + isPlaying = previous.isPlaying, + playbackSpeed = previous.playbackSpeed + ) logger?.invoke( "[RuntimeCache] lyrics updated songKey=$songKey " + "lines=${runtimeLines.size} source=$lyricSource" @@ -147,7 +223,10 @@ object CurrentTrackRuntimeCache { lyricLines = runtimeLines, translationLines = runtimeLines.map { it.translation }, romanizationLines = runtimeLines.map { it.romanization }, - currentWord = findCurrentWord(runtimeLines, previous.positionMs), + positionMs = projectedPosition, + positionAnchorElapsedMs = startedAt, + currentLine = findCurrentLineText(runtimeLines, projectedPosition), + currentWord = findCurrentWord(runtimeLines, projectedPosition), lastUpdatedAtMs = now ) } @@ -162,7 +241,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 +260,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 +275,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 @@ -253,8 +334,20 @@ object CurrentTrackRuntimeCache { synchronized(lock) { if (current == null) { cacheMiss += 1 + RealtimeTrace.record( + stage = "runtimeCacheMiss", + payloadType = "currentTrack", + result = "miss" + ) } else { cacheHit += 1 + RealtimeTrace.record( + stage = "runtimeCacheHit", + trackId = current?.trackId, + generation = current?.currentTrackGeneration, + payloadType = "currentTrack", + result = "hit" + ) } return CurrentTrackRuntimeCacheSnapshot( track = current, @@ -290,7 +383,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 +411,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 +421,131 @@ 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, elapsedRealtimeMs) + } + } + + fun currentWordEligibilitySnapshot( + timestampMs: Long = System.currentTimeMillis(), + elapsedRealtimeMs: Long = SystemClock.elapsedRealtime() + ): CurrentWordEligibilitySnapshot { + synchronized(lock) { + val track = current ?: return CurrentWordEligibilitySnapshot( + eligible = false, + reason = "LYRIC_NOT_READY" + ) + val common = CurrentWordEligibilitySnapshot( + eligible = false, + reason = "UNKNOWN", + trackId = track.trackId, + generation = track.currentTrackGeneration, + positionMs = track.positionMs, + positionAnchorMs = track.positionAnchorElapsedMs + ) + if (!track.isPlaying) return common.copy(reason = "PAUSED") + if (track.positionAnchorElapsedMs <= 0L || + elapsedRealtimeMs < track.positionAnchorElapsedMs + ) { + return common.copy(reason = "CLOCK_UNTRUSTED") + } + if (track.lyricLines.isEmpty()) return common.copy(reason = "LYRIC_NOT_READY") + val position = PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = track.positionMs, + positionAnchorElapsedMs = track.positionAnchorElapsedMs, + nowElapsedMs = elapsedRealtimeMs, + durationMs = track.durationMs, + isPlaying = track.isPlaying, + playbackSpeed = track.playbackSpeed + ) + val lineIndex = findLatestLineIndex(track.lyricLines, position) + if (lineIndex < 0) { + val firstBoundary = track.lyricLines.firstOrNull()?.let { line -> + line.words.firstOrNull()?.startMs ?: line.timeMs + } + return common.copy( + reason = "INTRO_WAIT", + positionMs = position, + nextBoundaryDelayMs = firstBoundary + ?.minus(position) + ?.coerceAtLeast(0L) + ) + } + val line = track.lyricLines[lineIndex] + if (line.words.isEmpty()) { + return common.copy( + reason = "NO_WORD_TIMING", + positionMs = position, + lineIndex = lineIndex, + wordTimingStatus = "LINE_ONLY" + ) + } + val wordIndex = findLatestWordIndex(line.words, position) + if (wordIndex < 0) { + return common.copy( + reason = "INTRO_WAIT", + positionMs = position, + lineIndex = lineIndex, + wordTimingStatus = "AVAILABLE", + nextBoundaryDelayMs = (line.words.first().startMs - position) + .coerceAtLeast(0L) + ) + } + val word = line.words[wordIndex] + val wordEndMs = word.startMs + word.durationMs.coerceAtLeast(0L) + if (word.durationMs > 0L && position >= wordEndMs) { + val nextBoundary = line.words.getOrNull(wordIndex + 1)?.startMs + ?: track.lyricLines.getOrNull(lineIndex + 1)?.let { nextLine -> + nextLine.words.firstOrNull()?.startMs ?: nextLine.timeMs + } + return common.copy( + reason = if (nextBoundary != null) "INTRO_WAIT" else "NO_ACTIVE_LINE", + positionMs = position, + lineIndex = lineIndex, + wordTimingStatus = "AVAILABLE", + nextBoundaryDelayMs = nextBoundary + ?.minus(position) + ?.coerceAtLeast(0L) + ) + } + val state = findCurrentWordStateLocked(track, timestampMs, elapsedRealtimeMs) + return common.copy( + eligible = state != null && state.hasWordTiming, + reason = if (state != null && state.hasWordTiming) "ELIGIBLE" else "NO_WORD_TIMING", + positionMs = position, + lineIndex = lineIndex, + wordTimingStatus = if (state?.hasWordTiming == true) "AVAILABLE" else "LINE_ONLY", + state = state + ) + } + } + + fun traceIdentitySnapshot(): Pair? { synchronized(lock) { val track = current ?: return null - return findCurrentWordStateLocked(track, timestampMs) + return track.trackId to track.currentTrackGeneration } } /** 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) { @@ -439,28 +647,25 @@ object CurrentTrackRuntimeCache { lines: List, positionMs: Long ): String { - val lineIndex = findCurrentLineIndexed(lines, positionMs)?.lineIndex ?: return "" - return lines.getOrNull(lineIndex)?.text.orEmpty() + return RuntimeLyricLineProjectionPolicy.currentLineText(lines, positionMs) } 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 +679,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..a05d6f6 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt @@ -2,9 +2,14 @@ package com.example.playeragent.media import android.content.Context import android.os.Environment +import android.os.PowerManager +import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator import com.example.playeragent.logging.LogConfig import java.io.File import java.nio.charset.Charset +import java.security.MessageDigest import java.util.LinkedHashMap import java.util.Locale import java.util.concurrent.Executors @@ -13,7 +18,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 +59,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,39 +90,71 @@ class LyricManager( context = appContext, logger = logger ) - private val predictiveLyricsPipeline = PredictiveLyricsPipeline( + private val powerManager = appContext.getSystemService(PowerManager::class.java) + @Volatile + private var fuzzyIndexReady: Boolean = qrcLyricManager.fuzzyIndexStatus().ready + private val predictiveMediaCoordinator = PredictiveMediaCoordinator( logger = logger, - loader = { track -> - val result = qrcLyricManager.loadWithResult(track.title, track.artist, track.album) - val lines = if (result.success) { - result.lines.map { - LyricLine( - timeMs = it.timeMs, - text = it.text, - durationMs = it.durationMs, - words = it.words, - translation = it.translation, - romanization = it.romanization - ) - } - } else { - emptyList() - } - PredictiveLyricsLoadResult( - lines = lines, + executor = executionHub?.maintenance, + allowPrewarm = { powerManager?.isPowerSaveMode != true }, + prewarmer = { track, shouldCancel -> + val result = qrcLyricManager.loadParsedCacheWithResult( + title = track.title, + artist = track.artist, + album = track.album, + traceId = "prediction-${track.identityDigest}", + shouldCancel = shouldCancel + ) + PredictionPrewarmResult( + lyricsReady = result.success && result.lines.isNotEmpty(), + lyricFingerprint = if (result.success) { + predictionLyricFingerprint(result.lines) + } else { + "" + }, + lineCount = result.lines.size, + hasWordTiming = result.lines.any { it.words.isNotEmpty() }, source = if (result.success) LyricSource.QRC.name else LyricSource.NONE.name, reason = result.reason ) + }, + promoter = { track -> + val result = qrcLyricManager.loadCacheOnlyWithResult( + title = track.title, + artist = track.artist, + album = track.album, + traceId = "promotion-${track.identityDigest}" + ) + if (!result.success || result.lines.isEmpty()) { + null + } else { + PredictionPromotionPayload( + lines = result.lines.map { + LyricLine( + timeMs = it.timeMs, + text = it.text, + durationMs = it.durationMs, + words = it.words, + translation = it.translation, + romanization = it.romanization + ) + }, + source = LyricSource.QRC.name, + lyricFingerprint = predictionLyricFingerprint(result.lines) + ) + } } ) private val recoveryEngine = LyricRecoveryEngine( 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 +192,11 @@ class LyricManager( MaintenanceGuard.removeWindowEndListener(maintenanceWindowEndListener) recoveryEngine.shutdown() requestCancellationGate.cancelAll() - lyricExecutor.shutdownNow() - foregroundLyricExecutor.shutdownNow() + predictiveMediaCoordinator.close() + if (ownsLyricExecutors) { + lyricExecutor.shutdownNow() + foregroundLyricExecutor.shutdownNow() + } qrcLyricManager.close() synchronized(this) { pendingRequest = null @@ -234,6 +276,13 @@ class LyricManager( reason: String = "playback state" ) { val key = lyricKey(title, artist, album) + realtimeLyricTrace( + stage = "lyricLookupRequested", + trackId = trackId, + result = "requested", + positionAnchorMs = positionMs, + cacheSource = "UNRESOLVED" + ) if (trackId.isNotBlank()) { TrackCapabilityTracker.onLyricLookupStart(key, trackId) } @@ -250,6 +299,13 @@ class LyricManager( if (positionMs > 0L) { activePositionMs = positionMs } + realtimeLyricTrace( + stage = "lyricRuntimeCacheHit", + trackId = trackId.ifBlank { activeTrackId }, + result = "hit", + positionAnchorMs = positionMs, + cacheSource = "RUNTIME" + ) return } if (!force && @@ -369,6 +425,20 @@ class LyricManager( ) lastAttemptAtMs = System.currentTimeMillis() lyricsReadyState = LyricsReadyState.PARSING + realtimeLyricTrace( + stage = "lyricLookupStart", + trackId = pendingRequest?.trackId.orEmpty(), + result = "started", + positionAnchorMs = pendingRequest?.positionMs, + cacheSource = "UNRESOLVED" + ) + realtimeLyricTrace( + stage = "lyricLoadStart", + trackId = pendingRequest?.trackId.orEmpty(), + result = "started", + positionAnchorMs = pendingRequest?.positionMs, + cacheSource = "UNRESOLVED" + ) logger("[LyricAsync] scheduled songKey=$key reason=$reason force=$force") logger("[LyricsFastPath] lookup start songKey=$key reason=$reason") TrackCapabilityTracker.onLyricLookupStart(key, trackId) @@ -386,7 +456,7 @@ class LyricManager( } @Synchronized - fun preloadPredictiveLyrics(candidate: PredictiveLyricsCandidate) { + fun preloadPredictiveMedia(candidate: PredictionCandidate) { val track = candidate.track if (track.title.isBlank()) { return @@ -398,7 +468,7 @@ class LyricManager( ) return } - predictiveLyricsPipeline.onCandidate(candidate) + predictiveMediaCoordinator.onCandidate(candidate) } @Synchronized @@ -603,6 +673,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 +708,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 +783,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", @@ -919,12 +1011,6 @@ class LyricManager( "[LyricAsync] success marked loaded songKey=${request.key} " + "lines=${result.lineCount}" ) - predictiveLyricsPipeline.putLoadedTrack( - track = activePredictiveTrackLocked(request), - lines = cachedLines, - source = result.source.name, - buildTimeMs = 0L - ) lyricsReadyState = LyricsReadyState.READY val runtimeApplyStartedAt = System.currentTimeMillis() trace( @@ -940,6 +1026,18 @@ class LyricManager( lyricSource = result.source.name, logger = logger ) + realtimeLyricTrace( + stage = "lyricReady", + trackId = request.trackId.ifBlank { activeTrackId }, + result = "ready", + positionAnchorMs = request.positionMs, + wordTimingStatus = if (result.lines.any { it.words.isNotEmpty() }) { + "AVAILABLE" + } else { + "LINE_ONLY" + }, + cacheSource = result.source.name + ) trace( request.traceId, "runtimeApplyEnd", @@ -1017,6 +1115,14 @@ class LyricManager( "reason=$retryableFailureReason state=${lyricsReadyState.name}" ) onLyricsReady(lyricsReadyGateSnapshotLocked()) + realtimeLyricTrace( + stage = "lyricReady", + trackId = request.trackId.ifBlank { activeTrackId }, + result = "not_ready", + positionAnchorMs = request.positionMs, + cacheSource = result.source.name, + failureReason = classifyLyricFailure(retryableFailureReason) + ) } else { lyricsReadyState = LyricsReadyState.FAILED finalEmptySongKey = request.key @@ -1046,6 +1152,14 @@ class LyricManager( "reason=$finalEmptyReason state=${lyricsReadyState.name}" ) onLyricsReady(lyricsReadyGateSnapshotLocked()) + realtimeLyricTrace( + stage = "lyricReady", + trackId = request.trackId.ifBlank { activeTrackId }, + result = "not_ready", + positionAnchorMs = request.positionMs, + cacheSource = result.source.name, + failureReason = classifyLyricFailure(finalEmptyReason) + ) } lastLoggedLine = null } @@ -1084,6 +1198,18 @@ class LyricManager( ) } val costMs = System.currentTimeMillis() - parsedCacheStartedAt + realtimeLyricTrace( + stage = "lyricParsedCacheHit", + trackId = request.trackId, + result = "hit", + positionAnchorMs = request.positionMs, + wordTimingStatus = if (qrcLines.any { it.words.isNotEmpty() }) { + "AVAILABLE" + } else { + "LINE_ONLY" + }, + cacheSource = "QRC_PARSED_DISK" + ) trace( request.traceId, "parseOptimizationCacheHit", @@ -1135,6 +1261,14 @@ class LyricManager( "parseOptimizationCacheMiss", "source=qrc_parsed_fast costMs=${System.currentTimeMillis() - parsedCacheStartedAt}" ) + realtimeLyricTrace( + stage = "lyricParsedCacheMiss", + trackId = request.trackId, + result = "miss", + positionAnchorMs = request.positionMs, + cacheSource = "QRC_PARSED_DISK", + failureReason = "EXACT_CACHE_MISS" + ) val lrcStartedAt = System.currentTimeMillis() if (applyScannedCacheIfAvailable(title, artist)) { trace( @@ -1314,6 +1448,14 @@ class LyricManager( } parsedCacheGet(request)?.let { cached -> val costMs = System.currentTimeMillis() - totalStartedAt + realtimeLyricTrace( + stage = "lyricParsedCacheHit", + trackId = request.trackId, + result = "hit", + positionAnchorMs = request.positionMs, + wordTimingStatus = if (cached.hasWordTiming) "AVAILABLE" else "LINE_ONLY", + cacheSource = "PARSED" + ) trace( request.traceId, "parsedCache", @@ -1383,6 +1525,14 @@ class LyricManager( return cached.result } trace(request.traceId, "parsedCache", "result=miss") + realtimeLyricTrace( + stage = "lyricParsedCacheMiss", + trackId = request.trackId, + result = "miss", + positionAnchorMs = request.positionMs, + cacheSource = "PARSED", + failureReason = "EXACT_CACHE_MISS" + ) val lookupStartedAt = System.currentTimeMillis() logger("[LyricsState] parse start trackId=${request.trackId} songKey=${request.key}") @@ -1474,7 +1624,7 @@ class LyricManager( durationMs: Long, positionMs: Long ): Boolean { - val track = PredictiveLyricsTrack( + val track = PredictiveMediaTrack( trackId = trackId, songKey = key, title = title, @@ -1482,11 +1632,11 @@ class LyricManager( album = album, durationMs = durationMs ) - val result = predictiveLyricsPipeline.applyIfAvailable(track) ?: return false + val result = predictiveMediaCoordinator.promote(track) ?: return false cachedKey = key - cachedLines = result.entry.lines + cachedLines = result.payload.lines loadedSongKey = key - lastSource = runCatching { LyricSource.valueOf(result.entry.source) } + lastSource = runCatching { LyricSource.valueOf(result.payload.source) } .getOrDefault(LyricSource.QRC) retryableFailureSongKey = null retryableFailureReason = "" @@ -1507,7 +1657,7 @@ class LyricManager( CurrentTrackRuntimeCache.applyPredictiveLyrics( songKey = key, lines = cachedLines, - lyricSource = result.entry.source, + lyricSource = result.payload.source, positionMs = positionMs, logger = logger ) @@ -1618,6 +1768,55 @@ class LyricManager( LyricTraceLogger.legacy(id, stage, detail, logger) } + private fun realtimeLyricTrace( + stage: String, + trackId: String, + result: String, + positionAnchorMs: Long? = null, + lineIndex: Int? = null, + wordTimingStatus: String? = null, + cacheSource: String? = null, + failureReason: String? = null + ) { + val safeTrackId = trackId.ifBlank { activeTrackId } + val identity = CurrentTrackRuntimeCache.traceIdentitySnapshot() + val generation = identity + ?.takeIf { it.first == safeTrackId } + ?.second + val handoff = TrackHandoffTraceCoordinator.contextFor(safeTrackId) + RealtimeTrace.record( + stage = stage, + monoMs = SystemClock.elapsedRealtime(), + trackId = safeTrackId.takeIf { it.isNotBlank() }, + generation = generation, + payloadType = "lyrics", + result = result, + reason = failureReason, + handoffId = handoff?.handoffId, + triggerType = handoff?.triggerType?.name, + positionAnchorMs = positionAnchorMs, + lineIndex = lineIndex, + wordTimingStatus = wordTimingStatus, + cacheSource = cacheSource, + failureReason = failureReason + ) + } + + private fun classifyLyricFailure(reason: String): String { + val normalized = reason.lowercase(Locale.ROOT) + return when { + normalized.contains("waiting qqmusic") -> "WAITING_QQMUSIC_CACHE" + normalized.contains("identity") -> "TRACK_IDENTITY_INCOMPLETE" + normalized.contains("parsed") && normalized.contains("invalid") -> + "PARSED_CACHE_INVALID" + normalized.contains("generation") -> "GENERATION_MISMATCH" + normalized.contains("track") && normalized.contains("mismatch") -> "TRACK_MISMATCH" + normalized.contains("stale") -> "STALE_PACKET" + normalized.contains("qrc") || normalized.contains("lyric") -> "NO_QRC_FILE" + else -> "UNKNOWN" + } + } + private fun staleLyricResult(): LyricLoadResult { return LyricLoadResult( lines = emptyList(), @@ -1703,6 +1902,21 @@ class LyricManager( } if (currentLine.isNotBlank()) { logger("[LyricAsync] current lyric updated") + realtimeLyricTrace( + stage = "lyricCurrentLineSelected", + trackId = activeTrackId, + result = "selected", + positionAnchorMs = safePosition, + lineIndex = currentIndex, + wordTimingStatus = if ( + lines.getOrNull(currentIndex)?.words?.isNotEmpty() == true + ) { + "AVAILABLE" + } else { + "LINE_ONLY" + }, + cacheSource = lastSource.name + ) } lastLoggedLine = currentLine } @@ -1756,7 +1970,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 +1989,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, @@ -1805,18 +2043,6 @@ class LyricManager( "[Lyric] incremental lyrics applied " + "songKey=${currentTrack.songKey} lines=${lines.size}" ) - predictiveLyricsPipeline.putLoadedTrack( - track = PredictiveLyricsTrack( - trackId = currentTrack.trackId, - songKey = lyricStateKey, - title = currentTrack.title, - artist = currentTrack.artist, - album = currentTrack.album - ), - lines = lines, - source = LyricSource.QRC.name, - buildTimeMs = 0L - ) CurrentTrackRuntimeCache.updateLyrics( songKey = currentTrack.songKey, lines = lines, @@ -1844,11 +2070,15 @@ class LyricManager( @Synchronized fun predictiveMetricsSnapshot(): PredictiveLyricsMetrics { - return predictiveLyricsPipeline.metricsSnapshot() + return predictiveMediaCoordinator.metricsSnapshot() } @Synchronized fun currentStatusText(): String { + return currentStatusTextLocked() + } + + private fun currentStatusTextLocked(): String { val key = activeSongKey ?: return "unknown" return when { key == loadedSongKey && cachedLines.isNotEmpty() -> "loaded" @@ -1919,7 +2149,16 @@ class LyricManager( songKey = activeKey, generation = CurrentTrackRuntimeCache.currentGeneration(), lineCount = if (linesReady) cachedLines.size else 0, - reason = if (linesReady) "" else currentUnavailableReason() + reason = if (linesReady) "" else currentUnavailableReason(), + wordTimingStatus = when { + !linesReady -> "NOT_READY" + cachedLines.any { it.words.isNotEmpty() } -> "AVAILABLE" + else -> "LINE_ONLY" + }, + cacheSource = lastSource.name, + failureReason = if (linesReady) "" else { + classifyLyricFailure(currentUnavailableReason()) + } ) } @@ -2308,6 +2547,42 @@ class LyricManager( .trim() } + private fun predictionLyricFingerprint( + lines: List + ): String { + val digest = MessageDigest.getInstance("SHA-256") + lines.forEachIndexed { index, line -> + digest.update( + buildString { + append(index) + append('|') + append(line.timeMs) + append('|') + append(line.durationMs) + append('|') + append(line.text) + append('|') + append(line.translation.orEmpty()) + append('|') + append(line.romanization.orEmpty()) + append('|') + line.words.forEach { word -> + append(word.startMs) + append(':') + append(word.durationMs) + append(':') + append(word.text) + append(';') + } + append('\n') + }.toByteArray(Charsets.UTF_8) + ) + } + return digest.digest().take(12).joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + } + private fun lyricKey( title: String, artist: String, @@ -2316,17 +2591,6 @@ class LyricManager( return "${title.trim()}|${artist.trim()}|${album.trim()}" } - private fun activePredictiveTrackLocked(request: LyricLoadRequest): PredictiveLyricsTrack { - return PredictiveLyricsTrack( - trackId = activeTrackId, - songKey = request.key, - title = request.title, - artist = request.artist, - album = request.album, - durationMs = activeDurationMs - ) - } - data class LyricLine( val timeMs: Long, val text: String, @@ -2361,6 +2625,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 +2740,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..ff18036 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt @@ -9,18 +9,33 @@ import android.media.session.MediaSessionManager import android.media.session.PlaybackState import android.os.Bundle import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator import com.example.playeragent.history.FastPlaybackSnapshot import com.example.playeragent.logging.LogConfig import com.example.playeragent.service.PlayerNotificationListenerService import org.json.JSONObject import java.security.MessageDigest +internal object TrackIdentityFastLanePolicy { + fun shouldPublish( + trackChanged: Boolean, + trackId: String, + title: String + ): Boolean { + return trackChanged && trackId.isNotBlank() && title.isNotBlank() + } +} + class PlaybackStateReader( context: Context, 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 enableTrackIdentityFastLane: Boolean = false, + private val onTrackIdentityReady: (JSONObject) -> Unit = {} ) { private val appContext = context.applicationContext @@ -30,6 +45,7 @@ class PlaybackStateReader( LyricManager( context = appContext, logger = logger, + executionHub = executionHub, onLyricsReady = { snapshot -> reactiveMediaController.markLyricsTaskFinished( trackId = snapshot.trackId, @@ -47,11 +63,12 @@ class PlaybackStateReader( private var durationMissingLogged = false private var lastLoggedLyric: String? = null private var lastTrackId: String = "" - private var lastObservedTrack: PredictiveLyricsTrack? = null + private var lastObservedTrack: PredictiveMediaTrack? = null private var lastCandidateDiagnosticKey: String = "" private var lastCandidateDiagnosticAtMs: Long = 0L private var lastReactiveTraceKey: String = "" private val transitionStats = mutableMapOf() + private val predictionSourceResolver = PredictionSourceResolver() fun close() { if (lyricManagerHolder.isInitialized()) { @@ -62,6 +79,17 @@ class PlaybackStateReader( fun readPlaybackState(): JSONObject { val startedAtMs = SystemClock.elapsedRealtime() + val pendingHandoff = TrackHandoffTraceCoordinator.pendingContext(startedAtMs) + RealtimeTrace.record( + stage = "playbackReadStart", + monoMs = startedAtMs, + payloadType = "playbackState", + result = "started", + commandSeq = pendingHandoff?.commandSeq, + commandType = pendingHandoff?.commandType, + handoffId = pendingHandoff?.handoffId, + triggerType = pendingHandoff?.triggerType?.name + ) verbose("[PlaybackState] GET_PLAYBACK_STATE received") if (mediaSessionManager == null) { @@ -114,7 +142,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() @@ -126,9 +159,123 @@ class PlaybackStateReader( title = title, artist = artist, album = album, - durationMs = duration + durationMs = duration, + mediaId = mediaId + ) + val observedHandoff = TrackHandoffTraceCoordinator.observeMediaSessionMetadata( + trackId = currentTrack.trackId, + positionAnchorMs = positionSampleElapsedMs ) + if (observedHandoff != null && pendingHandoff == null) { + RealtimeTrace.record( + stage = "playbackReadStart", + monoMs = startedAtMs, + trackId = currentTrack.trackId, + payloadType = "playbackState", + result = "started", + handoffId = observedHandoff.handoffId, + triggerType = observedHandoff.triggerType.name, + positionAnchorMs = positionSampleElapsedMs + ) + } lastTrackId = currentTrack.trackId + val songKey = buildLyricSongKey(title, artist, album) + val mediaDecision = reactiveMediaController.onPlaybackObserved( + trackId = lastTrackId, + songKey = songKey, + title = title, + artist = artist, + album = album, + positionMs = position, + durationMs = duration, + isPlaying = playing + ) + val fastIdentityPublished = enableTrackIdentityFastLane && + TrackIdentityFastLanePolicy.shouldPublish( + trackChanged = mediaDecision.trackChanged, + trackId = lastTrackId, + title = title + ) + if (fastIdentityPublished) { + val identityResponse = JSONObject() + .put("type", "playbackState") + .put("playing", playing) + .put("title", title) + .put("artist", artist) + .put("album", album) + .put("position", position) + .put("positionSampleElapsedMs", positionSampleElapsedMs) + .put("positionSampleUnixMs", positionSampleUnixMs) + .put("speed", playbackSpeed.toDouble()) + .put("duration", duration) + .put("lyric", "") + .put("lyricStatus", "LOADING") + .put("lyricReason", "TRACK_IDENTITY_READY") + .put("lyricSuggestion", "") + val runtimeTrack = CurrentTrackRuntimeCache.updatePlaybackState( + trackId = lastTrackId, + songKey = songKey, + title = title, + artist = artist, + album = album, + positionMs = position, + positionSampleElapsedMs = positionSampleElapsedMs, + durationMs = duration, + isPlaying = playing, + playbackSpeed = playbackSpeed, + currentLine = "", + lyricSource = "NONE", + lastPlaybackState = identityResponse, + diagnosticSnapshot = "", + logger = logger + ) + val acceptedAtMs = SystemClock.elapsedRealtime() + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(runtimeTrack.trackId) + RealtimeTrace.record( + stage = "trackIdentityAccepted", + monoMs = acceptedAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "mediaSession", + result = "accepted", + reason = "fast_lane", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) + RealtimeTrace.record( + stage = "mediaGenerationCreated", + monoMs = acceptedAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "trackIdentity", + result = "created", + reason = "fast_lane", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) + RealtimeTrace.record( + stage = "trackIdentityFastReady", + monoMs = acceptedAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "trackInfo", + processingMs = (acceptedAtMs - startedAtMs).coerceAtLeast(0L), + result = "ready", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) + runCatching { + onTrackIdentityReady(identityResponse) + }.onFailure { exception -> + logger( + "[PlaybackState] track identity fast lane callback failed: " + + exception.message + ) + } + } TrackCapabilityTracker.onTrackSeen( trackId = lastTrackId, protocolId = lastTrackId, @@ -157,17 +304,6 @@ class PlaybackStateReader( .isNotBlank() ) observeTrackTransition(currentTrack) - val songKey = buildLyricSongKey(title, artist, album) - val mediaDecision = reactiveMediaController.onPlaybackObserved( - trackId = lastTrackId, - songKey = songKey, - title = title, - artist = artist, - album = album, - positionMs = position, - durationMs = duration, - isPlaying = playing - ) if (mediaDecision.trackChanged) { LyricTraceLogger.stage( runId = "unknown", @@ -262,9 +398,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,20 +429,25 @@ 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) .put("lyricReason", lyricReason) .put("lyricSuggestion", diagnostic.suggestion) - CurrentTrackRuntimeCache.updatePlaybackState( + val runtimeTrack = CurrentTrackRuntimeCache.updatePlaybackState( trackId = lastTrackId, songKey = songKey, title = title, artist = artist, album = album, positionMs = position, + positionSampleElapsedMs = positionSampleElapsedMs, durationMs = duration, isPlaying = playing, + playbackSpeed = playbackSpeed, currentLine = lyric, lyricSource = diagnostic.source, lastPlaybackState = response, @@ -322,17 +464,75 @@ class PlaybackStateReader( ) } } + observeQueueCandidates(selected, currentTrack) + val readyAtMs = SystemClock.elapsedRealtime() + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(runtimeTrack.trackId) + if (mediaDecision.trackChanged && !fastIdentityPublished) { + RealtimeTrace.record( + stage = "trackIdentityAccepted", + monoMs = readyAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "mediaSession", + result = "accepted", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) + RealtimeTrace.record( + stage = "mediaGenerationCreated", + monoMs = readyAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "trackIdentity", + result = "created", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) + } + RealtimeTrace.record( + stage = "playbackReady", + monoMs = readyAtMs, + trackId = runtimeTrack.trackId, + generation = runtimeTrack.currentTrackGeneration, + payloadType = "playbackState", + processingMs = (readyAtMs - startedAtMs).coerceAtLeast(0L), + result = "ready", + reason = if (runtimeTrack.isPlaying) "playing" else "paused", + commandSeq = handoffTrace?.commandSeq, + commandType = handoffTrace?.commandType, + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = runtimeTrack.positionAnchorElapsedMs + ) return response } fun notifyManualNextHint(seq: String? = null) { - logger("[PredictiveLyricsCandidate] manual next requested seq=${seq.orEmpty()}") + notifyManualTrackHint(PredictionDirection.NEXT, seq) + } + + fun notifyManualPreviousHint(seq: String? = null) { + notifyManualTrackHint(PredictionDirection.PREVIOUS, seq) + } + + private fun notifyManualTrackHint(direction: PredictionDirection, seq: String?) { + val mode = if (direction == PredictionDirection.NEXT) { + PredictiveCandidateMode.MANUAL_NEXT + } else { + PredictiveCandidateMode.MANUAL_PREVIOUS + } + logger( + "[PredictionSource] manual direction=${direction.name} " + + "seqPresent=${!seq.isNullOrBlank()}" + ) val selected = selectedControllerForPrediction() if (selected == null) { logCandidateUnavailable( - source = PredictiveLyricsPipeline.SOURCE_MANUAL_NEXT_WITH_QUEUE, + source = mode.source.wireName, reason = "no_active_session", - detail = "seq=${seq.orEmpty()}" + detail = "direction=${direction.name}" ) return } @@ -346,21 +546,23 @@ class PlaybackStateReader( title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE).orEmpty(), artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST).orEmpty(), album = metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM).orEmpty(), - durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L + durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L, + mediaId = metadata?.getString(MediaMetadata.METADATA_KEY_MEDIA_ID).orEmpty() ) - val candidate = predictiveCandidate(selected, current, PredictiveCandidateMode.MANUAL_NEXT) + val candidate = predictiveCandidate(selected, current, mode) if (candidate == null) { logger( - "[PredictiveLyricsCandidate] rejected source=manual_next_with_queue " + - "reason=no_safe_queue_candidate seq=${seq.orEmpty()}" + "[PredictionSource] rejected source=${mode.source.wireName} " + + "reason=no_safe_queue_candidate direction=${direction.name}" ) return } logger( - "[PredictiveLyricsCandidate] manual next hint seq=${seq.orEmpty()} " + - "title=${candidate.track.title} artist=${candidate.track.artist}" + "[PredictionSource] selected candidateId=${candidate.candidateKey} " + + "identityDigest=${candidate.track.identityDigest} " + + "source=${candidate.source.wireName} confidence=${candidate.confidence.name}" ) - lyricManager.preloadPredictiveLyrics(candidate) + lyricManager.preloadPredictiveMedia(candidate) } fun lyricLinesSnapshot(): List { @@ -540,9 +742,9 @@ class PlaybackStateReader( private fun predictiveCandidate( controller: MediaController, - current: PredictiveLyricsTrack, + current: PredictiveMediaTrack, mode: PredictiveCandidateMode - ): PredictiveLyricsCandidate? { + ): PredictionCandidate? { val queueCandidate = queuePredictiveCandidate(controller, current, mode) if (queueCandidate != null) { return queueCandidate @@ -555,81 +757,67 @@ class PlaybackStateReader( private fun queuePredictiveCandidate( controller: MediaController, - current: PredictiveLyricsTrack, + current: PredictiveMediaTrack, mode: PredictiveCandidateMode - ): PredictiveLyricsCandidate? { + ): PredictionCandidate? { logMediaSessionQueueDiagnostics(controller, current, mode) val queue = controller.queue if (queue == null) { - logCandidateUnavailable(mode.source, "queue_null") + logCandidateUnavailable(mode.source.wireName, "queue_null") return null } if (queue.isEmpty()) { - logCandidateUnavailable(mode.source, "queue_empty") + logCandidateUnavailable(mode.source.wireName, "queue_empty") return null } - val activeQueueId = controller.playbackState?.activeQueueItemId ?: -1L - val currentIndexByQueueId = if (activeQueueId >= 0) { - queue.indexOfFirst { it.queueId == activeQueueId } - } else { - -1 - } - val currentIndex = if (currentIndexByQueueId >= 0) { - currentIndexByQueueId - } else { - queue.indexOfFirst { item -> - val track = trackFromDescription(item.description) - sameTrackIdentity(track, current, allowMissingDuration = true) - } - } - if (currentIndex < 0) { - logCandidateUnavailable(mode.source, "active_queue_id_unknown") - return null - } - if (currentIndex + 1 >= queue.size) { - logCandidateUnavailable(mode.source, "no_next_queue_item") - return null - } - val nextItem = queue[currentIndex + 1] - val nextTrack = trackFromDescription(nextItem.description) - if (nextTrack.title.isBlank()) { - logCandidateUnavailable(mode.source, "metadata_missing") - return null - } - return PredictiveLyricsCandidate( - source = mode.source, - confidence = 1.0, - track = nextTrack, - mediaId = nextItem.description.mediaId.orEmpty(), - queueId = nextItem.queueId, - reason = if (mode == PredictiveCandidateMode.MANUAL_NEXT) { - "manual next with visible queue" - } else { - "next queue item" + val snapshot = PredictionQueueSnapshot( + activeQueueItemId = controller.playbackState?.activeQueueItemId ?: -1L, + items = queue.map { item -> + PredictionQueueItem( + queueItemId = item.queueId, + mediaId = item.description.mediaId.orEmpty(), + track = trackFromDescription(item.description) + ) } ) + return predictionSourceResolver.resolve( + snapshot = snapshot, + current = current, + direction = mode.direction, + source = mode.source + ) ?: run { + logCandidateUnavailable(mode.source.wireName, "no_directional_queue_item") + null + } } - private fun historyPredictiveCandidate(current: PredictiveLyricsTrack): PredictiveLyricsCandidate? { + private fun historyPredictiveCandidate(current: PredictiveMediaTrack): PredictionCandidate? { val transition = transitionStats[current.fallbackKey] ?: return null if (transition.count < HISTORY_TRANSITION_MIN_COUNT) { return null } + val createdAt = SystemClock.elapsedRealtime() + val candidateKey = PredictionIdentity.candidateKey( + transition.next, + -1L + ) logger( - "[PredictiveLyricsCandidate] history transition candidate " + - "confidence=0.7 title=${transition.next.title} " + - "artist=${transition.next.artist} reason=repeated_transition " + + "[PredictionSource] history candidateId=$candidateKey " + + "identityDigest=${transition.next.identityDigest} confidence=WEAK " + "count=${transition.count}" ) - return PredictiveLyricsCandidate( - source = PredictiveLyricsPipeline.SOURCE_HISTORY_TRANSITION, - confidence = 0.7, + return PredictionCandidate( + candidateKey = candidateKey, + source = PredictionSource.HISTORY_TRANSITION, + confidence = PredictionConfidence.WEAK, + direction = PredictionDirection.NEXT, track = transition.next, - reason = "repeated transition count=${transition.count}" + createdElapsedMs = createdAt, + expiresElapsedMs = createdAt + PredictionSourceResolver.DEFAULT_CANDIDATE_TTL_MS ) } - private fun observeTrackTransition(current: PredictiveLyricsTrack) { + private fun observeTrackTransition(current: PredictiveMediaTrack) { if (current.title.isBlank()) { return } @@ -649,17 +837,35 @@ class PlaybackStateReader( } transitionStats[key] = TransitionStat(current, nextCount) logger( - "[PredictiveLyricsCandidate] history transition learned " + - "from=${previous.title}|${previous.artist} " + - "to=${current.title}|${current.artist} count=$nextCount" + "[PredictionSource] history transition learned " + + "fromDigest=${previous.identityDigest} " + + "toDigest=${current.identityDigest} count=$nextCount" ) } lastObservedTrack = current } + private fun observeQueueCandidates( + controller: MediaController, + current: PredictiveMediaTrack + ) { + val next = predictiveCandidate(controller, current, PredictiveCandidateMode.AUTO) + if (next != null) { + lyricManager.preloadPredictiveMedia(next) + } + val previous = queuePredictiveCandidate( + controller, + current, + PredictiveCandidateMode.AUTO_PREVIOUS + ) + if (previous != null) { + lyricManager.preloadPredictiveMedia(previous) + } + } + private fun logMediaSessionQueueDiagnostics( controller: MediaController, - current: PredictiveLyricsTrack, + current: PredictiveMediaTrack, mode: PredictiveCandidateMode ) { val metadata = controller.metadata @@ -670,25 +876,23 @@ class PlaybackStateReader( val metadataAlbum = metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM).orEmpty() val metadataDuration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L val metadataMediaId = metadata?.getString(MediaMetadata.METADATA_KEY_MEDIA_ID).orEmpty() - val currentQueueItem = queue?.firstOrNull { it.queueId == activeQueueId } - val currentQueueId = currentQueueItem?.queueId ?: -1L - val queueHead = queue?.take(3)?.joinToString(";") { item -> - val desc = item.description - "id=${item.queueId},title=${desc.title?.toString().orEmpty()}," + - "artist=${desc.subtitle?.toString().orEmpty()}," + - "mediaId=${desc.mediaId.orEmpty()}" - }.orEmpty() + val currentQueueId = queue?.firstOrNull { it.queueId == activeQueueId }?.queueId ?: -1L + val completeQueueIdentityCount = queue?.count { item -> + !item.description.mediaId.isNullOrBlank() && + !item.description.title.isNullOrBlank() && + !item.description.subtitle.isNullOrBlank() + } ?: 0 logger( - "[PredictiveLyricsCandidate] queue diagnostic source=${mode.source} " + + "[PredictionSource] queue diagnostic source=${mode.source.wireName} " + "hasQueue=${queue != null} queueSize=${queue?.size ?: -1} " + "activeQueueId=$activeQueueId currentQueueId=$currentQueueId " + - "currentTitle=${current.title} currentArtist=${current.artist} " + + "currentIdentityDigest=${current.identityDigest} " + "metadataTitlePresent=${metadataTitle.isNotBlank()} " + "metadataArtistPresent=${metadataArtist.isNotBlank()} " + "metadataAlbumPresent=${metadataAlbum.isNotBlank()} " + "metadataDurationMs=$metadataDuration " + - "metadataMediaId=${metadataMediaId.ifBlank { "none" }} " + - "queueHead=$queueHead" + "metadataMediaIdPresent=${metadataMediaId.isNotBlank()} " + + "completeQueueIdentityCount=$completeQueueIdentityCount" ) } @@ -705,7 +909,7 @@ class PlaybackStateReader( lastCandidateDiagnosticKey = key lastCandidateDiagnosticAtMs = now logger( - "[PredictiveLyricsCandidate] source=$source unavailable reason=$reason" + + "[PredictionSource] source=$source unavailable reason=$reason" + if (detail.isNotBlank()) " $detail" else "" ) } @@ -719,7 +923,7 @@ class PlaybackStateReader( val controllers = try { manager.getActiveSessions(listenerComponent) } catch (exception: Exception) { - logger("[PredictiveLyricsCandidate] source=manual_next_with_queue unavailable reason=getActiveSessions_failed") + logger("[PredictionSource] source=manual_queue unavailable reason=getActiveSessions_failed") return null } return selectQqMusicController(controllers) @@ -734,7 +938,7 @@ class PlaybackStateReader( } ?: qqControllers.firstOrNull() } - private fun trackFromDescription(description: MediaDescription): PredictiveLyricsTrack { + private fun trackFromDescription(description: MediaDescription): PredictiveMediaTrack { val title = description.title?.toString().orEmpty().trim() val artist = description.subtitle?.toString().orEmpty().trim() val album = description.description?.toString().orEmpty().trim() @@ -750,7 +954,8 @@ class PlaybackStateReader( title = title, artist = artist, album = album, - durationMs = duration + durationMs = duration, + mediaId = mediaId ) } @@ -759,22 +964,24 @@ class PlaybackStateReader( title: String, artist: String, album: String, - durationMs: Long - ): PredictiveLyricsTrack { + durationMs: Long, + mediaId: String = "" + ): PredictiveMediaTrack { val safeAlbum = album.trim() - return PredictiveLyricsTrack( + return PredictiveMediaTrack( trackId = trackId, songKey = buildLyricSongKey(title, artist, safeAlbum), title = title.trim(), artist = artist.trim(), album = safeAlbum, - durationMs = durationMs + durationMs = durationMs, + mediaId = mediaId ) } private fun sameTrackIdentity( - left: PredictiveLyricsTrack, - right: PredictiveLyricsTrack, + left: PredictiveMediaTrack, + right: PredictiveMediaTrack, allowMissingDuration: Boolean ): Boolean { if (!sameTrackTitleArtist(left, right)) { @@ -787,14 +994,14 @@ class PlaybackStateReader( } private fun sameTrackTitleArtist( - left: PredictiveLyricsTrack, - right: PredictiveLyricsTrack + left: PredictiveMediaTrack, + right: PredictiveMediaTrack ): Boolean { if (!left.title.equals(right.title, ignoreCase = true)) { return false } - return left.artist.isBlank() || - right.artist.isBlank() || + return left.artist.isNotBlank() && + right.artist.isNotBlank() && left.artist.equals(right.artist, ignoreCase = true) } @@ -810,17 +1017,20 @@ class PlaybackStateReader( ) keys.forEach { key -> if (extras.containsKey(key)) { - val value = extras.get(key) - when (value) { - is Number -> return value.toLong() - is String -> value.toLongOrNull()?.let { return it } - } + val longValue = extras.getLong(key, Long.MIN_VALUE) + if (longValue != Long.MIN_VALUE) return longValue + val intValue = extras.getInt(key, Int.MIN_VALUE) + if (intValue != Int.MIN_VALUE) return intValue.toLong() + extras.getString(key)?.toLongOrNull()?.let { return it } } } 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 +1040,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) } @@ -842,13 +1052,24 @@ class PlaybackStateReader( private const val QQ_MUSIC_PACKAGE = "com.tencent.qqmusic" } - private enum class PredictiveCandidateMode(val source: String) { - AUTO(PredictiveLyricsPipeline.SOURCE_MEDIA_SESSION_QUEUE), - MANUAL_NEXT(PredictiveLyricsPipeline.SOURCE_MANUAL_NEXT_WITH_QUEUE) + private enum class PredictiveCandidateMode( + val source: PredictionSource, + val direction: PredictionDirection + ) { + AUTO(PredictionSource.MEDIA_SESSION_QUEUE, PredictionDirection.NEXT), + AUTO_PREVIOUS(PredictionSource.MEDIA_SESSION_QUEUE, PredictionDirection.PREVIOUS), + MANUAL_NEXT( + PredictionSource.MANUAL_NEXT_WITH_QUEUE, + PredictionDirection.NEXT + ), + MANUAL_PREVIOUS( + PredictionSource.MANUAL_PREVIOUS_WITH_QUEUE, + PredictionDirection.PREVIOUS + ) } private data class TransitionStat( - val next: PredictiveLyricsTrack, + val next: PredictiveMediaTrack, val count: Int ) } 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..eab9229 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlayerAgentExecutionHub.kt @@ -0,0 +1,57 @@ +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 currentWord: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "$threadNamePrefix-CurrentWord") + } + 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, currentWord, maintenance) + + companion object { + private const val SHUTDOWN_WAIT_MS = 1_500L + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveLyricsPipeline.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveLyricsPipeline.kt deleted file mode 100644 index 2ba0af8..0000000 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveLyricsPipeline.kt +++ /dev/null @@ -1,401 +0,0 @@ -package com.example.playeragent.media - -import android.os.SystemClock -import java.util.Locale -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicLong - -data class PredictiveLyricsTrack( - val trackId: String, - val songKey: String, - val title: String, - val artist: String, - val album: String, - val durationMs: Long = 0L -) { - val fallbackKey: String - get() = PredictiveLyricsPipeline.fallbackKey(title, artist, durationMs) -} - -data class PredictiveLyricsCandidate( - val source: String, - val confidence: Double, - val track: PredictiveLyricsTrack, - val mediaId: String = "", - val queueId: Long = -1L, - val reason: String = "", - val createdAtMs: Long = System.currentTimeMillis() -) - -data class PredictiveLyricsEntry( - val track: PredictiveLyricsTrack, - val lines: List, - val source: String, - val buildTimeMs: Long, - val cachedAtMs: Long, - val hasWordTiming: Boolean -) - -data class PredictiveLyricsApplyResult( - val entry: PredictiveLyricsEntry, - val applyCostMs: Long -) - -data class PredictiveLyricsMetrics( - val candidateCount: Long = 0L, - val mediaSessionQueueCandidateCount: Long = 0L, - val manualNextHintCount: Long = 0L, - val historyTransitionCandidateCount: Long = 0L, - val selectedCount: Long = 0L, - val rejectedCount: Long = 0L, - val preloadStartCount: Long = 0L, - val preloadHitCount: Long = 0L, - val preloadMissCount: Long = 0L, - val preloadSuccessCount: Long = 0L, - val preloadFailedCount: Long = 0L, - val cachePutCount: Long = 0L, - val cacheEvictCount: Long = 0L, - val applyHitCount: Long = 0L, - val applyMissCount: Long = 0L, - val identityMismatchCount: Long = 0L, - val preloadCostTotalMs: Long = 0L, - val preloadCostMaxMs: Long = 0L, - val applyCostTotalMs: Long = 0L, - val applyCostMaxMs: Long = 0L -) - -data class PredictiveLyricsLoadResult( - val lines: List, - val source: String, - val reason: String -) - -class PredictiveLyricsPipeline( - private val logger: (String) -> Unit, - private val loader: (PredictiveLyricsTrack) -> PredictiveLyricsLoadResult, - private val enabled: Boolean = true -) { - private val lock = Any() - private val executor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "PredictiveLyricsThread") - } - private val cache = object : LinkedHashMap( - MAX_CACHE_SIZE, - 0.75f, - true - ) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry? - ): Boolean { - val shouldEvict = size > MAX_CACHE_SIZE - if (shouldEvict && eldest != null) { - cacheEvictCount.incrementAndGet() - logger( - "[PredictiveLyrics] cache evict key=${eldest.key} " + - "track=${eldest.value.track.title}" - ) - } - return shouldEvict - } - } - private val inFlight = mutableSetOf() - - private val candidateCount = AtomicLong(0L) - private val mediaSessionQueueCandidateCount = AtomicLong(0L) - private val manualNextHintCount = AtomicLong(0L) - private val historyTransitionCandidateCount = AtomicLong(0L) - private val selectedCount = AtomicLong(0L) - private val rejectedCount = AtomicLong(0L) - private val preloadStartCount = AtomicLong(0L) - private val preloadHitCount = AtomicLong(0L) - private val preloadMissCount = AtomicLong(0L) - private val preloadSuccessCount = AtomicLong(0L) - private val preloadFailedCount = AtomicLong(0L) - private val cachePutCount = AtomicLong(0L) - private val cacheEvictCount = AtomicLong(0L) - private val applyHitCount = AtomicLong(0L) - private val applyMissCount = AtomicLong(0L) - private val identityMismatchCount = AtomicLong(0L) - private val preloadCostTotalMs = AtomicLong(0L) - private val preloadCostMaxMs = AtomicLong(0L) - private val applyCostTotalMs = AtomicLong(0L) - private val applyCostMaxMs = AtomicLong(0L) - - fun onCandidate(candidate: PredictiveLyricsCandidate) { - if (!enabled) { - return - } - val track = candidate.track - if (track.title.isBlank()) { - rejectedCount.incrementAndGet() - logger( - "[PredictiveLyricsCandidate] rejected reason=title_blank " + - "source=${candidate.source}" - ) - return - } - when (candidate.source) { - SOURCE_MEDIA_SESSION_QUEUE -> mediaSessionQueueCandidateCount.incrementAndGet() - SOURCE_MANUAL_NEXT_WITH_QUEUE -> manualNextHintCount.incrementAndGet() - SOURCE_HISTORY_TRANSITION -> historyTransitionCandidateCount.incrementAndGet() - } - candidateCount.incrementAndGet() - selectedCount.incrementAndGet() - logger( - "[PredictiveLyricsCandidate] selected source=${candidate.source} " + - "confidence=${candidate.confidence} title=${track.title} " + - "artist=${track.artist} queueId=${candidate.queueId} " + - "mediaId=${candidate.mediaId} reason=${candidate.reason}" - ) - logger( - "[PredictiveLyrics] candidate track=${track.title} artist=${track.artist} " + - "trackId=${track.trackId} durationMs=${track.durationMs} " + - "source=${candidate.source}" - ) - val key = cacheKey(track) - synchronized(lock) { - evictExpiredLocked() - val cached = cache[key] ?: cache[track.fallbackKey] - if (cached != null && identityMatches(cached.track, track)) { - preloadHitCount.incrementAndGet() - logger( - "[PredictiveLyrics] preload hit track=${track.title} " + - "linesCount=${cached.lines.size} hasWordTiming=${cached.hasWordTiming}" - ) - return - } - if (!inFlight.add(key)) { - logger("[PredictiveLyrics] preload hit track=${track.title} reason=in_flight") - return - } - } - executor.execute { - preload(track, key) - } - } - - fun putLoadedTrack( - track: PredictiveLyricsTrack, - lines: List, - source: String, - buildTimeMs: Long = 0L - ) { - if (!enabled || track.title.isBlank() || lines.isEmpty()) { - return - } - putEntry( - PredictiveLyricsEntry( - track = track, - lines = lines.toList(), - source = source, - buildTimeMs = buildTimeMs, - cachedAtMs = System.currentTimeMillis(), - hasWordTiming = lines.any { it.words.isNotEmpty() } - ) - ) - } - - fun applyIfAvailable(track: PredictiveLyricsTrack): PredictiveLyricsApplyResult? { - if (!enabled || track.title.isBlank()) { - return null - } - val startedAt = SystemClock.elapsedRealtime() - synchronized(lock) { - evictExpiredLocked() - val entry = cache[cacheKey(track)] ?: cache[track.fallbackKey] - if (entry == null) { - applyMissCount.incrementAndGet() - logger("[PredictiveLyrics] apply miss track=${track.title}") - return null - } - if (!identityMatches(entry.track, track)) { - identityMismatchCount.incrementAndGet() - applyMissCount.incrementAndGet() - logger( - "[PredictiveLyrics] identity mismatch cached=${entry.track.title} " + - "current=${track.title} cachedArtist=${entry.track.artist} " + - "currentArtist=${track.artist}" - ) - return null - } - val costMs = SystemClock.elapsedRealtime() - startedAt - applyHitCount.incrementAndGet() - applyCostTotalMs.addAndGet(costMs) - updateMax(applyCostMaxMs, costMs) - logger( - "[PredictiveLyrics] apply hit track=${track.title} " + - "applyCostMs=$costMs linesCount=${entry.lines.size} " + - "hasWordTiming=${entry.hasWordTiming}" - ) - return PredictiveLyricsApplyResult(entry, costMs) - } - } - - fun metricsSnapshot(): PredictiveLyricsMetrics { - return PredictiveLyricsMetrics( - candidateCount = candidateCount.get(), - mediaSessionQueueCandidateCount = mediaSessionQueueCandidateCount.get(), - manualNextHintCount = manualNextHintCount.get(), - historyTransitionCandidateCount = historyTransitionCandidateCount.get(), - selectedCount = selectedCount.get(), - rejectedCount = rejectedCount.get(), - preloadStartCount = preloadStartCount.get(), - preloadHitCount = preloadHitCount.get(), - preloadMissCount = preloadMissCount.get(), - preloadSuccessCount = preloadSuccessCount.get(), - preloadFailedCount = preloadFailedCount.get(), - cachePutCount = cachePutCount.get(), - cacheEvictCount = cacheEvictCount.get(), - applyHitCount = applyHitCount.get(), - applyMissCount = applyMissCount.get(), - identityMismatchCount = identityMismatchCount.get(), - preloadCostTotalMs = preloadCostTotalMs.get(), - preloadCostMaxMs = preloadCostMaxMs.get(), - applyCostTotalMs = applyCostTotalMs.get(), - applyCostMaxMs = applyCostMaxMs.get() - ) - } - - private fun preload(track: PredictiveLyricsTrack, key: String) { - val startedAt = SystemClock.elapsedRealtime() - preloadStartCount.incrementAndGet() - logger("[PredictiveLyrics] preload start track=${track.title} key=$key") - try { - val result = loader(track) - val costMs = SystemClock.elapsedRealtime() - startedAt - preloadCostTotalMs.addAndGet(costMs) - updateMax(preloadCostMaxMs, costMs) - if (result.lines.isNotEmpty()) { - preloadHitCount.incrementAndGet() - preloadSuccessCount.incrementAndGet() - putEntry( - PredictiveLyricsEntry( - track = track, - lines = result.lines, - source = result.source, - buildTimeMs = costMs, - cachedAtMs = System.currentTimeMillis(), - hasWordTiming = result.lines.any { it.words.isNotEmpty() } - ) - ) - logger( - "[PredictiveLyrics] preload hit track=${track.title} " + - "preloadCostMs=$costMs linesCount=${result.lines.size} " + - "hasWordTiming=${result.lines.any { it.words.isNotEmpty() }}" - ) - } else { - preloadMissCount.incrementAndGet() - preloadFailedCount.incrementAndGet() - logger( - "[PredictiveLyrics] preload miss track=${track.title} " + - "preloadCostMs=$costMs reason=${result.reason}" - ) - } - } catch (exception: Exception) { - preloadMissCount.incrementAndGet() - preloadFailedCount.incrementAndGet() - logger( - "[PredictiveLyrics] preload miss track=${track.title} " + - "reason=${exception.message.orEmpty()}" - ) - } finally { - synchronized(lock) { - inFlight.remove(key) - } - } - } - - private fun putEntry(entry: PredictiveLyricsEntry) { - synchronized(lock) { - evictExpiredLocked() - cache[cacheKey(entry.track)] = entry - cache[entry.track.fallbackKey] = entry - cachePutCount.incrementAndGet() - logger( - "[PredictiveLyrics] cache put track=${entry.track.title} " + - "trackId=${entry.track.trackId} linesCount=${entry.lines.size} " + - "hasWordTiming=${entry.hasWordTiming} buildTimeMs=${entry.buildTimeMs}" - ) - } - } - - private fun evictExpiredLocked() { - val now = System.currentTimeMillis() - val iterator = cache.entries.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (now - entry.value.cachedAtMs > CACHE_TTL_MS) { - cacheEvictCount.incrementAndGet() - logger( - "[PredictiveLyrics] cache evict key=${entry.key} " + - "reason=ttl track=${entry.value.track.title}" - ) - iterator.remove() - } - } - } - - private fun cacheKey(track: PredictiveLyricsTrack): String { - return if (track.trackId.isNotBlank()) { - "track:${track.trackId}" - } else { - track.fallbackKey - } - } - - private fun identityMatches( - cached: PredictiveLyricsTrack, - current: PredictiveLyricsTrack - ): Boolean { - if (cached.trackId.isNotBlank() && current.trackId.isNotBlank() && - cached.trackId == current.trackId - ) { - return true - } - if (normalize(cached.title) != normalize(current.title)) { - return false - } - if (normalize(cached.artist) != normalize(current.artist)) { - return false - } - if (cached.durationMs > 0L && current.durationMs > 0L) { - return durationBucket(cached.durationMs) == durationBucket(current.durationMs) - } - return true - } - - private fun updateMax(target: AtomicLong, value: Long) { - while (true) { - val current = target.get() - if (value <= current) { - return - } - if (target.compareAndSet(current, value)) { - return - } - } - } - - companion object { - private const val MAX_CACHE_SIZE = 10 - private const val CACHE_TTL_MS = 30 * 60_000L - const val SOURCE_MEDIA_SESSION_QUEUE = "media_session_queue" - const val SOURCE_MANUAL_NEXT_WITH_QUEUE = "manual_next_with_queue" - const val SOURCE_HISTORY_TRANSITION = "history_transition" - - fun fallbackKey(title: String, artist: String, durationMs: Long): String { - return "fallback:${normalize(title)}|${normalize(artist)}|${durationBucket(durationMs)}" - } - - private fun normalize(value: String): String { - return value.trim().lowercase(Locale.ROOT).replace("\\s+".toRegex(), " ") - } - - private fun durationBucket(durationMs: Long): Long { - if (durationMs <= 0L) { - return 0L - } - return durationMs / 2_000L - } - } -} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveMediaCoordinator.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveMediaCoordinator.kt new file mode 100644 index 0000000..9323b6f --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveMediaCoordinator.kt @@ -0,0 +1,674 @@ +package com.example.playeragent.media + +import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace +import java.io.Closeable +import java.security.MessageDigest +import java.util.LinkedHashMap +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +enum class PredictionDirection { + NEXT, + PREVIOUS +} + +enum class PredictionSource(val wireName: String) { + MEDIA_SESSION_QUEUE("media_session_queue"), + MANUAL_NEXT_WITH_QUEUE("manual_next_with_queue"), + MANUAL_PREVIOUS_WITH_QUEUE("manual_previous_with_queue"), + HISTORY_TRANSITION("history_transition") +} + +enum class PredictionConfidence { + CONFIRMED, + STRONG, + WEAK, + NONE +} + +enum class PredictionState { + DISCOVERED, + PREWARM_QUEUED, + PREWARMING, + LYRICS_READY, + ARTWORK_READY, + READY, + PROMOTED, + REJECTED, + INVALIDATED, + EXPIRED +} + +data class PredictiveMediaTrack( + val trackId: String, + val songKey: String, + val title: String, + val artist: String, + val album: String, + val durationMs: Long = 0L, + val mediaId: String = "" +) { + val fallbackKey: String + get() = PredictionIdentity.fallbackKey(title, artist, durationMs) + + val identityDigest: String + get() = PredictionIdentity.digest(title, artist, album, durationMs, mediaId) + + val hasCompleteMetadata: Boolean + get() = title.isNotBlank() && artist.isNotBlank() +} + +data class PredictionCandidate( + val candidateKey: String, + val source: PredictionSource, + val confidence: PredictionConfidence, + val direction: PredictionDirection, + val track: PredictiveMediaTrack, + val queueItemId: Long = -1L, + val createdElapsedMs: Long, + val expiresElapsedMs: Long, + val lyricFingerprint: String = "", + val artworkId: String = "", + val lyricsReady: Boolean = false, + val artworkPreviewReady: Boolean = false, + val compressedLyricsReady: Boolean = false, + val state: PredictionState = PredictionState.DISCOVERED +) + +data class PredictionPrewarmResult( + val lyricsReady: Boolean, + val lyricFingerprint: String = "", + val lineCount: Int = 0, + val hasWordTiming: Boolean = false, + val compressedLyricsReady: Boolean = false, + val artworkPreviewReady: Boolean = false, + val artworkId: String = "", + val source: String = "", + val reason: String = "" +) + +data class PredictionPromotionPayload( + val lines: List, + val source: String, + val lyricFingerprint: String +) + +data class PredictionPromotionResult( + val candidate: PredictionCandidate, + val payload: PredictionPromotionPayload, + val applyCostMs: Long +) + +data class PredictiveLyricsMetrics( + val candidateCount: Long = 0L, + val mediaSessionQueueCandidateCount: Long = 0L, + val manualNextHintCount: Long = 0L, + val manualPreviousHintCount: Long = 0L, + val historyTransitionCandidateCount: Long = 0L, + val selectedCount: Long = 0L, + val rejectedCount: Long = 0L, + val expiredCount: Long = 0L, + val invalidatedCount: Long = 0L, + val preloadStartCount: Long = 0L, + val preloadHitCount: Long = 0L, + val preloadMissCount: Long = 0L, + val preloadSuccessCount: Long = 0L, + val preloadFailedCount: Long = 0L, + val cachePutCount: Long = 0L, + val cacheEvictCount: Long = 0L, + val applyHitCount: Long = 0L, + val applyMissCount: Long = 0L, + val identityMismatchCount: Long = 0L, + val preloadCostTotalMs: Long = 0L, + val preloadCostMaxMs: Long = 0L, + val applyCostTotalMs: Long = 0L, + val applyCostMaxMs: Long = 0L +) + +data class PredictionQueueItem( + val queueItemId: Long, + val mediaId: String, + val track: PredictiveMediaTrack +) + +data class PredictionQueueSnapshot( + val activeQueueItemId: Long, + val items: List +) + +object PredictionIdentity { + fun digest( + title: String, + artist: String, + album: String, + durationMs: Long, + mediaId: String + ): String { + val canonical = listOf( + normalize(title), + normalize(artist), + normalize(album), + durationBucket(durationMs).toString(), + normalize(mediaId) + ).joinToString("|") + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + .take(12) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + fun candidateKey( + track: PredictiveMediaTrack, + queueItemId: Long + ): String { + val stable = when { + track.mediaId.isNotBlank() -> "media:${normalize(track.mediaId)}" + queueItemId >= 0L -> "queue:$queueItemId" + track.trackId.isNotBlank() -> "track:${track.trackId}" + else -> track.fallbackKey + } + val material = "$stable|${track.identityDigest}" + return MessageDigest.getInstance("SHA-256") + .digest(material.toByteArray(Charsets.UTF_8)) + .take(8) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + fun fallbackKey(title: String, artist: String, durationMs: Long): String { + return "fallback:${normalize(title)}|${normalize(artist)}|${durationBucket(durationMs)}" + } + + fun exactMatch(candidate: PredictiveMediaTrack, actual: PredictiveMediaTrack): Boolean { + if (!candidate.hasCompleteMetadata || !actual.hasCompleteMetadata) return false + if (normalize(candidate.title) != normalize(actual.title)) return false + if (normalize(candidate.artist) != normalize(actual.artist)) return false + val mediaIdComparable = candidate.mediaId.isNotBlank() && actual.mediaId.isNotBlank() + val trackIdComparable = candidate.trackId.isNotBlank() && actual.trackId.isNotBlank() + if (mediaIdComparable && normalize(candidate.mediaId) != normalize(actual.mediaId)) { + return false + } + if (!mediaIdComparable && trackIdComparable && candidate.trackId != actual.trackId) { + return false + } + if (candidate.durationMs > 0L && actual.durationMs > 0L && + durationBucket(candidate.durationMs) != durationBucket(actual.durationMs) + ) { + return false + } + return mediaIdComparable || trackIdComparable + } + + fun normalize(value: String): String { + return value.trim().lowercase(Locale.ROOT).replace("\\s+".toRegex(), " ") + } + + private fun durationBucket(durationMs: Long): Long { + return if (durationMs <= 0L) 0L else durationMs / 2_000L + } +} + +class PredictionSourceResolver( + private val nowElapsedMs: () -> Long = { SystemClock.elapsedRealtime() }, + private val ttlMs: Long = DEFAULT_CANDIDATE_TTL_MS +) { + fun resolve( + snapshot: PredictionQueueSnapshot, + current: PredictiveMediaTrack, + direction: PredictionDirection, + source: PredictionSource + ): PredictionCandidate? { + if (snapshot.items.isEmpty()) return null + val activeIndex = snapshot.items.indexOfFirst { + snapshot.activeQueueItemId >= 0L && it.queueItemId == snapshot.activeQueueItemId + } + if (activeIndex >= 0 && + !PredictionIdentity.exactMatch(snapshot.items[activeIndex].track, current) + ) { + return null + } + val exactIndexes = snapshot.items.indices.filter { index -> + PredictionIdentity.exactMatch(snapshot.items[index].track, current) + } + val currentIndex = when { + activeIndex >= 0 -> activeIndex + exactIndexes.size == 1 -> exactIndexes.single() + else -> return null + } + val targetIndex = currentIndex + if (direction == PredictionDirection.NEXT) 1 else -1 + val target = snapshot.items.getOrNull(targetIndex) ?: return null + if (target.track.title.isBlank()) return null + val confidence = when { + activeIndex >= 0 && + target.queueItemId >= 0L && + target.mediaId.isNotBlank() && + target.track.hasCompleteMetadata -> PredictionConfidence.CONFIRMED + target.queueItemId >= 0L && + (target.mediaId.isNotBlank() || target.track.trackId.isNotBlank()) && + target.track.hasCompleteMetadata -> PredictionConfidence.STRONG + else -> PredictionConfidence.WEAK + } + val created = nowElapsedMs() + return PredictionCandidate( + candidateKey = PredictionIdentity.candidateKey( + target.track, + target.queueItemId + ), + source = source, + confidence = confidence, + direction = direction, + track = target.track, + queueItemId = target.queueItemId, + createdElapsedMs = created, + expiresElapsedMs = created + ttlMs + ) + } + + companion object { + const val DEFAULT_CANDIDATE_TTL_MS = 2 * 60_000L + } +} + +class PredictiveHotSet( + private val capacity: Int = 2, + private val nowElapsedMs: () -> Long = { SystemClock.elapsedRealtime() } +) { + init { + require(capacity in 1..2) { "predictive hot set supports at most two candidates" } + } + + private val entries = LinkedHashMap(capacity, 0.75f, true) + + @Synchronized + fun upsert(candidate: PredictionCandidate): PredictionCandidate? { + removeExpiredLocked(nowElapsedMs()) + val previous = entries[candidate.candidateKey] + entries[candidate.candidateKey] = if (previous == null) { + candidate + } else { + candidate.copy( + createdElapsedMs = previous.createdElapsedMs, + lyricsReady = previous.lyricsReady, + artworkPreviewReady = previous.artworkPreviewReady, + compressedLyricsReady = previous.compressedLyricsReady, + lyricFingerprint = previous.lyricFingerprint, + artworkId = previous.artworkId, + state = previous.state + ) + } + if (entries.size <= capacity) return null + val eldest = entries.entries.first() + entries.remove(eldest.key) + return eldest.value + } + + @Synchronized + fun get(candidateKey: String): PredictionCandidate? { + return entries[candidateKey] + } + + @Synchronized + fun replace(candidate: PredictionCandidate): Boolean { + if (!entries.containsKey(candidate.candidateKey)) return false + entries[candidate.candidateKey] = candidate + return true + } + + @Synchronized + fun remove(candidateKey: String): PredictionCandidate? = entries.remove(candidateKey) + + @Synchronized + fun clear(): List { + val removed = entries.values.toList() + entries.clear() + return removed + } + + @Synchronized + fun snapshot(): List { + removeExpiredLocked(nowElapsedMs()) + return entries.values.toList() + } + + @Synchronized + fun removeExpired(): List = removeExpiredLocked(nowElapsedMs()) + + private fun removeExpiredLocked(nowMs: Long): List { + val expired = entries.values.filter { it.expiresElapsedMs <= nowMs } + expired.forEach { entries.remove(it.candidateKey) } + return expired + } +} + +class PredictiveMediaCoordinator( + private val logger: (String) -> Unit, + private val prewarmer: (PredictiveMediaTrack, () -> Boolean) -> PredictionPrewarmResult, + private val promoter: (PredictiveMediaTrack) -> PredictionPromotionPayload?, + executor: ExecutorService? = null, + private val allowPrewarm: () -> Boolean = { true }, + private val enabled: Boolean = true, + private val nowElapsedMs: () -> Long = { SystemClock.elapsedRealtime() }, + private val hotSet: PredictiveHotSet = PredictiveHotSet(nowElapsedMs = nowElapsedMs) +) : Closeable { + private val ownedExecutor = executor == null + private val executor = executor ?: Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "PredictiveMediaPrewarm").apply { priority = Thread.MIN_PRIORITY } + } + private val inFlight = mutableSetOf() + + private val candidateCount = AtomicLong() + private val mediaSessionQueueCandidateCount = AtomicLong() + private val manualNextHintCount = AtomicLong() + private val manualPreviousHintCount = AtomicLong() + private val historyTransitionCandidateCount = AtomicLong() + private val selectedCount = AtomicLong() + private val rejectedCount = AtomicLong() + private val expiredCount = AtomicLong() + private val invalidatedCount = AtomicLong() + private val preloadStartCount = AtomicLong() + private val preloadHitCount = AtomicLong() + private val preloadMissCount = AtomicLong() + private val preloadSuccessCount = AtomicLong() + private val preloadFailedCount = AtomicLong() + private val cachePutCount = AtomicLong() + private val cacheEvictCount = AtomicLong() + private val applyHitCount = AtomicLong() + private val applyMissCount = AtomicLong() + private val identityMismatchCount = AtomicLong() + private val preloadCostTotalMs = AtomicLong() + private val preloadCostMaxMs = AtomicLong() + private val applyCostTotalMs = AtomicLong() + private val applyCostMaxMs = AtomicLong() + + fun onCandidate(candidate: PredictionCandidate) { + if (!enabled || candidate.confidence == PredictionConfidence.NONE) return + if (!candidate.track.hasCompleteMetadata) { + reject(candidate, "metadata_incomplete") + return + } + if (!allowPrewarm()) { + reject(candidate, "power_policy") + return + } + expiredCandidates() + val existing = hotSet.get(candidate.candidateKey) + if (existing != null) { + hotSet.upsert(candidate) + preloadHitCount.incrementAndGet() + if (existing.source != candidate.source || + existing.confidence != candidate.confidence || + existing.direction != candidate.direction + ) { + trace("predictionCandidateUpdated", candidate, "updated") + } + return + } + candidateCount.incrementAndGet() + selectedCount.incrementAndGet() + when (candidate.source) { + PredictionSource.MEDIA_SESSION_QUEUE -> mediaSessionQueueCandidateCount.incrementAndGet() + PredictionSource.MANUAL_NEXT_WITH_QUEUE -> manualNextHintCount.incrementAndGet() + PredictionSource.MANUAL_PREVIOUS_WITH_QUEUE -> manualPreviousHintCount.incrementAndGet() + PredictionSource.HISTORY_TRANSITION -> historyTransitionCandidateCount.incrementAndGet() + } + val queued = candidate.copy(state = PredictionState.PREWARM_QUEUED) + hotSet.upsert(queued)?.let { + cacheEvictCount.incrementAndGet() + trace("predictionInvalidated", it, "evicted", "capacity") + } + cachePutCount.incrementAndGet() + trace("predictionCandidateCreated", queued, "accepted") + synchronized(inFlight) { + if (!inFlight.add(queued.candidateKey)) { + preloadHitCount.incrementAndGet() + return + } + } + trace("predictionPrewarmQueued", queued, "queued") + executor.execute { prewarm(queued) } + } + + fun promote(actual: PredictiveMediaTrack): PredictionPromotionResult? { + expiredCandidates() + val startedAt = nowElapsedMs() + val candidates = hotSet.snapshot() + val promotableCandidates = candidates.filter { + it.confidence in setOf(PredictionConfidence.CONFIRMED, PredictionConfidence.STRONG) + } + val candidate = promotableCandidates.singleOrNull { + PredictionIdentity.exactMatch(it.track, actual) + } + if (candidate == null) { + if (candidates.isNotEmpty()) { + if (promotableCandidates.isNotEmpty()) { + identityMismatchCount.incrementAndGet() + } + rejectedCount.addAndGet(candidates.size.toLong()) + candidates.forEach { + val reason = if (it.confidence == PredictionConfidence.WEAK) { + "weak_local_only" + } else { + "identity_mismatch" + } + trace("predictionRejected", it, "rejected", reason) + } + hotSet.clear() + } + applyMissCount.incrementAndGet() + return null + } + trace("predictionPromotionAttempt", candidate, "started") + if (candidate.state != PredictionState.READY && + candidate.state != PredictionState.LYRICS_READY && + candidate.state != PredictionState.ARTWORK_READY + ) { + hotSet.remove(candidate.candidateKey) + rejectedCount.incrementAndGet() + applyMissCount.incrementAndGet() + trace("predictionRejected", candidate, "rejected", "prewarm_not_ready") + return null + } + val payload = promoter(actual) + if (payload == null || payload.lines.isEmpty()) { + hotSet.remove(candidate.candidateKey) + rejectedCount.incrementAndGet() + applyMissCount.incrementAndGet() + trace("predictionRejected", candidate, "rejected", "cache_promotion_miss") + return null + } + if (candidate.lyricFingerprint.isBlank() || + candidate.lyricFingerprint != payload.lyricFingerprint + ) { + hotSet.remove(candidate.candidateKey) + rejectedCount.incrementAndGet() + invalidatedCount.incrementAndGet() + applyMissCount.incrementAndGet() + trace("predictionInvalidated", candidate, "invalidated", "lyric_fingerprint_changed") + return null + } + val costMs = (nowElapsedMs() - startedAt).coerceAtLeast(0L) + val promoted = candidate.copy(state = PredictionState.PROMOTED) + hotSet.remove(candidate.candidateKey) + applyHitCount.incrementAndGet() + applyCostTotalMs.addAndGet(costMs) + updateMax(applyCostMaxMs, costMs) + trace("predictionPromoted", promoted, "success") + return PredictionPromotionResult(promoted, payload, costMs) + } + + fun invalidateAll(reason: String) { + hotSet.clear().forEach { + invalidatedCount.incrementAndGet() + trace("predictionInvalidated", it, "invalidated", reason) + } + } + + fun hotSetSnapshot(): List = hotSet.snapshot() + + fun metricsSnapshot(): PredictiveLyricsMetrics { + expiredCandidates() + return PredictiveLyricsMetrics( + candidateCount = candidateCount.get(), + mediaSessionQueueCandidateCount = mediaSessionQueueCandidateCount.get(), + manualNextHintCount = manualNextHintCount.get(), + manualPreviousHintCount = manualPreviousHintCount.get(), + historyTransitionCandidateCount = historyTransitionCandidateCount.get(), + selectedCount = selectedCount.get(), + rejectedCount = rejectedCount.get(), + expiredCount = expiredCount.get(), + invalidatedCount = invalidatedCount.get(), + preloadStartCount = preloadStartCount.get(), + preloadHitCount = preloadHitCount.get(), + preloadMissCount = preloadMissCount.get(), + preloadSuccessCount = preloadSuccessCount.get(), + preloadFailedCount = preloadFailedCount.get(), + cachePutCount = cachePutCount.get(), + cacheEvictCount = cacheEvictCount.get(), + applyHitCount = applyHitCount.get(), + applyMissCount = applyMissCount.get(), + identityMismatchCount = identityMismatchCount.get(), + preloadCostTotalMs = preloadCostTotalMs.get(), + preloadCostMaxMs = preloadCostMaxMs.get(), + applyCostTotalMs = applyCostTotalMs.get(), + applyCostMaxMs = applyCostMaxMs.get() + ) + } + + override fun close() { + invalidateAll("coordinator_closed") + synchronized(inFlight) { inFlight.clear() } + if (ownedExecutor) executor.shutdownNow() + } + + private fun prewarm(candidate: PredictionCandidate) { + val startedAt = nowElapsedMs() + preloadStartCount.incrementAndGet() + updateCandidate(candidate.candidateKey) { it.copy(state = PredictionState.PREWARMING) } + trace("predictionPrewarmStart", candidate, "started") + try { + val result = prewarmer(candidate.track) { + val current = hotSet.get(candidate.candidateKey) + current == null || current.expiresElapsedMs <= nowElapsedMs() + } + val costMs = (nowElapsedMs() - startedAt).coerceAtLeast(0L) + preloadCostTotalMs.addAndGet(costMs) + updateMax(preloadCostMaxMs, costMs) + val current = hotSet.get(candidate.candidateKey) ?: return + if (current.expiresElapsedMs <= nowElapsedMs()) { + hotSet.remove(candidate.candidateKey) + expiredCount.incrementAndGet() + trace( + "predictionExpired", + current.copy(state = PredictionState.EXPIRED), + "expired", + "prewarm_completed_after_ttl" + ) + return + } + if (result.lyricsReady || result.artworkPreviewReady) { + preloadHitCount.incrementAndGet() + preloadSuccessCount.incrementAndGet() + val state = when { + result.lyricsReady && result.artworkPreviewReady -> PredictionState.READY + result.lyricsReady -> PredictionState.LYRICS_READY + else -> PredictionState.ARTWORK_READY + } + val updated = updateCandidate(candidate.candidateKey) { + it.copy( + lyricFingerprint = result.lyricFingerprint, + artworkId = result.artworkId, + lyricsReady = result.lyricsReady, + artworkPreviewReady = result.artworkPreviewReady, + compressedLyricsReady = result.compressedLyricsReady, + state = state + ) + } + if (updated != null) { + if (result.lyricsReady) { + trace("predictionLyricsReady", updated, "ready") + } + if (result.artworkPreviewReady) { + trace("predictionArtworkReady", updated, "ready") + } + trace("predictionReady", updated, "ready") + } + } else { + preloadMissCount.incrementAndGet() + preloadFailedCount.incrementAndGet() + rejectedCount.incrementAndGet() + hotSet.remove(candidate.candidateKey) + trace( + "predictionRejected", + current.copy(state = PredictionState.REJECTED), + "cache_miss", + result.reason + ) + } + } catch (exception: Exception) { + preloadMissCount.incrementAndGet() + preloadFailedCount.incrementAndGet() + rejectedCount.incrementAndGet() + hotSet.remove(candidate.candidateKey) + trace( + "predictionRejected", + candidate.copy(state = PredictionState.REJECTED), + "failure", + "prewarm_exception" + ) + } finally { + synchronized(inFlight) { inFlight.remove(candidate.candidateKey) } + } + } + + private fun updateCandidate( + candidateKey: String, + transform: (PredictionCandidate) -> PredictionCandidate + ): PredictionCandidate? { + val current = hotSet.get(candidateKey) ?: return null + val updated = transform(current) + return if (hotSet.replace(updated)) updated else null + } + + private fun expiredCandidates() { + hotSet.removeExpired().forEach { + expiredCount.incrementAndGet() + trace("predictionExpired", it.copy(state = PredictionState.EXPIRED), "expired") + } + } + + private fun reject(candidate: PredictionCandidate, reason: String) { + rejectedCount.incrementAndGet() + trace("predictionRejected", candidate.copy(state = PredictionState.REJECTED), "rejected", reason) + } + + private fun trace( + stage: String, + candidate: PredictionCandidate, + result: String, + reason: String? = null + ) { + RealtimeTrace.record( + stage = stage, + trackId = candidate.track.identityDigest, + transferId = candidate.candidateKey, + payloadType = "${candidate.source.wireName}:${candidate.confidence.name}", + result = result, + reason = reason + ) + logger( + "[PredictiveMedia] stage=$stage candidateId=${candidate.candidateKey} " + + "identityDigest=${candidate.track.identityDigest} " + + "source=${candidate.source.wireName} confidence=${candidate.confidence.name} " + + "result=$result reason=${reason.orEmpty()}" + ) + } + + private fun updateMax(target: AtomicLong, value: Long) { + while (true) { + val current = target.get() + if (value <= current || target.compareAndSet(current, value)) return + } + } +} 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..3071673 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(), @@ -220,7 +243,10 @@ data class LyricsReadyGateSnapshot( val songKey: String = "", val generation: Long = 0L, val lineCount: Int = 0, - val reason: String = "" + val reason: String = "", + val wordTimingStatus: String = "NOT_READY", + val cacheSource: String = "UNKNOWN", + val failureReason: String = "" ) data class IncrementalLyricsReady( @@ -396,6 +422,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 +498,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 +540,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 +572,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 +819,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 +910,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/ReactiveMediaController.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/ReactiveMediaController.kt index 5c1b081..2a14ad7 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/ReactiveMediaController.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/ReactiveMediaController.kt @@ -1,6 +1,8 @@ package com.example.playeragent.media import android.os.SystemClock +import com.example.playeragent.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator enum class MediaEventType { MEDIA_METADATA_CHANGED, @@ -113,6 +115,38 @@ class ReactiveMediaController( albumArtReady = false lastCurrentLine = "" lastCurrentWordKey = "" + val handoffTrace = TrackHandoffTraceCoordinator.contextFor(stableTrackId) + RealtimeTrace.record( + stage = "mediaSessionTrackChanged", + monoMs = now, + trackId = stableTrackId, + generation = generation, + payloadType = "mediaSession", + result = "changed", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = now + ) + RealtimeTrace.record( + stage = "reactiveTrackIdentityAccepted", + monoMs = now, + trackId = stableTrackId, + generation = generation, + payloadType = "mediaSession", + result = "accepted", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name, + positionAnchorMs = now + ) + RealtimeTrace.record( + stage = "trackGenerationCreated", + monoMs = now, + trackId = stableTrackId, + generation = generation, + result = "created", + handoffId = handoffTrace?.handoffId, + triggerType = handoffTrace?.triggerType?.name + ) logger( "[Engine] event received type=${MediaEventType.TRACK_CHANGED} " + "trackId=$stableTrackId generation=$generation title=${title.take(48)}" @@ -157,6 +191,13 @@ class ReactiveMediaController( if (shouldScheduleLyrics) { lyricsScheduledGeneration = generation lyricsState = MediaPipelineState.SCHEDULED + RealtimeTrace.record( + stage = "lyricRequestQueued", + trackId = stableTrackId, + generation = generation, + payloadType = "lyrics", + result = "scheduled" + ) logger( "[Engine] task scheduled priority=${MediaTaskPriority.P3_LYRICS_PARSE.wireName} " + "trackId=$stableTrackId generation=$generation reason=metadata_stable" @@ -197,6 +238,13 @@ class ReactiveMediaController( lyricsInFlightTrackId = trackId lyricsInFlightGeneration = generation lyricsState = MediaPipelineState.RUNNING + RealtimeTrace.record( + stage = "lyricLoadStart", + trackId = trackId, + generation = generation, + payloadType = "lyrics", + result = "started" + ) logger("[Lyrics] parse start trackId=$trackId generation=$generation") return true } 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/PlayerNotificationListenerService.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerNotificationListenerService.kt index c041ca8..ad089ca 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerNotificationListenerService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerNotificationListenerService.kt @@ -1,7 +1,11 @@ package com.example.playeragent.service +import android.app.Notification import android.service.notification.StatusBarNotification import android.service.notification.NotificationListenerService +import com.example.playeragent.diagnostics.RealtimeTrace +import com.example.playeragent.diagnostics.TrackHandoffTraceCoordinator +import java.security.MessageDigest import java.util.concurrent.CopyOnWriteArrayList class PlayerNotificationListenerService : NotificationListenerService() { @@ -57,6 +61,20 @@ class PlayerNotificationListenerService : NotificationListenerService() { if (notification?.packageName != QQ_MUSIC_PACKAGE) { return } + if (event == "posted" && RealtimeTrace.enabled) { + val extras = notification.notification.extras + val title = extras.getCharSequence(Notification.EXTRA_TITLE) + ?.toString() + .orEmpty() + val artist = extras.getCharSequence(Notification.EXTRA_TEXT) + ?.toString() + .orEmpty() + val identityKey = MessageDigest.getInstance("SHA-256") + .digest("$title|$artist".toByteArray(Charsets.UTF_8)) + .take(8) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + TrackHandoffTraceCoordinator.observeNotificationMetadata(identityKey) + } qqMusicArtworkListeners.forEach { listener -> runCatching { listener(event) } } 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..0f4e1ec --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt @@ -0,0 +1,222 @@ +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 trackIdentityAlwaysUsesRealtimeQueueDuringLongMediaTransfer() { + assertFalse( + StatusMessageDeliveryPolicy.canUseLatestInterleavedSlot("trackInfo") + ) + assertFalse( + StatusMessageDeliveryPolicy.canUseLatestInterleavedSlot("playbackState") + ) + assertFalse( + StatusMessageDeliveryPolicy.canUseLatestInterleavedSlot("currentWord") + ) + assertTrue( + StatusMessageDeliveryPolicy.canUseLatestInterleavedSlot("volumeState") + ) + assertEquals( + BleNotifyQueue.Priority.P0_REALTIME, + BleNotifyQueue.priorityFor("trackInfo") + ) + } + + @Test + fun postControlBroadcastIncludesMediaOnlyAfterIdentityChanges() { + assertFalse( + PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = "NEXT", + trackIdBeforeControl = "track-a", + observedTrackId = "track-a" + ) + ) + assertTrue( + PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = "NEXT", + trackIdBeforeControl = "track-a", + observedTrackId = "track-b" + ) + ) + assertTrue( + PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = "PREVIOUS", + trackIdBeforeControl = "", + observedTrackId = "track-b" + ) + ) + assertFalse( + PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = "PLAY_PAUSE", + trackIdBeforeControl = "track-a", + observedTrackId = "track-b" + ) + ) + assertFalse( + PostControlBroadcastPolicy.shouldIncludeTrackMedia( + command = "NEXT", + trackIdBeforeControl = "track-a", + observedTrackId = "" + ) + ) + } + + @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..95c9b39 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt @@ -0,0 +1,66 @@ +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(15L, profile.artworkDelayMs) + + profile.recordFailure(BleLinkProfile.PayloadKind.BINARY_ARTWORK) + assertEquals(20L, profile.artworkDelayMs) + repeat(20) { + profile.recordSuccess( + BleLinkProfile.PayloadKind.BINARY_ARTWORK, + callbackRttMs = 20 + ) + } + assertEquals(19L, 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(15L, profile.artworkDelayMs) + assertEquals(0L, profile.successCount) + assertEquals(0L, profile.failureCount) + } + + @Test + fun sustainedFastArtworkCallbacksNeverRemoveControlHeadroom() { + val profile = BleLinkProfile(initialMtu = 185) + repeat(200) { + profile.recordSuccess( + BleLinkProfile.PayloadKind.BINARY_ARTWORK, + callbackRttMs = 1 + ) + } + assertEquals(15L, profile.artworkDelayMs) + } +} 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..4334c40 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,184 @@ 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 commandResponseQuietWindowIsPerDeviceAndDoesNotExtendActiveBurst() { + val windows = CommandResponseQuietWindows(25L) + + assertEquals(1_025L, windows.reserve("ios", 1_000L)) + assertEquals(1_025L, windows.reserve("ios", 1_010L)) + assertEquals( + 15L, + windows.remainingDelayMs("ios", "albumArtBinaryChunk", 1_010L) + ) + assertEquals( + 0L, + windows.remainingDelayMs("android", "albumArtBinaryChunk", 1_010L) + ) + assertEquals(1_050L, windows.reserve("ios", 1_025L)) + } + + @Test + fun commandResponseQuietWindowNeverDelaysRealtimeState() { + val windows = CommandResponseQuietWindows(25L) + windows.reserve("ios", 1_000L) + + listOf( + "trackInfo", + "trackInfoChunk", + "playbackState", + "currentWord", + "lyricWindowChunk" + ).forEach { + assertEquals(0L, windows.remainingDelayMs("ios", it, 1_010L)) + } + listOf( + "albumArtBinaryChunk", + "albumArtChunk", + "fullLyricsBinaryChunk", + "fullLyricsChunk", + "lyricSecondaryPart", + "logChunk", + "mediaFieldDumpChunk", + "historyPayloadChunk" + ).forEach { + assertTrue(BleNotifyQueue.isCommandResponseSensitivePacket(it)) + assertEquals(15L, windows.remainingDelayMs("ios", it, 1_010L)) + } + } + + @Test + fun commandResponseQuietWindowClearsPerDeviceAndGlobally() { + val windows = CommandResponseQuietWindows(25L) + windows.reserve("ios", 1_000L) + windows.reserve("android", 1_000L) + + windows.remove("ios") + assertEquals( + 0L, + windows.remainingDelayMs("ios", "albumArtBinaryChunk", 1_010L) + ) + assertEquals( + 15L, + windows.remainingDelayMs("android", "albumArtBinaryChunk", 1_010L) + ) + + windows.clear() + assertEquals( + 0L, + windows.remainingDelayMs("android", "albumArtBinaryChunk", 1_010L) + ) + } + + @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..bfed728 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleV3SessionCoordinatorTest.kt @@ -0,0 +1,117 @@ +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 or + BleV3Features.MEDIA_CACHE_VALIDATION_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 statusMetadataIsOnlyAddedWhenNegotiated() { + val coordinator = BleV3SessionCoordinator("1234abcd") + val value = JSONObject().put("type", "mediaLoadState") + + val withoutMetadata = coordinator.decorateIfEnabled("A", value, enabled = false) + assertFalse(withoutMetadata.has("sid")) + assertFalse(withoutMetadata.has("es")) + + val withMetadata = coordinator.decorateIfEnabled("A", value, enabled = true) + assertEquals("1234abcd", withMetadata.getString("sid")) + assertEquals(1L, withMetadata.getLong("es")) + } + + @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/CurrentLyricFastPublishPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentLyricFastPublishPolicyTest.kt new file mode 100644 index 0000000..487a57b --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentLyricFastPublishPolicyTest.kt @@ -0,0 +1,63 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.LyricsReadyGateSnapshot +import com.example.playeragent.media.LyricsReadyState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CurrentLyricFastPublishPolicyTest { + @Test + fun exactGenerationAndProtocolIdentityPublishImmediately() { + assertTrue( + CurrentLyricFastPublishPolicy.shouldPublish( + ready = ready(), + currentTrackId = "123456789abc", + currentGeneration = 9L, + currentLyric = "line", + hasSubscribers = true + ) + ) + } + + @Test + fun staleIdentityGenerationAndEmptyContentAreRejected() { + assertFalse( + CurrentLyricFastPublishPolicy.shouldPublish( + ready = ready(), + currentTrackId = "ffffffffffff", + currentGeneration = 9L, + currentLyric = "line", + hasSubscribers = true + ) + ) + assertFalse( + CurrentLyricFastPublishPolicy.shouldPublish( + ready = ready(), + currentTrackId = "123456789abc", + currentGeneration = 8L, + currentLyric = "line", + hasSubscribers = true + ) + ) + assertFalse( + CurrentLyricFastPublishPolicy.shouldPublish( + ready = ready(), + currentTrackId = "123456789abc", + currentGeneration = 9L, + currentLyric = "", + hasSubscribers = true + ) + ) + } + + private fun ready(): LyricsReadyGateSnapshot { + return LyricsReadyGateSnapshot( + state = LyricsReadyState.READY, + lyricsReady = true, + trackId = "123456789abc-full-digest", + generation = 9L, + lineCount = 10 + ) + } +} 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..8670d29 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt @@ -0,0 +1,219 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.CurrentWordState +import com.example.playeragent.media.CurrentWordEligibilitySnapshot +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 eligibleSnapshotPublishesImmediatelyAfterGenerationObservation() { + var sent = 0 + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + sent += 1 + true + }, + expectedGeneration = { 7L }, + currentWordEligibility = { + eligible(word(positionMs = 1_000L, wordIndex = 1)) + }, + elapsedRealtime = { 1_000L } + ) + + engine.observeGeneration(7L) + assertTrue(engine.pushCurrentWord() != null) + assertEquals(1, sent) + } + + @Test + fun observingSameGenerationPreservesOrderingSequence() { + 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 }, + currentWordEligibility = { eligible(state) }, + elapsedRealtime = { nowMs } + ) + + engine.observeGeneration(7L) + assertTrue(engine.pushCurrentWord() != null) + nowMs += 100L + state = word(positionMs = 1_500L, wordIndex = 2) + engine.observeGeneration(7L) + assertTrue(engine.pushCurrentWord() != null) + assertEquals(1L, payloads[0].getLong("seq")) + assertEquals(2L, payloads[1].getLong("seq")) + } + + @Test + fun eventBarrierStillRejectsSnapshotFromOlderGeneration() { + var sent = 0 + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + sent += 1 + true + }, + expectedGeneration = { 8L }, + currentWordEligibility = { + eligible(word(positionMs = 1_000L, wordIndex = 1)) + }, + elapsedRealtime = { 1_000L } + ) + + engine.observeGeneration(8L) + assertNull(engine.pushCurrentWord()) + assertEquals(0, sent) + } + + @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 }, + currentWordEligibility = { eligible(state) }, + elapsedRealtime = { nowMs } + ) + + 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 }, + currentWordEligibility = { eligible(state) }, + elapsedRealtime = { nowMs } + ) + + 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 }, + currentWordEligibility = { + eligible( + word(positionMs = 1_000L, wordIndex = 1) + .copy(sampleElapsedMs = 88_000L) + ) + }, + elapsedRealtime = { nowMs } + ) + + assertTrue(engine.pushCurrentWord() != null) + + assertEquals(88_000L, payloads.single().getLong("sampleMono")) + assertTrue(payloads.single().has("timestamp")) + assertTrue(payloads.single().toString().toByteArray().size <= 182) + } + + @Test + fun introWaitNeverPublishesFutureWordSnapshot() { + var sent = 0 + val futureWord = word(positionMs = 1_000L, wordIndex = 0) + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + sent += 1 + true + }, + expectedGeneration = { 7L }, + currentWordEligibility = { + eligible(futureWord).copy( + eligible = false, + reason = "INTRO_WAIT", + state = futureWord + ) + }, + elapsedRealtime = { 2_000L } + ) + + engine.observeGeneration(7L) + assertNull(engine.pushCurrentWord()) + assertEquals(0, sent) + } + + 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 + ) + } + + private fun eligible(state: CurrentWordState): CurrentWordEligibilitySnapshot { + return CurrentWordEligibilitySnapshot( + eligible = true, + reason = "ELIGIBLE", + trackId = state.trackId, + generation = state.trackGeneration, + positionMs = state.positionMs, + positionAnchorMs = state.sampleElapsedMs, + lineIndex = state.lineIndex, + wordTimingStatus = "AVAILABLE", + state = state + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/FullLyricsCacheValidationTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/FullLyricsCacheValidationTest.kt new file mode 100644 index 0000000..df87c5d --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/FullLyricsCacheValidationTest.kt @@ -0,0 +1,145 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.LyricManager +import com.example.playeragent.media.QrcLyricWord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FullLyricsCacheValidationTest { + + @Test + fun exactDescriptorIsAHit() { + val descriptor = FullLyricsCacheValidation.describe("Song", "Artist", lines()) + assertEquals( + FullLyricsCacheValidationDecision.HIT, + FullLyricsCacheValidation.decide( + capabilityEnabled = true, + request = descriptor.asRequest(), + actual = descriptor + ) + ) + assertEquals(2, descriptor.lineCount) + assertEquals(1, descriptor.translationLineCount) + assertEquals(1, descriptor.romanizationLineCount) + assertEquals(24, descriptor.fingerprint.length) + } + + @Test + fun capabilityAndCompleteRequestAreRequired() { + val descriptor = FullLyricsCacheValidation.describe("Song", "Artist", lines()) + assertEquals( + FullLyricsCacheValidationDecision.CAPABILITY_DISABLED, + FullLyricsCacheValidation.decide(false, descriptor.asRequest(), descriptor) + ) + assertEquals( + FullLyricsCacheValidationDecision.REQUEST_MISSING, + FullLyricsCacheValidation.decide(true, null, descriptor) + ) + } + + @Test + fun everyValidationDimensionCanForceColdTransfer() { + val descriptor = FullLyricsCacheValidation.describe("Song", "Artist", lines()) + assertEquals( + FullLyricsCacheValidationDecision.FINGERPRINT_MISMATCH, + FullLyricsCacheValidation.decide( + true, + descriptor.asRequest().copy(fingerprint = "00".repeat(12)), + descriptor + ) + ) + assertEquals( + FullLyricsCacheValidationDecision.SCHEMA_MISMATCH, + FullLyricsCacheValidation.decide( + true, + descriptor.asRequest().copy(schemaVersion = 99), + descriptor + ) + ) + assertEquals( + FullLyricsCacheValidationDecision.LINE_COUNT_MISMATCH, + FullLyricsCacheValidation.decide( + true, + descriptor.asRequest().copy(lineCount = 1), + descriptor + ) + ) + assertEquals( + FullLyricsCacheValidationDecision.SECONDARY_COUNT_MISMATCH, + FullLyricsCacheValidation.decide( + true, + descriptor.asRequest().copy(translationLineCount = 0), + descriptor + ) + ) + } + + @Test + fun secondaryAndWordChangesInvalidateFingerprint() { + val baseline = FullLyricsCacheValidation.describe("Song", "Artist", lines()) + val translationChanged = FullLyricsCacheValidation.describe( + "Song", + "Artist", + lines().mapIndexed { index, line -> + if (index == 0) line.copy(translation = "changed") else line + } + ) + val wordChanged = FullLyricsCacheValidation.describe( + "Song", + "Artist", + lines().mapIndexed { index, line -> + if (index == 0) { + line.copy(words = line.words.map { it.copy(text = "changed") }) + } else { + line + } + } + ) + assertNotEquals(baseline.fingerprint, translationChanged.fingerprint) + assertNotEquals(baseline.fingerprint, wordChanged.fingerprint) + assertTrue(FullLyricsCacheValidation.estimatedPayloadBytes(lines()) > 0) + } + + @Test + fun fingerprintMatchesIosFixedVector() { + val descriptor = FullLyricsCacheValidation.describe( + "Song", + "Artist", + listOf( + LyricManager.LyricLine( + timeMs = 0, + durationMs = 1_000, + text = "line", + translation = "translation" + ) + ) + ) + assertEquals("3e1c8ce388901c3763a5b1c3", descriptor.fingerprint) + } + + private fun lines(): List = listOf( + LyricManager.LyricLine( + timeMs = 0, + durationMs = 1_000, + text = "line one", + translation = "translation", + words = listOf(QrcLyricWord(0, 1_000, "line one")) + ), + LyricManager.LyricLine( + timeMs = 1_000, + durationMs = 1_000, + text = "line two", + romanization = "romanization" + ) + ) + + private fun FullLyricsCacheDescriptor.asRequest() = FullLyricsCacheValidationRequest( + fingerprint = fingerprint, + schemaVersion = schemaVersion, + lineCount = lineCount, + translationLineCount = translationLineCount, + romanizationLineCount = romanizationLineCount + ) +} 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/ble/MediaWireGenerationPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaWireGenerationPolicyTest.kt new file mode 100644 index 0000000..485b562 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaWireGenerationPolicyTest.kt @@ -0,0 +1,49 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaWireGenerationPolicyTest { + @Test + fun `matching runtime track owns wire generation after service restart`() { + assertEquals( + 107L, + MediaWireGenerationPolicy.resolve( + protocolTrackId = "0123456789ab", + runtimeTrackId = "0123456789abcdef01234567", + runtimeGeneration = 107L, + fallbackGeneration = 105L + ) + ) + } + + @Test + fun `different runtime track cannot donate its generation`() { + assertEquals( + 105L, + MediaWireGenerationPolicy.resolve( + protocolTrackId = "0123456789ab", + runtimeTrackId = "fedcba987654321001234567", + runtimeGeneration = 107L, + fallbackGeneration = 105L + ) + ) + } + + @Test + fun `missing or uninitialized runtime generation uses task fallback`() { + assertEquals( + 5L, + MediaWireGenerationPolicy.resolve("0123456789ab", null, null, 5L) + ) + assertEquals( + 5L, + MediaWireGenerationPolicy.resolve( + protocolTrackId = "0123456789ab", + runtimeTrackId = "0123456789ab", + runtimeGeneration = 0L, + fallbackGeneration = 5L + ) + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/diagnostics/RealtimeTraceTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/diagnostics/RealtimeTraceTest.kt new file mode 100644 index 0000000..6a79d37 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/diagnostics/RealtimeTraceTest.kt @@ -0,0 +1,220 @@ +package com.example.playeragent.diagnostics + +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 RealtimeTraceTest { + @Test + fun ringBufferKeepsNewestEventsInOrder() { + var now = 10L + val buffer = RealtimeTraceBuffer(capacity = 3) { now++ } + + buffer.append(stage = "one") + buffer.append(stage = "two") + buffer.append(stage = "three") + buffer.append(stage = "four") + + assertEquals(listOf("two", "three", "four"), buffer.snapshot().map { it.stage }) + assertEquals(listOf(2L, 3L, 4L), buffer.snapshot().map { it.sequence }) + } + + @Test + fun monotonicClockNeverRegresses() { + val samples = ArrayDeque(listOf(100L, 90L, 120L)) + val buffer = RealtimeTraceBuffer(capacity = 4) { samples.removeFirst() } + + buffer.append(stage = "one") + buffer.append(stage = "two") + buffer.append(stage = "three") + + assertEquals(listOf(100L, 100L, 120L), buffer.snapshot().map { it.monoMs }) + } + + @Test + fun traceLineHasStableFieldsAndNoPayloadBody() { + val event = RealtimeTraceBuffer(capacity = 1) { 55L }.append( + stage = "notifyEnqueued", + trackId = "track 1", + payloadType = "currentWord", + result = "ok" + ) + val line = event.logLine() + + assertTrue(line.startsWith("[RealtimeTrace] side=sony stage=notifyEnqueued monoMs=55")) + assertTrue(line.contains("trackId=track_1")) + assertTrue(line.contains("payloadType=currentWord")) + assertFalse(line.contains("lyrics=")) + assertFalse(line.contains("bytes=")) + } + + @Test + fun correlationFieldsKeepCommandTrackGenerationAndTransferIdentity() { + val event = RealtimeTraceBuffer(capacity = 1) { 77L }.append( + stage = "fullLyricsSendStart", + commandSeq = 42L, + commandType = "GET_FULL_LYRICS", + trackId = "track-safe-id", + generation = 9L, + transferId = "transfer-3" + ) + + assertEquals(42L, event.commandSeq) + assertEquals("track-safe-id", event.trackId) + assertEquals(9L, event.generation) + assertEquals("transfer-3", event.transferId) + } + + @Test + fun phaseFourFieldsArePrivacySafeAndRoundTrip() { + val event = RealtimeTraceBuffer(capacity = 1) { 88L }.append( + stage = "currentWordEligible", + trackId = "track-safe-id", + generation = 3L, + handoffId = "command-42", + triggerType = "IOS_NEXT", + positionAnchorMs = 700L, + lineIndex = 2, + wordTimingStatus = "AVAILABLE", + cacheSource = "PARSED", + failureReason = null + ) + + assertEquals("command-42", event.handoffId) + assertEquals("IOS_NEXT", event.triggerType) + assertTrue(event.logLine().contains("wordTimingStatus=AVAILABLE")) + assertTrue(event.logLine().length < 300) + assertFalse(event.logLine().contains(" commandSeq=-")) + assertFalse(event.logLine().contains("wordText=")) + } + + @Test + fun commandHandoffIsPromotedByMatchingMediaObservation() { + TrackHandoffTraceCoordinator.clear() + val command = TrackHandoffTraceCoordinator.observeCommand( + commandSeq = 7L, + commandType = "NEXT", + nowMs = 100L + ) + val accepted = TrackHandoffTraceCoordinator.observeMediaSessionMetadata( + trackId = "track-a", + positionAnchorMs = 120L, + nowMs = 120L + ) + + assertEquals("command-7", command?.handoffId) + assertEquals(command?.handoffId, accepted?.handoffId) + assertEquals(TrackHandoffTriggerType.IOS_NEXT, accepted?.triggerType) + assertEquals(accepted, TrackHandoffTraceCoordinator.contextFor("track-a")) + TrackHandoffTraceCoordinator.clear() + } + + @Test + fun changedNotificationCreatesBoundedAutomaticHandoff() { + TrackHandoffTraceCoordinator.clear() + TrackHandoffTraceCoordinator.observeNotificationMetadata( + identityKey = "notification-a", + nowMs = 200L + ) + val accepted = TrackHandoffTraceCoordinator.observeMediaSessionMetadata( + trackId = "track-b", + positionAnchorMs = 220L, + nowMs = 220L + ) + + assertTrue(accepted?.handoffId?.startsWith("sony-200-") == true) + assertEquals(TrackHandoffTriggerType.UNKNOWN, accepted?.triggerType) + TrackHandoffTraceCoordinator.clear() + } + + @Test + fun notificationFollowingMediaObservationKeepsCommandHandoff() { + val original = RealtimeTrace.enabled + try { + RealtimeTrace.clear() + RealtimeTrace.enabled = true + TrackHandoffTraceCoordinator.clear() + TrackHandoffTraceCoordinator.observeCommand( + commandSeq = 11L, + commandType = "NEXT", + nowMs = 1_000L + ) + TrackHandoffTraceCoordinator.observeMediaSessionMetadata( + trackId = "track-c", + positionAnchorMs = 1_050L, + nowMs = 1_050L + ) + TrackHandoffTraceCoordinator.observeNotificationMetadata( + identityKey = "notification-c", + nowMs = 1_400L + ) + + val notification = RealtimeTrace.snapshot().last { + it.stage == "notificationMetadataObserved" + } + assertEquals("command-11", notification.handoffId) + assertEquals("IOS_NEXT", notification.triggerType) + } finally { + TrackHandoffTraceCoordinator.clear() + RealtimeTrace.enabled = original + RealtimeTrace.clear() + } + } + + @Test + fun repeatedNotificationFollowingMediaObservationKeepsCommandHandoff() { + val original = RealtimeTrace.enabled + try { + RealtimeTrace.clear() + RealtimeTrace.enabled = true + TrackHandoffTraceCoordinator.clear() + TrackHandoffTraceCoordinator.observeCommand( + commandSeq = 12L, + commandType = "NEXT", + nowMs = 1_000L + ) + TrackHandoffTraceCoordinator.observeMediaSessionMetadata( + trackId = "track-d", + positionAnchorMs = 1_050L, + nowMs = 1_050L + ) + TrackHandoffTraceCoordinator.observeNotificationMetadata( + identityKey = "notification-d", + nowMs = 1_100L + ) + TrackHandoffTraceCoordinator.observeNotificationMetadata( + identityKey = "notification-d", + nowMs = 1_150L + ) + + val notifications = RealtimeTrace.snapshot().filter { + it.stage == "notificationMetadataObserved" + } + assertEquals(2, notifications.size) + notifications.forEach { + assertEquals("command-12", it.handoffId) + assertEquals("IOS_NEXT", it.triggerType) + } + } finally { + TrackHandoffTraceCoordinator.clear() + RealtimeTrace.enabled = original + RealtimeTrace.clear() + } + } + + @Test + fun disabledTraceDoesNotStoreEvents() { + val original = RealtimeTrace.enabled + try { + RealtimeTrace.clear() + RealtimeTrace.enabled = false + assertNull(RealtimeTrace.record(stage = "commandReceived")) + assertTrue(RealtimeTrace.snapshot().isEmpty()) + } finally { + RealtimeTrace.enabled = original + RealtimeTrace.clear() + } + } +} 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/AlbumArtIdentityPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtIdentityPolicyTest.kt new file mode 100644 index 0000000..fb0b87d --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtIdentityPolicyTest.kt @@ -0,0 +1,51 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumArtIdentityPolicyTest { + @Test + fun exactTrackIdentityIsAccepted() { + assertTrue( + AlbumArtIdentityPolicy.matches( + expectedTitle = "Song", + expectedArtist = "Artist", + actualTitle = "song", + actualArtist = "Artist / Guest" + ) + ) + } + + @Test + fun staleOrUnverifiableIdentityIsRejected() { + assertFalse( + AlbumArtIdentityPolicy.matches( + expectedTitle = "New Song", + expectedArtist = "Artist", + actualTitle = "Old Song", + actualArtist = "Artist" + ) + ) + assertFalse( + AlbumArtIdentityPolicy.matches( + expectedTitle = "New Song", + expectedArtist = "Artist", + actualTitle = "", + actualArtist = "" + ) + ) + } + + @Test + fun unspecifiedExpectedIdentityKeepsLegacyBehavior() { + assertTrue( + AlbumArtIdentityPolicy.matches( + expectedTitle = "", + expectedArtist = "", + actualTitle = "", + actualArtist = "" + ) + ) + } +} 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..b45ac23 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PlayerAgentExecutionHubTest.kt @@ -0,0 +1,36 @@ +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(5) + listOf( + hub.realtime, + hub.foregroundIO, + hub.scheduled, + hub.currentWord, + hub.maintenance + ).forEach { executor -> + executor.execute { + synchronized(names) { names += Thread.currentThread().name } + latch.countDown() + } + } + assertTrue(latch.await(2, TimeUnit.SECONDS)) + assertEquals(5, names.size) + assertTrue(names.all { it.startsWith("HubTest-") }) + assertTrue(names.any { it == "HubTest-CurrentWord" }) + + hub.close() + assertTrue(hub.isShutdown()) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/PredictiveMediaCoordinatorTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PredictiveMediaCoordinatorTest.kt new file mode 100644 index 0000000..41be4be --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/PredictiveMediaCoordinatorTest.kt @@ -0,0 +1,406 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit + +class PredictiveMediaCoordinatorTest { + private var nowMs = 1_000L + private val resolver = PredictionSourceResolver(nowElapsedMs = { nowMs }, ttlMs = 500L) + + @Test + fun activeQueueIdentityCreatesConfirmedNextAndPreviousCandidates() { + val snapshot = queueSnapshot() + + val next = resolver.resolve( + snapshot, + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MEDIA_SESSION_QUEUE + ) + val previous = resolver.resolve( + snapshot, + currentTrack(), + PredictionDirection.PREVIOUS, + PredictionSource.MEDIA_SESSION_QUEUE + ) + + assertEquals(PredictionConfidence.CONFIRMED, next?.confidence) + assertEquals("media-next", next?.track?.mediaId) + assertEquals(PredictionConfidence.CONFIRMED, previous?.confidence) + assertEquals("media-previous", previous?.track?.mediaId) + } + + @Test + fun uniqueStableCurrentMappingWithoutActiveIdIsStrong() { + val snapshot = queueSnapshot().copy(activeQueueItemId = -1L) + + val candidate = resolver.resolve( + snapshot, + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MANUAL_NEXT_WITH_QUEUE + ) + + assertEquals(PredictionConfidence.STRONG, candidate?.confidence) + } + + @Test + fun ambiguousOrMismatchedCurrentQueueIdentityIsRejected() { + val mismatch = queueSnapshot().copy( + items = queueSnapshot().items.map { item -> + if (item.queueItemId == 20L) { + item.copy(track = item.track.copy(title = "Different")) + } else { + item + } + } + ) + + assertNull( + resolver.resolve( + mismatch, + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MEDIA_SESSION_QUEUE + ) + ) + } + + @Test + fun targetWithoutStableIdIsWeakAndLocalOnly() { + val items = queueSnapshot().items.toMutableList() + items[2] = items[2].copy( + queueItemId = -1L, + mediaId = "", + track = items[2].track.copy(trackId = "", mediaId = "") + ) + + val candidate = resolver.resolve( + queueSnapshot().copy(items = items), + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MEDIA_SESSION_QUEUE + ) + + assertEquals(PredictionConfidence.WEAK, candidate?.confidence) + } + + @Test + fun hotSetCapacityIsTwoAndUsesLruEviction() { + val hotSet = PredictiveHotSet(capacity = 2, nowElapsedMs = { nowMs }) + val first = candidate("first", track("one", "media-one")) + val second = candidate("second", track("two", "media-two")) + val third = candidate("third", track("three", "media-three")) + + assertNull(hotSet.upsert(first)) + assertNull(hotSet.upsert(second)) + assertEquals(first, hotSet.get("first")) + assertEquals(second, hotSet.upsert(third)) + assertEquals(listOf("first", "third"), hotSet.snapshot().map { it.candidateKey }) + } + + @Test + fun hotSetExpiresOnMonotonicClock() { + val hotSet = PredictiveHotSet(capacity = 2, nowElapsedMs = { nowMs }) + hotSet.upsert(candidate("expiring", track("one", "media-one"))) + + nowMs = 1_501L + + assertTrue(hotSet.snapshot().isEmpty()) + } + + @Test + fun duplicateCandidateKeepsReadyPrewarmMetadata() { + val hotSet = PredictiveHotSet(capacity = 2, nowElapsedMs = { nowMs }) + val ready = candidate("same", track("one", "media-one")).copy( + lyricsReady = true, + lyricFingerprint = "fingerprint", + state = PredictionState.LYRICS_READY + ) + hotSet.upsert(ready) + + hotSet.upsert(candidate("same", track("one", "media-one"))) + + assertTrue(hotSet.get("same")?.lyricsReady == true) + assertEquals("fingerprint", hotSet.get("same")?.lyricFingerprint) + } + + @Test + fun exactReadyCandidatePromotesExistingCachePayload() { + val coordinator = coordinator() + val predicted = queueCandidate(track("next", "media-next")) + coordinator.onCandidate(predicted) + + val result = coordinator.promote(track("next", "media-next")) + + assertEquals(1, result?.payload?.lines?.size) + assertEquals(1L, coordinator.metricsSnapshot().applyHitCount) + coordinator.close() + } + + @Test + fun identityMismatchRejectsCandidateAndFallsBackCold() { + val coordinator = coordinator() + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + val result = coordinator.promote(track("other", "media-other")) + + assertNull(result) + assertEquals(1L, coordinator.metricsSnapshot().identityMismatchCount) + assertTrue(coordinator.hotSetSnapshot().isEmpty()) + coordinator.close() + } + + @Test + fun weakCandidateCanPrewarmButNeverPromotes() { + val coordinator = coordinator() + coordinator.onCandidate( + queueCandidate(track("next", "media-next")).copy( + confidence = PredictionConfidence.WEAK + ) + ) + + assertNull(coordinator.promote(track("next", "media-next"))) + assertEquals(1L, coordinator.metricsSnapshot().preloadSuccessCount) + assertEquals(0L, coordinator.metricsSnapshot().identityMismatchCount) + coordinator.close() + } + + @Test + fun powerPolicyRejectsPrewarmWithoutRunningLoader() { + var loaderCalled = false + val coordinator = PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + allowPrewarm = { false }, + prewarmer = { _, _ -> + loaderCalled = true + PredictionPrewarmResult(lyricsReady = true) + }, + promoter = { null }, + nowElapsedMs = { nowMs } + ) + + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + assertFalse(loaderCalled) + assertEquals(1L, coordinator.metricsSnapshot().rejectedCount) + coordinator.close() + } + + @Test + fun candidateStoresMetadataOnlyAndPromotionReadsPayloadOnDemand() { + var promotionReads = 0 + val coordinator = coordinator(onPromotion = { promotionReads += 1 }) + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + assertTrue(coordinator.hotSetSnapshot().single().lyricsReady) + assertEquals(0, promotionReads) + coordinator.promote(track("next", "media-next")) + assertEquals(1, promotionReads) + coordinator.close() + } + + @Test + fun sameTargetDeduplicatesAcrossObservationSources() { + val first = resolver.resolve( + queueSnapshot(), + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MEDIA_SESSION_QUEUE + ) + val second = resolver.resolve( + queueSnapshot(), + currentTrack(), + PredictionDirection.NEXT, + PredictionSource.MANUAL_NEXT_WITH_QUEUE + ) + + assertEquals(first?.candidateKey, second?.candidateKey) + } + + @Test + fun repeatedObservationDoesNotRepeatPrewarmWork() { + var prewarmCalls = 0 + val coordinator = PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + prewarmer = { _, _ -> + prewarmCalls += 1 + PredictionPrewarmResult( + lyricsReady = true, + lyricFingerprint = "fingerprint", + source = "QRC" + ) + }, + promoter = { null }, + nowElapsedMs = { nowMs } + ) + val candidate = queueCandidate(track("next", "media-next")) + + coordinator.onCandidate(candidate) + coordinator.onCandidate(candidate.copy(source = PredictionSource.MANUAL_NEXT_WITH_QUEUE)) + + assertEquals(1, prewarmCalls) + assertEquals(1L, coordinator.metricsSnapshot().candidateCount) + coordinator.close() + } + + @Test + fun fingerprintChangeInvalidatesPromotionAndPreservesColdFallback() { + val coordinator = PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + prewarmer = { _, _ -> + PredictionPrewarmResult( + lyricsReady = true, + lyricFingerprint = "old-fingerprint", + source = "QRC" + ) + }, + promoter = { + PredictionPromotionPayload( + lines = listOf(LyricManager.LyricLine(0L, "line")), + source = "QRC", + lyricFingerprint = "new-fingerprint" + ) + }, + nowElapsedMs = { nowMs } + ) + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + assertNull(coordinator.promote(track("next", "media-next"))) + assertEquals(1L, coordinator.metricsSnapshot().applyMissCount) + assertTrue(coordinator.hotSetSnapshot().isEmpty()) + coordinator.close() + } + + @Test + fun candidateExpiringDuringPrewarmIsCancelledAndCounted() { + val coordinator = PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + prewarmer = { _, _ -> + nowMs = 2_000L + PredictionPrewarmResult( + lyricsReady = true, + lyricFingerprint = "fingerprint", + source = "QRC" + ) + }, + promoter = { null }, + nowElapsedMs = { nowMs } + ) + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + assertTrue(coordinator.hotSetSnapshot().isEmpty()) + assertEquals(1L, coordinator.metricsSnapshot().expiredCount) + coordinator.close() + } + + @Test + fun cacheMissRemovesCandidateWithoutBlockingColdPath() { + val coordinator = PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + prewarmer = { _, _ -> PredictionPrewarmResult(lyricsReady = false, reason = "miss") }, + promoter = { null }, + nowElapsedMs = { nowMs } + ) + coordinator.onCandidate(queueCandidate(track("next", "media-next"))) + + assertTrue(coordinator.hotSetSnapshot().isEmpty()) + assertNull(coordinator.promote(track("next", "media-next"))) + assertEquals(1L, coordinator.metricsSnapshot().preloadFailedCount) + coordinator.close() + } + + private fun coordinator(onPromotion: () -> Unit = {}): PredictiveMediaCoordinator { + return PredictiveMediaCoordinator( + logger = {}, + executor = DirectExecutorService(), + prewarmer = { _, cancelled -> + assertFalse(cancelled()) + PredictionPrewarmResult( + lyricsReady = true, + lyricFingerprint = "fingerprint", + lineCount = 1, + hasWordTiming = true, + source = "QRC" + ) + }, + promoter = { + onPromotion() + PredictionPromotionPayload( + lines = listOf(LyricManager.LyricLine(0L, "line")), + source = "QRC", + lyricFingerprint = "fingerprint" + ) + }, + nowElapsedMs = { nowMs } + ) + } + + private fun queueSnapshot(): PredictionQueueSnapshot { + return PredictionQueueSnapshot( + activeQueueItemId = 20L, + items = listOf( + PredictionQueueItem(10L, "media-previous", track("previous", "media-previous")), + PredictionQueueItem(20L, "media-current", currentTrack()), + PredictionQueueItem(30L, "media-next", track("next", "media-next")) + ) + ) + } + + private fun currentTrack(): PredictiveMediaTrack = track("current", "media-current") + + private fun track(name: String, mediaId: String): PredictiveMediaTrack { + return PredictiveMediaTrack( + trackId = "track-$name", + songKey = "$name|artist|album", + title = name, + artist = "artist", + album = "album", + durationMs = 180_000L, + mediaId = mediaId + ) + } + + private fun queueCandidate(track: PredictiveMediaTrack): PredictionCandidate { + return candidate("candidate-${track.mediaId}", track) + } + + private fun candidate(key: String, track: PredictiveMediaTrack): PredictionCandidate { + return PredictionCandidate( + candidateKey = key, + source = PredictionSource.MEDIA_SESSION_QUEUE, + confidence = PredictionConfidence.CONFIRMED, + direction = PredictionDirection.NEXT, + track = track, + queueItemId = 30L, + createdElapsedMs = nowMs, + expiresElapsedMs = nowMs + 500L + ) + } + + private class DirectExecutorService : AbstractExecutorService() { + private var shutdown = false + + override fun execute(command: Runnable) = command.run() + override fun shutdown() { + shutdown = true + } + override fun shutdownNow(): MutableList { + shutdown = true + return mutableListOf() + } + override fun isShutdown(): Boolean = shutdown + override fun isTerminated(): Boolean = shutdown + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = shutdown + } +} 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/media/RuntimeLyricLineProjectionPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/RuntimeLyricLineProjectionPolicyTest.kt new file mode 100644 index 0000000..ff18214 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/RuntimeLyricLineProjectionPolicyTest.kt @@ -0,0 +1,28 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RuntimeLyricLineProjectionPolicyTest { + @Test + fun selectsLatestNonEmptyLineAtProjectedPosition() { + val lines = listOf( + RuntimeLyricLine(0L, "first"), + RuntimeLyricLine(1_000L, ""), + RuntimeLyricLine(2_000L, "second") + ) + + assertEquals( + "first", + RuntimeLyricLineProjectionPolicy.currentLineText(lines, 1_500L) + ) + assertEquals( + "second", + RuntimeLyricLineProjectionPolicy.currentLineText(lines, 2_500L) + ) + assertEquals( + "", + RuntimeLyricLineProjectionPolicy.currentLineText(lines, -1L) + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/TrackIdentityFastLanePolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/TrackIdentityFastLanePolicyTest.kt new file mode 100644 index 0000000..58f6a09 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/TrackIdentityFastLanePolicyTest.kt @@ -0,0 +1,43 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrackIdentityFastLanePolicyTest { + @Test + fun confirmedDistinctIdentityUsesFastLane() { + assertTrue( + TrackIdentityFastLanePolicy.shouldPublish( + trackChanged = true, + trackId = "track-2", + title = "Song" + ) + ) + } + + @Test + fun refreshAndIncompleteMetadataStayOnNormalPath() { + assertFalse( + TrackIdentityFastLanePolicy.shouldPublish( + trackChanged = false, + trackId = "track-2", + title = "Song" + ) + ) + assertFalse( + TrackIdentityFastLanePolicy.shouldPublish( + trackChanged = true, + trackId = "", + title = "Song" + ) + ) + assertFalse( + TrackIdentityFastLanePolicy.shouldPublish( + trackChanged = true, + trackId = "track-2", + title = "" + ) + ) + } +} 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..00bf512 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ # MusicBleController +> **V4 milestone, not full SLO acceptance:** the V4 work is being accepted into +> `master` with known track-switch/artwork latency tails, incomplete first-word +> coverage, and an unresolved long-run Sony Bluetooth-stack failure. +> See [V4 acceptance status and open metrics](docs/V4_ACCEPTANCE_STATUS.md). +

English · 简体中文

- 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 +22,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 +36,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 +73,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 +95,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 +115,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 +136,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 +172,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..4af09bc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,13 +1,17 @@ # MusicBleController +> **V4 阶段性交付,不代表性能验收完成:** 本次将 V4 开发成果提前接入 `master`, +> 保留切歌与冷封面长尾、首字样本不足、Sony 蓝牙栈长时间稳定性失败等未闭环项。 +> 具体目标、实测数值与后续验收见 [V4 验收状态与未达标指标](docs/V4_ACCEPTANCE_STATUS.md)。 +

English · 简体中文

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

@@ -17,11 +21,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 +61,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 +77,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 +118,7 @@ Sony 端是播放状态、歌词和封面的权威数据源,iPhone 端是控 - 控制响应、播放状态和当前词可以抢占完整歌词、高清封面等大任务; - 播放中和暂停中采用不同的静默探测周期,避免正常状态下频繁重连; - 新协议使用轻量 PING/PONG,旧 Sony 版本自动回退到原有状态请求; +- iPhone 在连接后主动进行低 RTT 多样本校时,播放位置按消息采样时刻自动追平,严重积压的旧歌词不会覆盖当前进度; - 连续探测失败后才执行硬重连,明确断开事件仍会立即处理。 ## 兼容性 @@ -114,6 +128,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 +165,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 +194,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 +222,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 +240,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/build.gradle b/build.gradle index dfe0978..612a878 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,14 @@ buildscript { repositories { + google() + mavenCentral() maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } - google() - mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:8.5.2' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24' } -} \ No newline at end of file +} diff --git a/docs/ALBUM_ART_ARCHITECTURE.md b/docs/ALBUM_ART_ARCHITECTURE.md index 6f30de0..24a6a60 100644 --- a/docs/ALBUM_ART_ARCHITECTURE.md +++ b/docs/ALBUM_ART_ARCHITECTURE.md @@ -4,20 +4,24 @@ ## 模块职责 -- Sony `AlbumArtTestManager`:从 MediaMetadata 和通知 largeIcon 中探测当前可用封面。当前真实链路常见来源是 QQMusic notification largeIcon。 -- Sony `BleGattServerManager`:处理 `ALBUM_ART_REQUEST`,压缩 preview/HQ/fallback,发送 `albumArtOffer` 和 binary chunk。 +- Sony `AlbumArtTestManager`:从 MediaMetadata 和通知 largeIcon 中探测当前可用封面。PlayerAgent UI 请求当前封面时必须用当前 title/artist 验证来源身份;当前真实链路常见来源是 QQMusic notification largeIcon。 +- Sony `MainActivity`:监听既有 QQ 音乐通知事件,事件到达后在后台执行精确身份读取;用 generation+songKey 防止迟到结果覆盖当前歌曲。 +- 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 +35,14 @@ 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 和失败,可只重试当前歌曲。 +11. Sony PlayerAgent 本机 UI 在歌曲变化时先进入 LOADING;MediaMetadata/Notification 的 title/artist 与当前身份不匹配时拒绝。通知更新触发精确重读,不增加轮询;同一当前歌曲的临时精确源缺失不会把已经 READY 的图替换为占位图。 +12. V4 第三阶段的预测模型可以记录“已有精确 artwork cache”这一 readiness,但当前 QQ 音乐不暴露高置信下一/上一首 identity,因此没有执行候选图片预编码、跨端 Preview/HQ 预取或猜测式图片晋升。正式封面仍只按当前 trackId/generation 发布。 +13. TrackInfo、PlaybackState、Preview/HQ A1 和 CurrentWord 使用同一个经过 identity 复核的 wire generation;Sony 捕获一次传输 generation 后贯穿 Start/Chunk/End,迟到旧 generation 不能覆盖 iOS 或 Sony PlayerAgent 当前封面。 +14. Android notify callback 不能等同 L2CAP 已排空。封面 binary 使用 15ms 最小 pacing;command write 到达时同步预留 response quiet window,避免频繁切歌与 HQ 并发时写响应被 L2CAP hold queue 挤掉。该节流不作用于 JSON、歌词或 CurrentWord。 ## 关键状态 @@ -46,7 +56,10 @@ - 缓存最长可展示 30 天;通知来源 30 分钟后复验,稳定 metadata 来源 24 小时后复验。小于 300px 只触发后台刷新,不清空可用图。 - Sony preview 目标约 112px、Q40~50、最多 1.8KB/12 包;HQ Q70~80、最多 8KB且保持最低优先级。 - Sony 已编码 JPEG 使用 40 项、16MB、30 分钟内存 LRU,避免同歌曲重复压缩。 +- Predictive Hot Set 只保存 artworkId/readiness 元数据,不复制 Bitmap/JPEG;当前真机 prefetch bytes/packets 均为 0。 +- iOS 解码缓存 key 包含 `artworkId + quality + targetPixelSize`,避免同一 JPEG 在主界面和历史列表重复全尺寸解码。 - A1 header 和 quality code 不变;start/end 可带 `transferId`、`generation`、`crc32`,新端支持局部重传,旧端直接忽略新字段。 +- 真机 100 次快速切歌中 command write callback 313/313,response failed、write timeout、L2CAP congestion 均为 0;Preview/HQ p95 707.2ms。 ## 不允许随便修改的点 @@ -55,6 +68,7 @@ - 不要让 HQ 请求在 fullLyrics、lyricSecondary、remoteLog、mediaFieldDump 长任务期间抢占。 - 不要覆盖 HQ 原图;enhanced 必须是独立缓存。 - 不要把封面接收逻辑塞回 `BLETestManager`。 +- 不要在 SwiftUI `body` 或主线程同步 `Data(contentsOf:)`、校验 JPEG 或创建全尺寸图片。 ## 常见问题排查入口 @@ -64,11 +78,15 @@ - `[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]` - `[AlbumArt][BLE] failed` - `[BleNotifyQueue] job start type=albumArt` + - `[PlayerUI] artwork source event=...` + - `[AlbumArtSource] metadata rejected reason=identity_mismatch` + - `[PlayerUI] album art retained reason=transient_exact_source_miss` - 自动验收: `./tools/ios-smoke-tests/ios_album_art_flow_test.sh ` @@ -78,3 +96,5 @@ - 必须查看 `report.json` 的 `albumArtFlow`,Sony 不在线时允许 Optional WARN/SKIPPED;若真实在线应看到 PASS。 - 改 Xcode target 或新增 Swift 文件:full smoke。 - 改 Sony 发送压缩或 binary 协议:Android build + iPhone 真机封面链路。 + +V4 实时性观测按 `trackId + generation + transferId` 记录 offer、cache hit、Preview/HQ 编码、队列、传输、iOS 解码与 publish。Trace 不包含图片正文,不改变 A1、CRC、质量、超时或调度策略;指标定义见 [REALTIME_SLO_V4.md](/Volumes/雷电/project/MusicBleController/docs/REALTIME_SLO_V4.md)。 diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index c14c061..ac31cdd 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。 @@ -56,33 +60,109 @@ - `albumArtUnavailable` - `fullLyricsStart` / `fullLyricsChunk` / `fullLyricsEnd` / `fullLyricsUnavailable` - `fullLyricsBinaryStart` / `0xA2` binary chunk / `fullLyricsBinaryEnd` +- `fullLyricsCacheMetadata` / `fullLyricsNotModified`(仅协商 `mediaCacheValidationV1`) - `lyricWindowStart` / `lyricWindowChunk` / `lyricWindowEnd` - `clientCapabilitiesAck`、`pong` - `lyricSecondaryStart` / `lyricSecondaryPart` / `lyricSecondaryEnd` - `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`、bit3=`mediaCacheValidationV1`。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`。未协商时任何状态(包括 `mediaLoadState` 和 structured error)都不得附加这两个字段,避免小 MTU payload 超限。`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`。 + +协商 `mediaCacheValidationV1` 后,iOS 可在原 `GET_FULL_LYRICS` 中携带 `fp/sv/n/tc/rc`(fingerprint、schema、主歌词/翻译/罗马音行数)以及紧凑 `id/p/w/f`。Sony 必须同时验证当前 trackId、generation 和实际内容描述符;命中返回 `fullLyricsNotModified` 并跳过 A2/legacy 正文,未命中返回 `fullLyricsCacheMetadata` 后继续原传输。旧端不声明 bit3,完全保持原行为。A2 header、命令名和 FullLyrics 正文 schema 均不改变。 + +V4 第三阶段没有实现 `prefetchManifest`、`PREFETCH_MEDIA` 或 purpose=prefetch 的 A1/A2 会话。真机预测来源审计显示 QQ 音乐 queue 与 activeQueueItemId 均不可用,跨端预取 Gate 被关闭;详见 [PREDICTIVE_MEDIA_ENGINE_V4.md](/Volumes/雷电/project/MusicBleController/docs/PREDICTIVE_MEDIA_ENGINE_V4.md)。 + +## 自动歌词时钟同步 + +- `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;封面 binary 初始/最小均为 15ms、最大 30ms。 +- notify 失败或 callback RTT 超过 120ms 时增加 5ms;连续 20 次成功且 EWMA RTT 小于 60ms 时减少 1ms。 +- Android notify callback 可能早于 L2CAP 实际排空,封面不能因快速 callback 下降到 15ms 以下。command characteristic 收到 write 时同步预留响应 quiet window,GATT write response 优先于后台封面继续入队。 - `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。 +- 频繁切歌只要 notify 与 write callback 仍健康,就不得把 UI 连接状态降为“正在连接”;媒体 generation 变化不等于 BLE connection generation 变化。 +- Sony 端 45 秒无业务流量只会按设备发送 `{"type":"link"}` 小探针,不再重建共享 GATT。探针的真实 notify 失败累计到阈值后,仅隔离失败地址,其他控制器继续工作。 +- 恢复连接的 smoke 参数通过一次性 App 容器标记传递,避免 CoreBluetooth 后台恢复抢先启动导致测试序列丢失。 + +## V4 Cold-Path Handoff 与 ATT response 边界 + +- `handoffId` 和 `triggerType` 只存在于两端本地 Realtime Trace。远程 NEXT/PREVIOUS 复用 command seq;Sony 本地转换使用进程内 ID。BLE JSON、UUID、A1/A2 header 和旧客户端解析均不改变。 +- TrackInfo、PlaybackState、CurrentWord 属于 P0,全部进入普通实时队列并在包边界抢占 FullLyrics、HQ、history 和 diagnostics。latest-interleaved 槽只保留可替换的 volumeState,避免长任务恰好结束时 TrackInfo 停留在无人再读取的插播槽。 +- Sony 对合法 JSON command 保持即时 `sendResponse()`;response 后的 25ms quiet window 仍按设备限制新的 response-sensitive 媒体包,多个 burst 命令不能无限延长首个 deadline。命令业务继续在原 executor 异步执行。 +- 曾验证过把 ATT response 延迟到同设备 notify callback 边界的实验实现,但安装后出现 iOS 和 Android Controller 媒体/控制回归,因此已由 `1154397` 完整回退。当前代码没有 response gate、pending response 队列或新增 ATT 状态机。 +- iOS 会合并普通的延迟 `GET_PLAYBACK_STATE`:新控制优先于旧 fallback,新 fallback 替换旧 fallback;前台验证与 Health probe 的请求序列受保护。该行为只改变本地调度,不改变命令名称或 JSON。 +- Sony 的 NEXT/PREVIOUS 控制后兜底广播先读取权威 PlaybackState;`trackId` 未变化时不附带 TrackInfo/AlbumArt,真实身份变化仍由既有 MediaSession/AutoPush 路径发布。该行为不增加 notify 类型,也不改变旧客户端解析。 +- 旧的 90 次转换和 100 次压力数据只作为历史 Trace 证据。回退后的当前策略已重新完成 NEXT、PREVIOUS、Sony dispatch-next 各 30 次及 100 次快速压力,四项严格报告均 PASS;双控制器矩阵按当前开发优先级延期,不记为 PASS。 + +实现、边界和完整性能数据见 [COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。 ## 不允许随便修改的点 diff --git a/docs/COLD_PATH_HANDOFF_V4.md b/docs/COLD_PATH_HANDOFF_V4.md new file mode 100644 index 0000000..66c7a17 --- /dev/null +++ b/docs/COLD_PATH_HANDOFF_V4.md @@ -0,0 +1,239 @@ +# MusicBleController V4 Cold-Path Handoff + +本文记录第四阶段的 Track Handoff、current lyric 与 CurrentWord 冷路径优化。所有结果来自同一台 iPhone 与同一台 Sony 的单调时钟 Trace;日志只保存在本机临时目录,仓库不保存歌曲名、设备标识或原始媒体内容。 + +## 1. 第三阶段基线 + +第三阶段最终 Cold Path 固定基线为:command → Track publish p95 632.6ms、Track → current lyric p95 1210.1ms、Track → Preview/HQ p95 707.2ms、iOS decode p95 1ms、iOS publish p95 18.5ms。CurrentWord 只有一个可关联样本,不能作为统计基线。 + +第四阶段增强 Trace 后,在修改前另外完成 NEXT、PREVIOUS 和 Sony MediaSession dispatch-next 各 30 次。它证明了两个新的主要问题:lyrics ready 后仍可能等下一轮 PlaybackState 才发布 current line;CurrentWord 的 generation 切换固定等待和共享 scheduler 使首包常在 eligible 后再等 0.9~1.4 秒。 + +## 2. Track Handoff 状态机 + +Handoff 的观测状态为: + +```text +COMMAND_PENDING / SONY_LOCAL_PENDING +→ METADATA_OBSERVED +→ IDENTITY_CANDIDATE +→ IDENTITY_ACCEPTED + GENERATION_CREATED +→ PLAYBACK_READY +→ P0_QUEUE +→ NOTIFY_CALLBACK +→ IOS_RECEIVED +→ IOS_DECODED +→ IOS_TRACK_ACCEPTED + PLAYBACK_PUBLISHED +→ NOW_PLAYING_CONSUMED +``` + +状态机只做诊断关联,不参与媒体选择。MediaSession 的正式 trackId、runtime generation 和 iOS generation fence 仍决定内容能否发布。新 Track 被接受后,旧 generation 的歌词、CurrentWord 和封面仍会被拒绝。 + +## 3. Handoff ID + +iOS NEXT/PREVIOUS 使用现有 command sequence 生成 `command-`;无 iOS 命令的 Sony 本地转换使用进程内有界递增 ID。pending context 的 TTL 为 15 秒,Track Identity 接受后转成 active context;连接状态重置或服务关闭时清理。 + +Handoff ID 只写两端本地 Trace,没有加入 TrackInfo、PlaybackState、A1/A2 header 或任何 BLE payload。报告器优先按 Handoff ID 关联;自动场景无法共享本地 ID 时,才使用精确 trackId/generation 和可信 Clock Sync 对齐,不用墙上时间补齐。 + +## 4. 完整 Trace 事件 + +Track 链路覆盖 command intent/enqueue/write/callback、Sony receive/validate/dispatch、notification/MediaSession metadata、identity candidate/accepted、generation、playback read/ready/enqueue/dequeue/notify callback,以及 iOS receive/decode/accept/publish/UI consume。 + +歌词链路覆盖 lookup requested/start、runtime/parsed cache hit/miss、load、ready、current line selected/enqueued/sent/received/accepted/published。CurrentWord 覆盖 eligibility evaluated/not eligible/eligible、scheduler create/scheduled、first boundary、immediate/boundary enqueue、send、receive、accept/reject/publish。 + +新增关联字段为 `handoffId/triggerType/positionAnchorMs/lineIndex/wordTimingStatus/cacheSource/failureReason`。Trace 不记录标题、歌词正文、图片或文件路径。 + +报告器在一次连接中发现 Clock Sync sample count 回退时,将整轮跨设备时钟标记为不可信;不能用重连后重新收敛的最终 offset 解释重连前事件。App 启动和重连产生的同 Track `refreshed` 事件不再冒充新 Handoff。 + +## 5. Track Identity 来源 + +MediaSession metadata 是正式身份来源;QQ 音乐通知 metadata 仅作为辅助观测和本机 UI 封面刷新触发。通知中的身份只写隐私安全 digest,不直接成为 iOS 可见歌曲。 + +正式测试显示 MediaSession callback 存在,metadata accepted → Sony Track accepted p95 为 98.5ms(NEXT)、91.1ms(PREVIOUS)和 113.1ms(Sony dispatch-next)。远程控制的主要剩余时间在播放器收到 dispatch 后到暴露新 metadata:NEXT p95 773.3ms、PREVIOUS p95 781.4ms。这不是 iOS JSON decode、SwiftUI publish、Sony playback read 或 BLE queue 的固定等待。 + +## 6. Track Fast Lane + +没有新增协议类型。TrackInfo、PlaybackState 和 CurrentWord 继续使用现有 JSON status,并进入既有 P0 实时队列。第四阶段先修正了 PlaybackState/CurrentWord 的队列边界;本轮真机 Trace 又发现 TrackInfo 在存在长任务时仍会被发送层放入 latest-only interleaved 槽。该槽只在长任务包边界被读取,长任务恰好结束时可能让歌曲身份滞留,而新的无身份 PlaybackState 已先到达 iOS。 + +`80222a5` 将 TrackInfo 恢复到普通 P0 队列,只保留可替换的 volumeState 使用 latest-only 槽。TrackInfo 因此可以在长任务下一包边界抢占,并先于随后排队的 PlaybackState 建立正式 trackId/generation。两轮 NEXT 真机 Trace 中,修改后的 TrackInfo 全部为 `queued`,不再出现 `queued_interleaved`;正常 TrackInfo queue wait 的 p95 为 5~18ms。此修复消除了偶发身份滞留边界,但没有把它误报为约 1 秒远程切歌长尾的主因。 + +第一份身份不等待 FullLyrics、translation、romanization、Preview、HQ、history 或 diagnostics。iOS 仍在正式 trackId/generation 被接受后发布 metadata,缓存快照不计作新歌发布。 + +后续真机 Trace 发现,`PlaybackStateReader` 在已经从权威 MediaSession 观察到新 metadata 后,仍会先执行 capability、预测历史、歌词状态和诊断工作,最后才建立 runtime generation 并发布 TrackInfo。新的 authority-acceptance fast lane 只在 BLE 主 Reader 中开启:当 `ReactiveMediaController` 确认 trackId 变化、trackId 和 title 均非空时,先原子创建正式 generation,发布不含旧歌词的轻量身份状态,再继续原有歌词和诊断路径。 + +Sony 本机 UI 与 iOS 在同一回调中接收新身份。Sony 先进入新曲的封面 LOADING,并复用 title/artist 精确校验和 generation/songKey fence 请求新图,不再继续显示上一首封面。其他用于历史、诊断和临时查询的 Reader 默认不开启 fast lane,避免重复 generation 或重复发布。 + +## 7. 有界 Handoff Probe + +本阶段没有增加 Handoff Probe。真机 Trace 已观察到 MediaSession metadata callback,且 dispatch → metadata 长尾发生在 QQ 音乐/MediaSession 真正切换之前;增加 0/80/160/320ms 读取只会重复读旧身份,不能缩短播放器内部切歌时间,还会引入额外 Binder 和媒体读取竞争。 + +因此新增持续轮询为 0,现有 AutoPush 周期也没有调快。若未来设备出现“播放器已经切换但 callback 丢失”的独立证据,再单独打开固定次数、可取消的 Probe Gate。 + +本轮曾试验 120/200/280/360ms 的命令触发轻量 Probe。该轮 NEXT 30 次总 p95 为 734.3ms,且有 1 个 UI consume Trace 不完整;其 MTU 为 185,而保留实现的正常轮次为 515,不能把差异单独归因于 Probe 或 Handler 排队。由于没有证明稳定收益,该实现已在提交前完整删除,最终代码仍使用通知事件与原 220ms 冷路径 fallback。 + +## 8. Current Lyric 首包路径 + +`LyricManager` 在 runtime/parsed cache、load 和 ready 处输出精确 Trace。QRC ready 时先原子更新 `CurrentTrackRuntimeCache`,随后 `LyricsReadyGateSnapshot` 触发 `publishLyricsReadyPlaybackIfCurrent`:只有当前 trackId、generation、非空 current line 和订阅状态全部匹配,才立即发送既有 PlaybackState。 + +路径由“lyrics ready → 等下一轮 PlaybackState”收敛为“lyrics ready → current line select → P0 enqueue → iOS publish”。FullLyrics 校验、A2 正文、translation 和 romanization 仍在后台,不阻塞 current line;QRC Triple DES、parser、index schema、fuzzy/negative/alias/recovery 均未修改。 + +## 9. CurrentWord Eligibility + +Eligibility 必须同时满足:正式 trackId/generation、当前歌词属于该 Track、播放中、可信的单调 position anchor、当前行有 word timing、当前 position 已进入有效词。分类结果包含 `ELIGIBLE`、`INTRO_WAIT`、`LINE_ONLY/NO_WORD_TIMING`、`LYRIC_NOT_READY`、`PAUSED`、`CLOCK_UNTRUSTED` 和 `NO_ACTIVE_LINE`。 + +`trackAcceptedToFirstWord` 只统计立即或接近立即 eligible 样本;音乐前奏、逐行歌词、无歌词、暂停或不可信 anchor 不计作系统失败。报告仍保留全部 rejected/missing 计数,不能挑最快样本构造 p95。 + +## 10. INTRO_WAIT 与 LINE_ONLY + +播放位置在第一行/第一个词之前,或刚越过一个词而尚未进入下个边界时标记 `INTRO_WAIT`,并计算下一个真实词/行边界。只有逐行歌词时标记 `LINE_ONLY`,不发送伪造的 `line=0, word=0`。 + +第一行查找和 current line 选择使用有序时间轴的二分/边界策略;空行向前寻找最近有效行。position anchor 会按播放速度投影,暂停时不推进。Seek 的明显倒退会建立新本地时间线,小抖动仍由 position fence 拒绝。 + +## 11. CurrentWord Scheduler + +CurrentWord 使用独立单线程 scheduled executor,不再与其他 scheduled maintenance 共享执行器。新 generation 到来时保留 sequence/generation/position fence、取消旧 CurrentWord notify,并用匹配的 `lyricsReady` 回调作为启动屏障;250ms 只作为 bounded re-evaluation fallback,不再固定等待 450ms 后才启动。 + +若当前 position 已位于有效词内部,立即发送 snapshot;否则按真实下一个词/行边界调度。暂停时 scheduler suspended,恢复和 seek 时重新计算。边界漂移校正仍最多 500ms,但不再作为切歌首包 holdoff。每个 generation 的 sequence 从新时间线单调增长,同词去重和 40ms 最小发送间隔保留。 + +## 12. Queue Priority + +P0 仍包括控制状态、TrackInfo、PlaybackState 和 CurrentWord;P1 lyricWindow/正式 Preview;P2 FullLyrics/secondary;P3 HQ/history/diagnostics。P0 可以在大任务包边界抢占;CurrentWord 只保留最新有效状态,但在途 notify 不被替换。 + +正式样本的 `wordEligible → schedulerCreated` p95 为 0ms,`schedulerCreated → firstEnqueue` p95 为 0.5~1ms,`firstEnqueue → send` p95 为 0ms,证明 Sony scheduler 和 enqueue 已不是剩余长尾。PREVIOUS 与 Sony dispatch-next 的约 250ms 目标偏差发生在 send 后的 BLE/CoreBluetooth 接收或 iOS accept 段,不应继续盲调 scheduler。 + +## 13. Command Response 与控制后媒体发布 + +第三阶段保留的 25ms per-device quiet window 继续只限制 response 后的新 response-sensitive 媒体包。合法 JSON command 当前直接调用 `sendResponse()`,命令业务仍在原 executor 异步执行;没有延迟 response gate,也没有 pending response closure。 + +提交 `3596804` 曾把 ATT response 串行化到同设备 notify callback 边界。它在一轮压力 Trace 中消除了 write timeout,但安装后出现 iOS 和 Android Controller 同时拿不到部分歌词、进度和图片的产品回归,因此由 `1154397` 完整回退。旧轮次的 `commandResponseDeferred/Released/Rejected` 数据只能解释实验,不再描述当前协议路径。 + +当前跟进优化避免再改 ATT 生命周期:iOS 在串行写队列中合并过期的普通 `GET_PLAYBACK_STATE`,NEXT/PREVIOUS fallback 从 500ms 调整为 1 秒,并在正式新 trackId 到达时取消;前台验证和 Health probe 不参与丢弃。Sony 的 220ms 控制后 fallback 保留轻量 PlaybackState 兜底;新身份、TrackInfo 和精确封面由权威 MediaSession 确认后的 fast lane 独立发布。这样避免用旧歌曲媒体抢占新身份、歌词、CurrentWord 和封面。 + +新增 Trace 为 iOS `commandRefreshSuperseded` 与 Sony `postControlMediaDeferred/postControlMediaPublished`。该策略先通过纯策略单测、Sony unit/assemble 和 Swift 源码解析,本轮再由下述 iPhone + Sony 正常与压力场景完成真机复核。 + +本轮进一步让 iOS Clock Sync bootstrap/refresh PING 在 FullLyrics 或图片 receiving/decoding 期间事件化延后,媒体空闲后有界恢复;没有新增 Timer。NEXT 30 次中 `clockSyncProbeDeferred/Resumed` 成对出现,command timeout 与 hard reconnect 均为 0。随后的 NEXT、PREVIOUS、Sony dispatch-next 各 30 次以及 100 次快速压力均完成,严格报告均为 PASS(第一轮 NEXT 曾因一条已存在但未关联 handoff ID 的重复 Notification Trace 判为 29/30;`3f706b4` 修复纯观测关联后复跑为 30/30)。 + +## 14. AlbumArt 15ms Pacing + +AlbumArt binary 15ms 最小 pacing 保持不变,没有为 Track 或 CurrentWord 调低。修改后正式 NEXT、PREVIOUS、Sony dispatch-next 的 Preview/HQ p95 分别为 68.3ms、79.4ms、66.5ms,正确性计数 wrong artwork=0;因此没有证据支持牺牲 command callback 可靠性继续压缩图片间隔。 + +100 次 650ms 压力用于 stale/队列正确性,不作为正常图片 SLO:其并发与播放器合并导致 Preview p95 1490.4ms,但仍低于 HQ 2500ms,且没有旧图被接受。 + +## 15. 双控制器行为 + +实现继续按设备隔离 capability、MTU、transfer、quiet window、notify failure 和 cleanup;当前代码没有 response gate。 + +按当前产品优先级,本轮先闭环 iPhone 控制端与 Sony PlayerAgent。Android Controller 与双控制器并发矩阵延期,标记 `SKIPPED(当前范围)`,不能用单元测试代替,也不作为这轮 iOS + Sony 优化的完成声明。 + +## 16. 修改前数据 + +第四阶段增强 Trace 的修改前 p95: + +| metric | NEXT | PREVIOUS | Sony dispatch-next | +|---|---:|---:|---:| +| command/Track T0 → Track publish | 1153.8ms | 1084.3ms | 155.4ms | +| Track → current lyric | 1497.2ms | 1423.6ms | 692.9ms | +| word eligible → publish | 2023.5ms | 2098.5ms | 1053.6ms | +| immediately eligible Track → first word | 2052.5ms | 2180.2ms | 1225.5ms | +| Track → Preview/HQ | 1317.5ms | 1124.0ms | 203.0ms | +| scheduler created → first enqueue | 1340.5ms | 1370.2ms | 913.3ms | + +Sony dispatch-next 是 `adb shell cmd media_session dispatch next` 的自动等价场景,不是真实歌曲自然播放结束。 + +## 17. 修改后数据 + +回退前历史真机轮次的正常 30 次结果: + +| metric | NEXT p95 | PREVIOUS p95 | Sony dispatch-next p95 | target/result | +|---|---:|---:|---:|---| +| command/Track T0 → Track publish | 1025.9ms | 1005.2ms | 148.2ms | 远程 FAIL;本地等价 PASS | +| Track → current lyric | 115.2ms | 94.3ms | 107.8ms | PASS ≤500ms | +| word eligible → publish | 193.0ms | 282.4ms | 279.1ms | NEXT PASS;其余 MISS ≤250ms | +| immediately eligible Track → first word | 172.2ms | 146.8ms | 225.4ms | PASS ≤500ms | +| Track → Preview/HQ | 68.3ms | 79.4ms | 66.5ms | PASS | +| iOS decode | 1.0ms | 1.0ms | 1.0ms | PASS | +| iOS publish | 17.0ms | 18.0ms | 17.0ms | PASS | + +相对增强 Trace 基线,current lyric p95 下降 84%~93%,立即 eligible 的第一词 p95 下降 82%~93%,word eligible → publish 下降 73%~90%。远程 command → Track 只下降约 7%~11%,仍不满足 350ms 目标;主要剩余段是 QQ 音乐/MediaSession 的 dispatch → metadata。 + +有效覆盖为 NEXT/PREVIOUS/Sony dispatch-next 各 30 个 eligible → publish 样本;立即 eligible Track → first word 分别为 25、25、23 个,低于提示词要求的每场景 30 个正式有效样本,必须如实保留为覆盖缺口。 + +100 次快速压力完成 100 个控制尝试、观察到 88 次转换;Clock Sync 因测试中跨重连 sample reset 被正确标记为不可信,所以跨设备耗时不生成。所有 8 个 CurrentWord reject 均为 `TRACK_MISMATCH`,9 个 Track Identity reject 为 generation conflict/stale generation,属于栅栏生效;stale accepted、duplicate control、hard reconnect、write timeout、L2CAP response failure 均为 0。 + +上述数字来自包含后来已回退 response gate 的构建,只用于保留 CurrentWord、歌词和队列优化的历史证据,不等价于 `1154397` 之后当前代码的最终产品结果。下面单独列出回退后当前提交的真机复测,不混用两组数据。 + +当前提交 `3f706b4` 的 iPhone + Sony 正常场景复测如下: + +| metric | NEXT p95 | PREVIOUS p95 | Sony dispatch-next p95 | 结论 | +|---|---:|---:|---:|---| +| command/Track T0 → Track publish | 1026.0ms | 640.0ms | 145.0ms | 远程仍 FAIL ≤350ms;Sony 等价场景 PASS | +| metadata observed → Track accepted | 301.0ms | 91.1ms | 103.4ms | NEXT 有长尾,PREVIOUS/等价稳定 | +| playback ready → notify start | 129.0ms | 19.5ms | 72.4ms | P0 队列无系统性阻塞 | +| lyric ready → current line enqueue | 69.5ms | 21.8ms | 43.8ms | 三场景均 PASS ≤100ms | +| Track → current lyric(原始) | 3843.9ms | 3948.9ms | 112.4ms | NEXT/PREVIOUS 被 QQ 音乐 QRC 缺失/等待样本拉长 | +| word eligible → publish | 407.7ms(2) | 445.2ms(2) | 81.1ms(2) | 样本不足,不能形成 p95 验收 | +| Track → Preview | 1992.1ms | 171.8ms | 138.1ms | NEXT 冷传输 FAIL;其余 PASS | +| Track → HQ | 3543.7ms | 171.8ms | 138.1ms | NEXT 冷传输 FAIL;其余 PASS | + +括号为 CurrentWord 有效样本数。当前播放列表绝大多数歌曲只有逐行歌词,本轮每场景都只有 2 个立即 eligible 样本,因此必须保留为覆盖缺口。报告器新增 `lyricMissingReasons` 与聚合计数,区分 `WAITING_QQMUSIC_CACHE`、`NO_QRC_FILE`、`NO_LINE_AT_POSITION`、精确缓存 miss 等原因;既有指标重新生成后数值不变。 + +100 次 650ms 快速压力完成 100 个控制意图和 100 个写回调,播放器实际产生 55 次 Track change;报告按压力口径 PASS。`stale accepted`、`duplicate control`、command timeout、hard reconnect、CurrentWord reject 均为 0。30 个被取消的媒体任务全部是切歌触发的 `track_changed/stale_generation` 主动取消,没有队列永久卡住;快速压力数据不计入正常 SLO。 + +加入 authority-acceptance fast lane 后的当前 iPhone + Sony 正常场景: + +| metric | NEXT p95 | PREVIOUS p95 | 对比上一当前轮次 | +|---|---:|---:|---:| +| command → Track publish | 546.4ms | 391.8ms | NEXT -46.7%;PREVIOUS -38.8% | +| metadata observed → Track accepted | 30.5ms | 14.0ms | NEXT -89.9%;PREVIOUS -84.6% | +| Track → current lyric | 426.8ms(28) | 566.6ms(25) | QRC 可用性仍影响覆盖 | +| word eligible → publish | 189.0ms(4) | 149.6ms(13) | 样本 p95 满足 ≤250ms,覆盖仍不足 | +| Track → Preview | 1338.5ms | 92.2ms | NEXT 冷图仍 FAIL;PREVIOUS PASS | + +NEXT/PREVIOUS 均完成 30/30 转换,`trackIdentityFastReady/trackFastLanePublished` 均为 30/30,没有 command timeout、hard reconnect、duplicate control 或 stale accepted。NEXT 主要剩余段是 dispatch 结束到 QQ 音乐/MediaSession 暴露新 metadata,fast lane 不提前猜测歌曲,因此不能消除这段外部等待。 + +最终 100 次 650ms 快速交替压力报告 PASS:100 个控制意图、53 次播放器实际身份切换、53/53 fast lane 发布;`STALE_CONTENT=0`、`duplicate_control=0`、`malformed=0`。一条旧身份被 generation fence 正确拒绝,2 个在途 Preview 被新曲抢占取消,都没有被 iOS 接受为当前内容。 + +专用逐字 QRC 90 秒窗口的旧版日志报告统计到 27 条 iOS CurrentWord accepted,stale discard=0、main stall=0、execution gap=0,日志派生 latency p95=114ms,CurrentWord 与 Live Activity 检查均 PASS。日志可能节流,该结果仅说明这一稳态窗口表现,不能替代统一 Trace 下切歌 → 首字的完整 SLO 验收。另一首只有逐行歌词的样本明确返回 `LINE_ONLY/NO_WORD_TIMING`,不伪造 CurrentWord。 + +## 18. 两小时 Soak + +最终真机轮次实际观察 120 分钟。Sony Trace 有 33 条 track-change 事件;按精确 trackId 的相邻状态去除 2 条同曲刷新后,为 31 个顺序唯一状态、30 次真实自然转换。该轮没有发送 dispatch-next;测试工具仅依据明确的 `mode=natural` 测量场景在报告层标为 `NATURAL_AUTOPLAY`,设备原始 Trace 的 `triggerType` 仍为 `UNKNOWN`,不能表述成 QQ 音乐主动提供了自然播放来源标签。 + +CurrentWord 的 `eligible/sendEnd` 为 11520/11520,`currentWordRejected=0`;PlayerAgent PID 全程不变,前台服务缺失 0,ANR 0,PlayerAgent crash 0。120 个每分钟资源点中,PSS 为 55933KB → 78233KB,范围 54057~117314KB;前 20 分钟包含歌词/封面缓存预热,去掉前 20 分钟后的线性趋势为 -64.2KB/min,没有观察到持续无界增长。1 个非 playing 采样点发生在真实切歌边界,约 100ms 后 Track Trace 已从 paused 恢复 playing。 + +本轮在约第 97 分钟发生一次 Sony 系统 Bluetooth stack 故障:8.3 秒内出现 4 次 L2CAP write failure,随后 `hci_timeout_abort`,系统杀死并重启 `com.android.bluetooth`。PlayerAgent 正确清理 runtime、重建 GATT/广播,iPhone 从 Bluetooth service down 到重新连接并订阅约 4.4 秒,之后 CurrentWord 恢复且故障没有再次发生。它证明恢复路径有效,但违反 0 crash/0 hard reconnect/0 L2CAP failure 的 Soak 验收,因此本轮结果为 `FAIL`。 + +iOS App 日志只有 current + old 两段滚动文件。末尾原生报告虽然看到 33 次 Sony 转换,却只有 3 个可见 iOS Track change、0 个完整 Handoff 样本并返回 `trace_sample_incomplete`。补充的只读定时快照去重后恢复 52662 条 iOS Trace、19 次 iOS Track change、7369 次 CurrentWord publish,但只有 7 个包含 UI consume 的完整样本;同时 Bluetooth 重启使 Clock Sync 不可信。因此自然场景跨端 p95 为 `NOT APPLICABLE`,不得使用错误相关的长时延代替正式性能结果。 + +## 19. 资源开销 + +新增常驻状态仅为两端单个 pending/current Handoff context、固定 2048 条 Trace ring、一个可取消的 iOS playback fallback work item 和已有队列中的 trace context,目标远低于 2MB。新增大块歌词/图片 cache、持续轮询、主线程磁盘 I/O和网络访问均为 0。 + +Sony 新增一个 CurrentWord 单线程 scheduler,替代共享 executor 上的同类任务,不增加周期任务数量;暂停或无歌词时没有固定轮询。iOS 没有新增 Timer,界面只在 metadata Slice 真正变化时记录 consume,不引入整页随机 identity 重绘。 + +最终资源采样显示 PlayerAgent PID count=1、foreground service missing=0。PSS 全程斜率受冷启动缓存预热影响为 +137.6KB/min,但去掉前 20 分钟后为 -64.2KB/min,最后 60 分钟为 -83.8KB/min;因此没有进程内持续泄漏证据。系统 Bluetooth 进程的单次崩溃是独立的正确性/稳定性失败,不能由 PSS 趋势抵消。 + +## 20. 未完成问题 + +- 远程 NEXT/PREVIOUS command → Track publish p95 已下降到 546.4ms 和 391.8ms,但仍未达到 350ms;当前主要剩余段是播放器切换/metadata 暴露及其波动。 +- 专用逐字 QRC 90 秒窗口的日志派生 CurrentWord latency p95=114ms 且未观察到 stale,但 NEXT/PREVIOUS 切歌场景仍只有 4/13 个 word eligible 样本,未达到每场景 30 个覆盖目标。 +- NEXT 冷图 Track → Preview p95 仍为 1338.5ms,未达 800ms;PREVIOUS 的缓存/重用路径为 92.2ms。 +- 120 分钟自然播放虽完成 30 次真实转换,但 Sony 系统 Bluetooth stack 在约第 97 分钟发生 1 次 HCI timeout/process death;4 次 L2CAP write failure,Soak 为 FAIL。 +- iOS 滚动日志只恢复到 19 次 Track change、7 个完整 UI Handoff;Clock Sync 又因 Bluetooth 重启不可信,自然场景跨端 p95 为 `NOT APPLICABLE`。 +- Android Controller 与双控制器矩阵按当前优先级延期,为 `SKIPPED(当前范围)`。 +- authority-acceptance fast lane 的 iPhone + Sony NEXT/PREVIOUS 各 30 次、CurrentWord 90 秒和 100 次快速压力已执行;保留实现的这些轮次均 PASS。远程 Track、部分 QRC 冷路径和 NEXT 冷图片仍未达到目标。 +- 历史快速压力曾发生 Clock Sync reset;本轮 100 次压力 Clock Sync 可信且无 hard reconnect,但仍只按正确性口径验收,不作为正常 SLO。 + +在上述项闭环前,第四阶段结论只能是“未完成,存在阻断项”。 + +## 21. Fast lane 提交前复核(2026-09-05) + +- Sony `testDebugUnitTest` / `assembleDebug`:PASS;新增身份变化与不完整 metadata 的策略测试。最终 APK 已覆盖安装,未清除应用数据。 +- Sony quick smoke:PASS,Required 8/8,Optional pass=5、warn=0、skipped=2;无订阅者窗口的 PlaybackDiff / CurrentWord 为 SKIPPED,不能算作链路通过。 +- iOS quick smoke:PASS,Required 6/6,Optional pass=4、warn=4、skipped=1;Notify、PlaybackState、Healthy 与封面链路 PASS。没有新扫描/发现/连接事件及 CurrentWord 样本的项目保留 WARN。 +- 最终安装版补测 NEXT 5 次:Trace 完整性 PASS(5/5,MTU 515、Clock Sync 可信、fast lane 5/5),不等于性能通过。command → Track p50/p95/max 为 938/3153.6/3591ms,其中 dispatch → metadata p95/max 为 2838.2/3425ms,metadata → accepted p95 为 45.4ms。冷图 Preview p95 为 2643.2ms;本轮无 word eligible 样本,一首缺少本地 QRC。该小样本不能替代前述 30 次对照,也不能忽略:它显示播放器 metadata 等待及冷图长尾仍不稳定,尚不能宣称端到端性能稳定改善。 +- 两个 quick 脚本均跳过构建/安装;上述 Sony 构建与安装是单独执行的。iOS 本批无源码改动,本地 XCTest、全量 smoke 与重新安装为 SKIPPED;提交后的 XCTest 以对应 GitHub Actions 结果为准。 +- Sony lint:FAIL(历史基线),修改前后均为 20 errors / 33 warnings,没有增加;未使用 suppress 或关闭 abortOnError。Android Controller 本地构建/lint 及双控制器真机测试为 SKIPPED(当前范围)。 +- BLE UUID、既有 command/status 语义、A1/A2 固定 Header、ATT response 生命周期与 pacing 均未修改;没有新增 Timer 或持续轮询。可选身份回调异常被隔离,后续正常歌词/冷路径继续执行。 +- `git diff --check`:PASS。图谱已完整重建且不落盘;当前工具未提供 `detect_changes`,影响范围由源代码 diff、调用路径和上述测试交叉核实。 + +原始报告仅保留在本机 `/tmp/musicble_phase4/`、`/tmp/music_ble_ios_smoke/` 与 `/tmp/music_ble_android_smoke/`,不提交真实歌曲名、设备标识或完整日志。 diff --git a/docs/IOS_ARCHITECTURE.md b/docs/IOS_ARCHITECTURE.md index 6bc220d..e908b7d 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,26 @@ - `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。 + +命令写回调连续超时仍会触发真实传输自愈。若 CoreBluetooth transport 尚连接且最近 notify 证明链路活跃,自愈期间最多 8 秒保持已连接展示;正式同步完成后结束保护,连接失败或保护窗到期则恢复真实重连状态。该策略只稳定产品展示,不跳过重连,也不把超时命令补发。 + +用户控制与播放状态 fallback 共用串行 command write 队列,但普通 `GET_PLAYBACK_STATE` 会被更新的 fallback 或新的用户控制合并;前台恢复验证与 Health probe 的请求序列受保护。NEXT/PREVIOUS fallback 等待 1 秒,优先让 Sony 的正式 TrackInfo/PlaybackState 主动推送完成;新 trackId 一旦被接受即取消剩余 fallback。该策略不新增 Timer、不重发控制命令,也不改变 BLE 协议。 ## 关键状态 @@ -44,6 +76,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 +94,16 @@ - 不要把设置重新塞回 `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`。 +- 频繁切歌时误显示重连:查 `[CTRL-iOS] write path recovery keeps connected presentation` 与 `[BLE-UIState] silent transport recovery started/finished`;保护窗内不应出现 `display reconnecting`。 +- 恢复后立即重连:查 `[BLE-Restore]`、`foreground restore skipped`,恢复完成前不应出现 `foreground unhealthy ... hard reconnect`。 - 设置未持久化:查 `[Preferences] loaded`、`[Preferences] changed`、smoke `[SmokeTest] preferences persisted`。 - 诊断页数据不对:查 `makeNowPlayingDiagnosticSnapshot()` 和 `SystemHealthSnapshot(nowPlaying:)`。 @@ -66,3 +113,10 @@ - 改 `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 为准。 + +V4 Debug/Smoke 构建通过 `RealtimeTraceStore` 记录命令、协议接收、解码、Track/歌词/CurrentWord/封面 publish 和主播放器消费事件。事件只进入 2048 条 Ring Buffer 与现有异步 `AppLogStore`,不成为业务状态源;完整口径和真机命令见 [REALTIME_SLO_V4.md](/Volumes/雷电/project/MusicBleController/docs/REALTIME_SLO_V4.md)。 diff --git a/docs/LYRICS_ARCHITECTURE.md b/docs/LYRICS_ARCHITECTURE.md index 8f92018..588831d 100644 --- a/docs/LYRICS_ARCHITECTURE.md +++ b/docs/LYRICS_ARCHITECTURE.md @@ -11,6 +11,8 @@ - Sony `QrcDirectoryWatcher` / `QrcIncrementalPrebuildManager`:监听 QQMusic 文件后到并增量解析。 - Sony `LyricRecoveryEngine`:当前歌曲无歌词后短期恢复窗口,处理 QQ音乐懒加载歌词缓存。 - iOS `BLETestManager`:接收 `lyricWindow*`、legacy `fullLyrics*`、A2 zlib full lyrics、`lyricSecondary*` 和歌词诊断。 +- Sony `LyricsTransferCoordinator` / `CompressedLyricsCache`:独立持有歌词重传与最多 16 项/512KB 的 zlib 正文和 CRC,不受封面生命周期影响。 +- Sony `PredictiveMediaCoordinator`:最多两个候选的有界 Hot Set;只在低优先级线程验证现有 QRC parsed cache 和 fingerprint,不复制第二份完整歌词。 - iOS `FullLyricsView`:显示原文、翻译、罗马音,逐字高亮只作用于原文。 ## 核心文件 @@ -25,6 +27,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 +39,13 @@ 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` 触发兼容刷新。 +11. V4 预测候选只有在真实 Track Identity、稳定 mediaId/trackId 和 QRC fingerprint 全部复验后才能晋升;WEAK 历史候选永不晋升,失败立即走步骤 3 的冷路径。 +12. `mediaCacheValidationV1` 启用时,iOS cache v3 分别保存 Sony QRC 远端 fingerprint 与 iOS 实际落盘正文的 local fingerprint,并附带 schema/主歌词、翻译和罗马音行数。远端 fingerprint 用于 not-modified 协商,local fingerprint 用于精确检测本地损坏;二者不能互相替代。精确命中返回 `fullLyricsNotModified`,不重复发送 FullLyrics;任一字段或正文不一致、cache 损坏或旧 cache v1/v2 都回退一次完整传输并迁移。新 descriptor 只随完整正文发布落盘,不能附着到旧正文。 ## 关键状态 @@ -46,6 +55,9 @@ - `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 改变时不复用旧正文。 +- `PredictiveHotSet` 最多两个 metadata entry、TTL 2 分钟;大数据继续引用 QRC parsed cache 和 `CompressedLyricsCache`,没有第二份大缓存。 ## 不允许随便修改的点 @@ -56,6 +68,7 @@ - 不要把 translation/romanization 塞回基础 `fullLyricsChunk` 导致 MTU 裁剪。 - 不要让 Live Activity 显示翻译/罗马音或携带完整歌词。 - 不要删除 negative/cooldown/alias 策略,除非有日志证明它们错误。 +- 不要让歌词重传读取或比较 `currentAlbumArtId`。 ## 常见问题排查入口 @@ -71,3 +84,16 @@ - iOS UserDefaults 歌词显示模式/偏移:full smoke。 - Sony QRC/Recovery/Cache:Android build `./gradlew :PlayerAgentApp:assembleDebug`,真机播放有/无歌词、懒加载、翻译/罗马音歌曲。 - docs-only:`git diff --check`。 + +V4 实时性观测使用 `trackId + generation` 关联 `lyricRequestQueued`、`lyricReady`、pending、LyricWindow、FullLyrics 和 CurrentWord 阶段。Trace 不读取歌词正文,也不改变 QRC 加载、pending flush、队列优先级或 stale fence;指标定义见 [REALTIME_SLO_V4.md](/Volumes/雷电/project/MusicBleController/docs/REALTIME_SLO_V4.md)。 + +## V4 Cold-Path Current Lyric 与 CurrentWord + +- `LyricManager` 对 lookup requested/start、runtime/parsed cache hit/miss、load、ready 和 current line selected 输出 `handoffId/trackId/generation/positionAnchor/lineIndex/wordTimingStatus/cacheSource/failureReason`;不写歌词正文。 +- `lyricsReady` 先把精确匹配的行写入 `CurrentTrackRuntimeCache`,再通过 `LyricsReadyGateSnapshot` 触发 current PlaybackState 首包。只有当前 trackId、generation、非空 current line 和订阅状态完全匹配才发送,旧 Track 不会借 fast publish 回写。 +- CurrentWord eligibility 区分 `ELIGIBLE`、`INTRO_WAIT`、`LINE_ONLY/NO_WORD_TIMING`、`LYRIC_NOT_READY`、`PAUSED`、`CLOCK_UNTRUSTED` 和 `NO_ACTIVE_LINE`。前奏/逐行/无歌词样本不冒充系统延迟失败。 +- CurrentWord 使用独立 scheduled executor;匹配 generation 的 `lyricsReady` 是首包事件屏障,250ms 仅作 bounded fallback。若 position 已在词内立即发送 snapshot,否则按真实下一边界调度;暂停时 suspended,seek/恢复时重算。 +- generation、sequence、position 和 anchor fence 全部保留。iOS 明确记录 `TRACK_MISMATCH/GENERATION_MISMATCH/SEQUENCE_OLD/POSITION_STALE/ANCHOR_STALE/DUPLICATE`,只有 accepted 后才发布。 +- 第四阶段没有修改 QRC Triple DES、parser、index schema、fuzzy、negative、alias 或 Recovery Engine。当前复测中 `lyrics ready → current line enqueue` p95 为 21.8~69.5ms,三场景均满足 100ms;Sony dispatch-next 的有效 Track → current lyric p95 为 112.4ms。NEXT/PREVIOUS 原始 p95 被 `WAITING_QQMUSIC_CACHE/NO_QRC_FILE` 样本拉到约 3.8~3.9 秒,报告已按原因单独分类,不能归因于 parser 或首包 enqueue。 + +详细状态机、样本覆盖和未达标项见 [COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。 diff --git a/docs/PREDICTIVE_MEDIA_ENGINE_V4.md b/docs/PREDICTIVE_MEDIA_ENGINE_V4.md new file mode 100644 index 0000000..74be6f1 --- /dev/null +++ b/docs/PREDICTIVE_MEDIA_ENGINE_V4.md @@ -0,0 +1,156 @@ +# MusicBleController V4 Predictive Media Engine + +本文记录第三阶段的预测来源审计、有界 Sony 本地预热、精确歌词缓存校验和回退边界。阶段目标是减少可消除的歌词准备与 BLE 重传,不提前展示未经 Sony 当前 PlaybackState 确认的媒体。 + +## 1. Gate 0 与基线 + +第三阶段从第二阶段最终提交 `c89acc7a5815d49e7026cc78c0f988c532be3f45` 创建 `codex/v4-phase3-predictive-engine`。第二阶段的 iOS/Android GitHub Jobs、iOS XCTest、iPhone/Sony smoke 均为 PASS;基线和 Top 3 瓶颈见 [REALTIME_SLO_V4.md](/Volumes/雷电/project/MusicBleController/docs/REALTIME_SLO_V4.md)。 + +第二阶段同场景快速切歌基线:command→Track p95 621.8ms、Track→当前歌词 p95 1230.8ms、Track→Preview p95 650ms、Track→HQ p95 652.2ms。CurrentWord 只有 1 个可关联样本,不能作回退结论。主要时间不在 iOS 解码/发布,而在播放器身份确认、歌词就绪和媒体传输。 + +## 2. Prediction Source Audit + +正式审计采集 239 次真实歌曲转换,产物保存在本机 `/tmp/musicble_prediction_audit_v4/phase3_gate1_100_final/`,真实设备日志不提交仓库。 + +| source | confidence | coverage | accuracy | average lead time | local prewarm | cross-device prefetch | +|---|---|---:|---:|---:|---|---| +| MediaSession queue + activeQueueItemId | CONFIRMED/STRONG | 0% | N/A | N/A | 可实现,当前设备无来源 | 否 | +| NEXT/PREVIOUS + visible queue | CONFIRMED/STRONG | 0% | N/A | N/A | 可实现,当前设备无来源 | 否 | +| learned history adjacency | WEAK | 仅低置信本地事件 | 不用于可见预测 | N/A | 是,仅 QRC 索引预热 | 否 | +| no candidate | NONE | 100% high-confidence miss | N/A | N/A | 冷路径 | 否 | + +审计结果:213 次 eligible transition,218 次 queue observation 中 queue available=0、activeQueueItemId available=0;高置信候选数为 0,预测覆盖率 0%,准确率和提前量均为 N/A。当前 Sony/QQ 音乐版本不暴露可用播放队列,通知只暴露当前媒体。因此 Gate 4 跨端预取按停止条件跳过,没有猜歌、读取私有数据库、Accessibility 抓取或联网请求。 + +## 3. 信任等级与来源解析 + +- `CONFIRMED`:activeQueueItemId 精确定位当前项,下一/上一 QueueItem 同时有 queueItemId、mediaId、title、artist。 +- `STRONG`:当前项能唯一精确定位,目标有稳定 queueItemId 或 mediaId/trackId 以及完整 title+artist。 +- `WEAK`:只来自重复历史邻接或不完整队列身份;仅允许 Sony 低优先级本地索引预热。 +- `NONE`:没有候选,直接保留冷路径。 + +`PredictionSourceResolver` 不用模糊 title 匹配定位当前项。晋升必须精确匹配 title、artist,并且至少有稳定 mediaId 或 trackId;可用时还校验 2 秒 duration bucket。WEAK 永远不晋升,也不发送到 iOS。 + +## 4. Candidate 状态机与 Hot Set + +状态为 `DISCOVERED → PREWARM_QUEUED → PREWARMING → LYRICS_READY/ARTWORK_READY/READY`,结束状态为 `PROMOTED/REJECTED/INVALIDATED/EXPIRED`。 + +`PredictiveHotSet` 只保存最多两个非当前候选,使用 access-order LRU 和 2 分钟 TTL。候选只保存 identity digest、稳定 ID、fingerprint 和 readiness 等元数据,不复制完整歌词、压缩正文或 Bitmap。重复 callback 按 source-independent candidateKey 去重,不会重复提交预热;容量淘汰、TTL、fingerprint 变化和 coordinator 关闭都会显式失效。 + +候选没有正式 generation。真实 Track Identity 被 Sony 接受后才建立当前 generation,再尝试晋升。 + +## 5. Sony 本地预热与晋升 + +`PredictiveMediaCoordinator` 使用 maintenance executor 的最低线程优先级,任务可通过 candidateKey/TTL 栅栏取消;省电模式、当前歌词正在前台加载或候选已过期时降级。它不增加 Timer 或周期轮询,只由 queue observation、NEXT/PREVIOUS、track transition 和既有 QRC 事件触发。 + +本阶段预热内容严格限于现有 QRC parsed cache 的精确查找、fingerprint 和逐字能力;不复制歌词正文、不在实时线程解密/压缩、不预编码 HQ。现有 QQ 音乐未暴露候选封面 identity,所以 artwork prewarm 只保留模型能力,实际不执行猜测式读取。 + +真实切歌时,只有唯一 CONFIRMED/STRONG 候选通过稳定身份和 fingerprint 复验才能从现有 QRC cache 原子应用到 `CurrentTrackRuntimeCache` 并恢复 CurrentWord。任何不匹配、未 ready、cache miss 或 fingerprint 变化都丢弃候选并继续原冷路径。WEAK 以 `weak_local_only` 拒绝,不能变成可见状态。 + +## 6. iOS Cache-First Handoff + +由于 Gate 1 没有高置信候选,本阶段没有新增 iOS Prefetch Cache、PrefetchTransferCoordinator 或候选 UI 状态。Sony 当前 PlaybackState 仍是 iOS 当前歌曲的唯一权威来源。 + +已实现的是当前歌曲确认后的精确 FullLyrics cache-first handoff:iOS 可先展示按 trackId 且复核 title+artist 的本地歌词;只有 Sony 返回相同 trackId、当前 generation、内容 fingerprint、schema 和行数后,才把它标记为正式可浏览内容。旧歌曲迟到回调继续受 generation 栅栏拒绝。 + +## 7. FullLyrics 缓存校验与增量发布 + +V3 `f3` bit3 定义 `mediaCacheValidationV1`。只有双方 ACK 后,iOS 才在原 `GET_FULL_LYRICS` 中附带紧凑字段:`id/p/w/f/fp/sv/n/tc/rc`。请求省略 Sony 从未使用的墙上时间字段,保持在常见 182-byte ATT write 内。 + +Sony 对实际歌词生成 SHA-256 截断 fingerprint,覆盖 title、artist、每行时长/正文、translation、romanization 和逐字 timing;并精确比较 schema、主歌词行数、翻译行数和罗马音行数。完全相同时返回轻量 `fullLyricsNotModified` 并跳过 A2/legacy 正文;不匹配时返回 `fullLyricsCacheMetadata` 后执行原完整传输。 + +iOS cache v3 将 Sony QRC 远端 fingerprint 与 iOS 已落盘正文的 local fingerprint 分开保存。远端 fingerprint 用于 `fullLyricsNotModified` 协商;local fingerprint 由 iOS 对实际收到并持久化的主歌词、逐字和 secondary 内容独立计算,用于发现本地损坏。两者不能互相替代,因为 Sony QRC fingerprint 可以覆盖未包含在当前 FullLyrics 正文中的完整逐字数据。只有远端 descriptor 完整、local fingerprint 精确复算一致时才发送校验请求。legacy v1/v2 仍可即时显示,但不能抑制一次完整传输;成功冷取后迁移到 v3。缓存损坏、descriptor 不完整或 not-modified 与本地内容不一致时删除该条并只强制刷新一次;新 descriptor 只在完整正文实际发布后落盘,不能覆盖到旧正文。negative cache 不参与校验。CurrentLine、LyricWindow、CurrentWord、position anchor 和 generation 始终继续实时发送。 + +## 8. Capability、协议与兼容 + +- 未协商 bit3:Sony/iOS 完全沿用 V2/legacy FullLyrics。 +- 新 iOS + 旧 Sony:新增请求字段不会发送;正常完整传输。 +- 新 Sony + 旧 iOS/Android Controller:旧端不声明 bit3,不收到 not-modified/metadata。 +- A1/A2 固定 header、UUID、command/status characteristic、已有命令名和 payload 均不变。 +- capability 和歌词传输状态继续按控制器地址隔离;没有共享 prefetch session。 +- `sid/es` 只在该设备实际协商 `statusMetaV1` 后附加;MTU 185 等小 payload 会关闭 bit0,不能把未协商元数据挤进 `mediaLoadState` 或错误状态。 +- 当前媒体的 TrackInfo、PlaybackState、歌词、CurrentWord 与 A1 封面统一通过 `MediaWireGenerationPolicy` 解析 wire generation;只有精确 identity 匹配的 runtime generation 才可复用,身份不匹配立即走正式冷路径 generation。 + +跨端 `prefetchManifest`、`PREFETCH_MEDIA` 和 purpose=prefetch A1/A2 会话均未实现。原因是高置信候选覆盖率为 0,保留它们只会增加无收益协议和 BLE 流量。 + +## 9. BLE 带宽预算 + +本阶段预取 BLE packets/bytes 为 0。P0 控制/PlaybackState/CurrentWord、P1 LyricWindow/正式 Preview、P2 正式 FullLyrics/Secondary、P3 HQ/History/Diagnostics 的既有优先级不变。FullLyrics 校验命中反而消除 P2 传输,不占用 CurrentWord 队列。 + +真机压力还确认 Android 的 notify callback 可能早于 L2CAP 实际排空。封面 chunk 因此使用独立的 15ms 最小 pacing,歌词和实时 JSON 不受影响;收到 command write 时同步预留 response quiet window,让 GATT write response 先于后台图片继续入队。最终 100 次快速切歌中 313/313 次 command write 收到 callback,write timeout、response failed 和 L2CAP congestion 均为 0。 + +## 10. Trace 与指标 + +Sony 输出 `predictionCandidateCreated/Updated`、`predictionPrewarmQueued/Start`、`predictionLyricsReady`、`predictionArtworkReady`、`predictionReady`、`predictionPromotionAttempt/Promoted/Rejected/Invalidated/Expired`、`cacheValidationHit/Miss` 和 `fullLyricsTransferSkipped`。iOS 输出 `cacheValidationRequest/Hit/Miss`、`cachedLyricsPublished` 和 `fullLyricsTransferSkipped`。 + +关联字段只包含 candidateId、identityDigest、trackId、generation、transferId、confidence、source、monoMs、result 和 reason;不记录标题、歌词或图片。 + +最终真机快速压力数据(100 个 NEXT/PREVIOUS 控制,68 个 eligible transition)观察到:FullLyrics 校验命中/跳过 30 次、节省估算正文 538,028 bytes、Preview cache hit 62;prefetch bytes=0。预测候选 37 个且均为 WEAK,本地预热 queued=4、ready=3、promoted=0,符合不得晋升弱候选的屏障。正确性计数 stale accepted、wrong CurrentWord、wrong artwork、visible false positive、duplicate control、cold fallback failure 均为 0。 + +缓存协议另有一次冷迁移和一次热命中真机证据:热命中请求包含 `fp/sv/n/tc/rc`,Sony 返回 `fullLyricsNotModified`,iOS 从 v3 cache 发布 59 行且没有 A2 正文。封面链路同一歌曲的 TrackInfo、PlaybackState、Preview/HQ A1 和 CurrentWord 全部使用 generation 1,未出现 generation mismatch 拒绝或旧图回写。 + +## 11. 性能结果与 SLO + +高置信候选为 0,因此 Warm Path 的全部指标为 `NOT APPLICABLE`,不得宣称 ≤150/250/350/300ms 已完成。 + +最终 Phase3 快速压力的可比指标如下;正式报告保存在本机 `/tmp/musicble_phase3_control_headroom_15ms_final100/`: + +| metric | Phase2 p95 | Phase3 p95 | change | result | +|---|---:|---:|---:|---| +| command → Track publish | 621.8ms | 632.6ms | +1.7% | PASS,回退 <10% | +| Track → current lyric | 1230.8ms | 1210.1ms | -1.7% | PASS,无回退 | +| Track → CurrentWord | 样本不足 | 362ms(1 个样本) | N/A | 样本不足,不作回退结论 | +| Track → Preview | 650ms | 707.2ms | +8.8% | PASS,回退 <10% 且 ≤800ms | +| Track → HQ | 652.2ms | 707.2ms | +8.4% | PASS,回退 <10% 且 ≤2500ms | +| iOS decode | 1ms | 1ms | 0% | PASS | +| iOS publish | 20.9ms | 18.5ms | -11.5% | PASS | + +Preview/HQ 的正式传输继续使用 Sony/iOS 精确封面栅栏;15ms 封面 pacing 保住了 100 次压力下的控制响应窗口,同时最终 Preview/HQ p95 仍处于 Cold SLO。Warm artwork 因无预测来源为 N/A。 + +## 12. 资源开销 + +- Hot Set 固定两项,只有短字符串、枚举、时间戳和 boolean,远小于 2MB。 +- iOS FullLyrics cache 仍为 80 项/8MB 磁盘、16 项/2MB 解码内存上限,没有第二份歌词 cache。 +- 新增 Timer:0;新增持续轮询:0;新增主线程同步磁盘 I/O:0。 +- 预热不在 MediaSession callback、BLE HandlerThread 或 CurrentWord 实时路径做 QRC 解密/压缩。 +- 预取 BLE packets=0;notify RTT 不受预取影响。 + +## 13. 自动化工具 + +```bash +./tools/smoke/prediction_source_audit_v4.sh --expected-transitions 100 --json +./tools/smoke/predictive_media_v4_test.sh --runs 30 --json +./tools/smoke/predictive_media_v4_test.sh --runs 100 --fast-switch --json +./tools/smoke/predictive_media_v4_test.sh --duration-minutes 120 --json +``` + +产物分别位于 `/tmp/musicble_prediction_audit_v4//` 和 `/tmp/musicble_predictive_v4//`。报告脚本测试覆盖无候选、命中/错误候选、预热完成/未完成、cache hit/skip、缺失/乱序 Trace、stale transfer、双端事件去重和 Warm/Cold 分类。 + +快速场景以 Sony 实际收到的 NEXT/PREVIOUS 控制数作为压力执行数;播放器合并掉的身份转换继续计入 latency missing,不用后续转换冒充样本。 + +## 14. 失败与回退 + +- queue/null、activeQueueItemId=-1:不创建高置信候选,正常冷路径。 +- WEAK 历史候选:只做本地 cache index prewarm,切歌时拒绝晋升。 +- candidate 过期/淘汰/fingerprint 变化:取消或失效,冷路径。 +- FullLyrics cache miss/corrupt/schema/secondary mismatch:完整传输;iOS 强刷最多一次。 +- capability timeout/旧客户端/metadata 超 MTU:V2/legacy 完整传输。 +- 连接 suspect/stale:没有跨端预取需要取消,正式任务保持既有优先级。 +- 小 MTU 未协商 `statusMetaV1`:不附加 `sid/es`,避免 notify payload 超限;其他 V3 capability 保持独立。 +- 高频切歌与封面并发:command write 到达时先预留响应窗口,图片以 15ms 最小间隔继续;若真实连接仍无 callback,iOS 仍按既有两阶段 suspect/reconnect 规则处理。 + +## 15. 未实现能力 + +- 跨端候选 Manifest、歌词/封面 prefetch 和 iOS PredictiveMediaCache:Gate 4 SKIPPED。 +- 预测封面提前编码/HQ prefetch:当前没有合法候选 identity,SKIPPED。 +- Warm Path SLO:当前设备不提供预测队列,NOT APPLICABLE。 +- 双控制器混合 capability 真机、两小时 soak:需第二控制器/专门长时间窗口,未执行时必须标记 SKIPPED。 + +这些边界是审计结论,不是未来协议承诺。只有实际设备暴露稳定队列且审计证明准确率、提前量和 BLE 收益后,才重新打开跨端预取 Gate。 + +## 16. 第四阶段 Cold-Path 后续 + +第四阶段没有重新打开 Predictive Prefetch,也没有猜测候选歌曲。它处理所有歌曲都适用的 current lyric/CurrentWord 冷路径:lyrics-ready 事件直接发布精确 current line,CurrentWord 使用独立 boundary scheduler 和 event barrier,P0 状态不再停留在结束的大任务 interleave slot。 + +第三阶段的 `mediaCacheValidationV1`、FullLyrics skip、Hot Set 容量/TTL、15ms AlbumArt pacing、wire generation 和全部 capability/legacy fallback 保持不变。回退后当前复测的 `lyrics ready → current line enqueue` p95 为 21.8~69.5ms,Sony dispatch-next 的 Track → current lyric p95 为 112.4ms;远程 NEXT/PREVIOUS Track publish p95 仍为 1026.0/640.0ms。当前歌单每场景仅有 2 个立即 eligible CurrentWord 样本,不据此宣称逐字 p95 完成。 + +详细结果见 [COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。 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..1473284 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -2,12 +2,14 @@ 本文给 Codex 使用,用于快速建立项目边界。修改代码前先读本文件,再按具体模块阅读对应架构文档。 +V4 本次按维护者决定提前接入主干,属于阶段性交付,不等于第四阶段完成或 SLO 全部达标。继续开发前应先读 [V4 验收状态与未达标指标](V4_ACCEPTANCE_STATUS.md),不得因分支已合并而关闭未通过的性能、覆盖率和稳定性项目。 + ## 模块职责 - `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。 ## 核心文件 @@ -20,22 +22,41 @@ - Sony BLE GATT:[BleGattServerManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt) - 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 预测热集:[PredictiveMediaCoordinator.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/PredictiveMediaCoordinator.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 同时在线并保持控制与媒体状态同步。 +8. Sony PlayerAgent 本机播放器封面监听 QQ 音乐通知事件,并在 title/artist 精确匹配当前媒体后发布;切歌后的迟到图片由 generation+songKey 栅栏拒绝。 +9. V4 第三阶段先审计 MediaSession queue;有稳定 queueItemId/mediaId 时 Sony 才允许 CONFIRMED/STRONG 候选晋升。当前真机 QQ 音乐不暴露队列,因此只保留 WEAK 历史候选的本地 QRC 索引预热,不向控制端发送候选。 +10. 协商 `mediaCacheValidationV1` 后,iOS 对当前歌曲的精确 FullLyrics cache 发 fingerprint/schema/行数校验;命中时 Sony 跳过完整正文传输,CurrentLine/CurrentWord 仍正常增量发布。 +11. iOS cache v3 分离远端 QRC fingerprint 与本地持久化正文 fingerprint;媒体 TrackInfo、PlaybackState、歌词、CurrentWord、Preview/HQ 统一使用经过 identity 复核的 wire generation。 +12. 高频切歌与封面传输并发时,Sony 对合法 command 保持即时 ATT response,并在 response 后按设备保留 25ms quiet window;封面 binary 最小 pacing 固定为 15ms。健康链路不会仅因歌曲 generation 变化显示“正在连接”。 +13. V4 第四阶段以本地 `handoffId` 关联 command、MediaSession、PlaybackState、歌词和 CurrentWord;该 ID 只用于 Trace,不进入 BLE payload。 +14. 歌词 ready 后,Sony 通过精确 trackId/generation 屏障立即发布包含 current line 的 PlaybackState,并立即恢复独立 CurrentWord boundary scheduler,不再等待下一轮 AutoPush。 +15. iOS 控制写会取消尚未执行的普通 `GET_PLAYBACK_STATE` fallback,并把 NEXT/PREVIOUS 的兜底读取合并到播放器身份切换窗之后;前台验证和 Health 探针不参与丢弃。Sony 的 220ms 控制后广播保留轻量 PlaybackState 兜底;新身份和精确封面由权威 MediaSession 确认后的 fast lane 发布,避免旧身份和旧图占用正式新歌曲通道。 +16. Sony TrackInfo 始终进入既有 P0 实时队列并在长媒体任务包边界抢占;latest-only interleaved 槽只保留可替换的 volumeState。iOS Clock Sync 后台探针在 FullLyrics/图片接收期间有界延后,媒体空闲后事件化恢复,不新增 Timer。 +17. Sony BLE 主 `PlaybackStateReader` 在权威 MediaSession 确认新身份后先原子建立 runtime generation,立即同步 Sony UI 和 iOS TrackInfo,再继续歌词、capability 和诊断路径;该 fast lane 不使用通知猜测、不改协议,也不向非 BLE Reader 重复发布。 ## 关键状态 - Sony 是播放、历史、歌词和封面来源的权威端。 +- iOS 与 Sony 可协商 `clockSyncV1`,使用单调时钟和播放位置采样时间动态补偿 BLE 歌词延迟;严重积压的旧锚点不得覆盖当前进度。 - iPhone 是同步、缓存和展示端。 +- Android Controller 是与 iPhone 对等的同步、缓存和展示端,不包含 Live Activity。 - iOS `BLETestManager` 仍是连接/协议分发中心,但设置已拆到 `PreferencesStore`,封面接收已拆到 `AlbumArtReceiver`。 +- iOS 写通道自愈不会把最近仍有 notify 的健康连接立即展示成“正在连接”;同步失败或保护窗到期时仍回到真实重连状态。 - Live Activity 状态必须保持轻量,`ContentState` 不允许放图片、完整歌词、大数组或 Base64。 - iOS 日志落盘在 App 容器 `Documents/Logs/ios_ble.log`。 @@ -56,6 +77,9 @@ - 封面:iOS `[AlbumArt]`、`[AlbumArtBinary]`、`[AlbumArt-iOS]`、`[ArtworkDisplay]`;Sony `[AlbumArt-Sony]`。 - Live Activity:`[LiveActivity]`、`[LiveActivityState]`、`[LiveActivityPerf]`。 - Smoke 报告:`/tmp/music_ble_ios_smoke//report.json`。 +- V4 实时性 Trace、SLO 与真机基线:[REALTIME_SLO_V4.md](/Volumes/雷电/project/MusicBleController/docs/REALTIME_SLO_V4.md);自动报告位于 `/tmp/musicble_realtime_v4//`。 +- V4 预测来源、Hot Set、缓存校验与 Gate 4 跳过依据:[PREDICTIVE_MEDIA_ENGINE_V4.md](/Volumes/雷电/project/MusicBleController/docs/PREDICTIVE_MEDIA_ENGINE_V4.md)。 +- V4 Cold-Path Handoff、current lyric/CurrentWord 首包、command response 竞态与第四阶段实测:[COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。 ## 修改后必须跑哪些 smoke test @@ -63,4 +87,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/REALTIME_SLO_V4.md b/docs/REALTIME_SLO_V4.md new file mode 100644 index 0000000..5770f99 --- /dev/null +++ b/docs/REALTIME_SLO_V4.md @@ -0,0 +1,246 @@ +# MusicBleController V4 Real-time SLO + +本文定义 V4 第二阶段的实时性 Trace、端到端 SLO、自动报告和真机基线方法。Trace 只观察现有业务链路,不参与播放、歌词、封面、连接或重连决策。 + +## 1. 范围与数据链路 + +测量链路为:iOS 用户意图 → CoreBluetooth 写队列 → Sony GATT 命令接收 → MediaSession 控制 → 新 Track Identity → 歌词/封面就绪 → Sony Notify 队列 → iOS 协议接收/解码 → Observable State 发布 → SwiftUI 主播放器消费。 + +本阶段没有新增第二套 Track Identity、generation、时钟、BLE 队列或媒体状态。iOS 继续以 `BLETestManager` 和聚焦 Store 为状态边界;Sony 继续以 `ReactiveMediaController`、`CurrentTrackRuntimeCache`、`PlaybackStateReader` 和 `BleNotifyQueue` 为权威链路。 + +## 2. Trace 数据模型 + +两端稳定输出单行结构化事件: + +```text +[RealtimeTrace] side=sony stage=lyricReady monoMs=123 commandSeq=- commandType=- trackId=... generation=7 transferId=- payloadType=lyrics queueWaitMs=- processingMs=- chunkIndex=- chunkCount=- result=ready reason=- +``` + +固定字段为 `side`、`stage`、`monoMs`、`commandSeq`、`commandType`、`trackId`、`generation`、`transferId`、`payloadType`、`queueWaitMs`、`processingMs`、`chunkIndex`、`chunkCount`、`result`、`reason`。缺失值统一为 `-`。 + +Trace 不写歌曲标题、歌手、专辑、歌词正文、图片正文或 Base64。字符串限制为 96 个安全字符;Track 使用协议现有的稳定 `trackId`。 + +## 3. 单调时钟 + +- iOS:`ProcessInfo.processInfo.systemUptime * 1000`。 +- Sony:`SystemClock.elapsedRealtime()`。 +- 持续时间禁止使用墙上时间。 +- 两端 Ring Buffer 都固定为 2048 条;时钟采样发生回退时只保证 Trace 序列不倒退,不改变业务时钟。 + +## 4. T0 定义 + +手动 NEXT/PREVIOUS 的 T0 是 `commandIntent`:用户命令进入 iOS BLE 写队列之前的单调时刻。报告器按顺序把该命令与下一次真实 `trackIdentityAccepted` 闭环关联;快速切歌中无法在下一条命令前确认身份的样本标记为缺失,不猜测归属。 + +Sony 本机或自动下一首的 T0 是 Sony `trackIdentityAccepted`。只有 Clock Sync 可信时,报告器才把该时刻映射到 iOS 单调时间;不可信时端到端指标保持空值。 + +缓存快照不是新 Track T0,也不计作新歌曲首屏完成。 + +## 5. Clock Sync 可信策略 + +报告器复用现有 `clockSyncV1`,只接受最新 `confident=true` 的 `[ClockSync] pong` 样本。日志中的 `offsetMs` 是 `iOS monotonic - Sony monotonic`,因此 Sony 事件映射为 `sonyMonoMs + offsetMs`。 + +Clock Sync 不可信时: + +- `commandToSonyReceiveMs`、自动切歌端到端和 Sony→iOS FullLyrics 指标不生成数值。 +- Sony 内部 `ready → pending → queue → send` 与 iOS 内部 `receive → decode → publish` 仍分别报告。 +- `CLOCK_SYNC_UNTRUSTED` 和对应 `missingCount` 明确增加,禁止用墙上时间补齐。 + +## 6. SLO + +| 指标 | p95 目标 | +|---|---:| +| iOS command enqueue → Sony command receive | ≤ 200 ms | +| T0 → iOS Track/Playback publish | ≤ 300 ms | +| T0 → 当前歌词 publish | ≤ 500 ms;stretch 300 ms | +| T0 → CurrentWord publish | ≤ 500 ms | +| T0 → cache/Preview 封面 publish | ≤ 800 ms;cache stretch 300 ms | +| T0 → HQ 封面 publish | ≤ 2500 ms | +| lyric ready → iOS FullLyrics publish | ≤ 2000 ms | +| lyric ready → pending flush | ≤ 100 ms | + +旧 Track/Generation 覆盖、错误 CurrentWord、错误封面、stale transfer accepted、重复控制执行和控制命令重连补发必须为 0。 + +## 7. Trace 阶段 + +Sony 覆盖控制接收/校验/派发、MediaSession Track、generation、runtime cache、PlaybackState、歌词请求/就绪/pending/full transfer、CurrentWord 调度/coalesce/drop/send、封面 detect/cache/offer/preview/HQ,以及 Notify enqueue/dequeue/send/callback/timeout/preempt/cancel。 + +iOS 覆盖命令 intent/enqueue/write/callback/timeout、status notify、PlaybackState decode/publish、Track Identity、当前歌词、CurrentWord、LyricWindow、FullLyrics、Artwork offer/cache/preview/HQ,以及主播放器真正消费新状态的 `nowPlayingViewStateChanged`。 + +SwiftUI `body` 不写盘、不读取完整日志、不增加 Timer,也不通过随机 identity 触发重建。 + +## 8. 报告工具与产物 + +普通、反向和自动场景: + +```bash +./tools/smoke/realtime_latency_v4_test.sh --runs 30 --json +./tools/smoke/realtime_latency_v4_test.sh --runs 30 --previous --json +./tools/smoke/realtime_latency_v4_test.sh --runs 30 --auto --json +``` + +快速切歌: + +```bash +./tools/smoke/realtime_latency_v4_test.sh --runs 100 --fast-switch --json +``` + +每轮写入 `/tmp/musicble_realtime_v4//`:`report.json`、`summary.md`、`sony_trace.log`、`ios_trace.log`、`raw_events.jsonl`,并保留两端完整原始日志供本地审计。真实设备日志不提交 Git。 + +## 9. 报告字段与分类 + +每个指标输出 `count/min/avg/p50/p95/p99/max/missingCount`。指标包括命令、Track、当前歌词、CurrentWord、Preview、HQ、lyric ready/pending、FullLyrics、Notify queue、iOS decode/publish;另外输出 `lyricReadyToFullLyricsPublishMs` 直接验收后台完整歌词 SLO。 + +分类固定包含 `COMMAND_DELAY`、`TRACK_IDENTITY_DELAY`、`LYRIC_READY_DELAY`、`PENDING_FLUSH_DELAY`、`SEND_QUEUE_DELAY`、`CURRENT_WORD_DELAY`、`ARTWORK_DELAY`、`IOS_DECODE_DELAY`、`IOS_PUBLISH_DELAY`、`CLOCK_SYNC_UNTRUSTED`、`TRACE_INCOMPLETE`、`STALE_CONTENT`。 + +正确性诊断另列 `duplicate_control`、`control_reconnect_resend`、`wrong_current_word`、`wrong_artwork`、`wrong_lyrics` 和 stale generation。Top 10 慢样本不包含媒体正文。 + +## 10. 测试场景 + +硬基线包含至少 30 次 NEXT、30 次 PREVIOUS、30 次 Sony 本机自动等价切歌、100 次 650 ms 快速交替切歌。报告同时记录 runtime/歌词/封面 cache 命中与未命中、Preview/HQ、playing/paused、iOS 前台/后台恢复和控制器数量。 + +双控制器只有在第二台 Controller 真机实际可用时才执行;未执行必须标记 SKIPPED,不能写成 PASS。 + +## 11. 修改前真机基线 — BASELINE ONLY + +以下数据由本分支 Trace 代码、真实 iPhone 与 Sony 设备于 2026-08-24 生成。原始日志保留在本机 `/tmp/musicble_phase2_baseline/`,不提交仓库。常规 NEXT 场景 Clock Sync 不可信,因此跨设备 `commandToSonyReceiveMs` 明确标为不可用;其余单端与 iOS T0 指标仍有效。 + +| metric | count | p50 | p95 | p99 | max | missing | +|---|---:|---:|---:|---:|---:|---:| +| commandToSonyReceiveMs | 0 | — | — | — | — | 30 | +| commandToTrackPublishMs | 30 | 867 | 1344.6 | 1537.5 | 1592 | 0 | +| trackToCurrentLyricMs | 28 | 1296.5 | 3372.3 | 3620.1 | 3696 | 2 | +| trackToCurrentWordMs | 26 | 2011 | 3620.3 | 3920.3 | 4005 | 4 | +| trackToPreviewArtMs | 29 | 1940 | 2473.8 | 2632 | 2679 | 1 | +| trackToHqArtMs | 25 | 3507 | 4341.4 | 5007.7 | 5216 | 5 | +| lyricReadyToPendingFlushMs | 2 | 35.5 | 48.6 | 49.7 | 50 | 0 | +| pendingFlushToSendStartMs | 2 | 88 | 88.9 | 89 | 89 | 0 | +| fullLyricsSendDurationMs | 30 | 129 | 338.1 | 625.6 | 737 | 0 | +| iOSDecodeDurationMs | 864 | 0 | 1 | 1 | 2 | 0 | +| iOSPublishDurationMs | 127 | 0 | 7.7 | 14.4 | 22 | 0 | + +补充场景:PREVIOUS 30 次、Sony 自动等价切歌 30 次和 650ms 快速交替 100 次均生成 PASS 报告。快速场景只有 39/100 次在下一条命令前形成可观察身份切换,其余 61 次按定义记为 missing,不用后续切换冒充当前样本。 + +Top 3 瓶颈: + +1. Track Identity 发布:常规 NEXT p95 1344.6ms,明显高于 300ms SLO;iOS decode/publish p95 仅 1ms/7.7ms,瓶颈不在 SwiftUI 或 JSON 解码。 +2. Current lyric / CurrentWord 首帧:相对 Track 的 p95 分别为 3372.3ms/3620.3ms;需要优先排查身份确认后的歌词就绪与 CurrentWord 启动,而不是盲目调整 QRC 解析。 +3. Artwork 首帧:Preview/HQ p95 分别为 2473.8ms/4341.4ms;真实日志同时暴露了 Sony UI 对旧通知封面的错误 READY 晋升。 + +Notify queue p95 为 79.6ms,单次 iOS decode/publish 远低于 SLO,因此本轮不修改 chunk size、CurrentWord 周期或全局 notify 优先级。100 次压力场景还复现 3 次“Sony 已接收并成功响应,但 iOS 写回调缺失”触发的主动硬重连,这是连接展示抖动的直接证据。 + +## 12. 修改后数据与实际优化 + +Gate 1 只增加观测能力。Gate 2 使用同一台 iPhone、同一台 Sony 和相同的 100 次 650ms 快速交替切歌复测。两轮 Clock Sync 都不可信,因此跨设备命令接收继续标记为不可用,不用墙上时间填充。 + +修改后报告保存在本机 `/tmp/musicble_phase2_after_fix2_fast100/`,原始设备日志不提交仓库。 + +| metric | count | p50 | p95 | p99 | max | missing | +|---|---:|---:|---:|---:|---:|---:| +| commandToSonyReceiveMs | 0 | — | — | — | — | 100 | +| commandToTrackPublishMs | 46 | 359.5 | 621.8 | 628.1 | 629 | 54 | +| trackToCurrentLyricMs | 25 | 410 | 1230.8 | 5945.2 | 7414 | 21 | +| trackToCurrentWordMs | 1 | 1836 | 1836 | 1836 | 1836 | 45 | +| trackToPreviewArtMs | 46 | 387 | 650 | 655.7 | 657 | 0 | +| trackToHqArtMs | 30 | 394 | 652.2 | 656.1 | 657 | 16 | +| lyricReadyToPendingFlushMs | 0 | — | — | — | — | 0 | +| pendingFlushToSendStartMs | 0 | — | — | — | — | 0 | +| fullLyricsSendDurationMs | 37 | 177 | 319.2 | 381.9 | 392 | 6 | +| iOSDecodeDurationMs | 1119 | 0 | 1 | 1 | 2 | 0 | +| iOSPublishDurationMs | 144 | 1 | 20.9 | 23 | 26 | 0 | + +同场景 p95 对比: + +| metric | before p95 | after p95 | change | 结论 | +|---|---:|---:|---:|---| +| command → Track publish | 631.1ms | 621.8ms | -1.5% | 无明显回退,仍高于 300ms SLO | +| Track → current lyric | 635.5ms | 1230.8ms | +93.7% | 25 个样本含 1 个 7414ms 外部歌词就绪长尾;未宣称优化 | +| Track → CurrentWord | 1196.4ms | 1836ms | 样本 3→1 | 样本不足,不作性能结论 | +| Track → Preview | 663.1ms | 650ms | -2.0% | 保持在 800ms SLO 内 | +| Track → HQ | 631.3ms | 652.2ms | +3.3% | 保持在 2500ms SLO 内 | +| FullLyrics send | 337.4ms | 319.2ms | -5.4% | 无协议或队列参数改动 | +| Notify queue | 124ms | 143ms | +15.3% | 最大值由 887ms 降至 591ms;p95 仍是待优化项 | +| iOS decode | 1ms | 1ms | 0% | 已满足目标 | +| iOS publish | 18ms | 20.9ms | +15.8% | 绝对值仍远低于 100ms | + +实际优化严格限于证据指向的正确性和产品状态: + +1. CoreBluetooth 已连接且最近仍收到 notify 时,连续两次写回调超时仍执行必要的传输自愈,但在 8 秒保护窗内保持“已连接”展示;只有同步失败或保护窗到期才显示重连。修复前压力轮次出现 3 次可见假重连,修复后一次同类内部自愈期间可见假重连为 0,4.38 秒后恢复同步。 +2. iOS 对同一 Sony session 的 Track Identity 增加 generation 回退和同 generation 冲突栅栏;新 session 与 legacy generation=0 保持兼容。复测拒绝 2 个迟到身份事件,`STALE_CONTENT=0`。 +3. Sony PlayerAgent UI 改为监听既有 QQ 音乐通知事件,并按当前 title/artist 精确验证 MediaMetadata/Notification 后才晋升封面。轨道变化先进入 LOADING,迟到任务受 generation+songKey 栅栏约束;临时精确源缺失只保留同一当前歌曲已经 READY 的图。复测最后一次歌曲身份变化后约 85ms 显示匹配封面。 + +本轮没有修改 BLE delay、全局 priority、Timer、chunk size、图片编码质量或协议。验收采用“找到真实外部瓶颈并有可重复证据”:剩余 Track/歌词长尾位于 MediaSession/媒体源身份与歌词就绪,不在 iOS decode/publish;盲目调低 BLE delay 无法解决。 + +## 13. 正确性与 stale fence + +报告按 iOS 当前 `trackId + generation` 检查 CurrentWord、歌词和封面 publish。旧事件被明确 drop 是保护生效,不计作 stale accepted;只有旧 generation 或不匹配内容进入 accepted/published 阶段才计入 `STALE_CONTENT`。 + +Sony 同一 `commandSeq + commandType` 的多次 `mediaControlDispatchStart` 计作重复执行;同一控制命令多次 `commandReceived` 单列为可能的重连补发。既有 generation、transferId、CRC 和顺序栅栏保持不变。 + +100 次复测结果:stale accepted 0、wrong currentWord 0、wrong artwork 0、wrong lyrics 0、duplicate control 0、control reconnect resend 0。快速切歌中被 iOS generation 栅栏拒绝的旧身份事件单独记为 `trackIdentityRejected=2`,不算 accepted。 + +## 14. CPU、内存、电量和日志 + +- 两端各 2048 条固定 Ring Buffer,目标常驻增量小于 2 MB。 +- iOS 复用 `AppLogStore` 串行异步缓冲与 2 MB 文件上限;Sony 使用单线程 daemon 日志执行器。 +- Debug/Smoke 才启用完整事件流;Release 不产生完整事件流。 +- 普通模式 BLE notify 数量不增加,Timer 不增加。 +- 连接展示保护只复用命令超时和既有连接健康事件;没有新增轮询或 Timer。 +- Sony 封面刷新复用 `PlayerNotificationListenerService` 已存在的通知回调和单线程 UI media executor;没有新增周期扫描,精确源不匹配时不解码或发布图片。 +- Trace 不做 JSON pretty print,不做主线程同步 I/O,不解码图片,不参与重连。 +- 诊断 UI 只读已有 Snapshot 并手动刷新,不绘制实时图表。 +- Android lint 在起始 commit 实测为 PlayerAgent 20 errors / 34 warnings,修改后为 20 errors / 33 warnings;Controller 修改前后均为 0 errors / 30 warnings。本阶段没有新增 lint,新增 `AlbumArtIdentityPolicy` 文件为 0 issue。PlayerAgent lint 仍因历史 20 errors 以非零退出,未用 baseline 或 suppress 隐藏。 + +## 15. BLE 协议审计 + +本阶段不修改 BLE UUID、characteristic 用途、命令名称、A1/A2 header、FullLyrics JSON、LyricSecondary schema 或 QRC Triple DES。Trace 只写本地日志;没有新增 notify、字段或兼容分支,旧控制端行为不变。 + +## 16. 已知边界与未完成瓶颈 + +- Clock Sync 样本不足时端到端跨设备指标不可用,这是可信度保护,不是 0 ms。 +- 快速切歌可能被播放器或 MediaSession 合并;无法关联的样本记为 missing,不选择最好一次。 +- QRC 解密/解析若重新成为 Top 3,将拆为 Phase 2.1,不在本阶段顺手重写。 +- 常规 NEXT 的 Track、歌词、CurrentWord、Preview/HQ 仍未全部达到目标;第二阶段完成的是 CI、可重复 Trace/SLO、正确性栅栏和基于证据的首轮修复,不宣称所有端到端 SLO 已达标。 +- 快速场景 CurrentWord 可关联样本只有 1 个,不能据此判断改善或回退。 +- 双控制器真机未执行,必须保持 SKIPPED。 + +## 17. 下一阶段建议 + +下一阶段应优先使用预测命中、精确缓存和传输消除处理 MediaSession 身份确认、歌词就绪与封面首帧长尾。若继续做第二阶段微调,仍只处理事件证据指向的 ready→pending、pending→queue、CurrentWord 启动或 Preview/HQ 竞争;每项优化必须保留 stale fence,并用同一场景输出 before/after p95 与正确性计数。 + +## 18. 第三阶段结果索引 + +第三阶段实现和数据见 [PREDICTIVE_MEDIA_ENGINE_V4.md](/Volumes/雷电/project/MusicBleController/docs/PREDICTIVE_MEDIA_ENGINE_V4.md)。正式 Prediction Source Audit 采集 239 次转换,但当前 Sony/QQ 音乐的 MediaSession queue available=0、activeQueueItemId available=0,高置信候选覆盖率为 0,所以 Warm Path 全部标记 `NOT APPLICABLE`,跨端 prefetch Gate 跳过。 + +已落地的可测收益是 `mediaCacheValidationV1`:iOS cache v3 分离 Sony 远端 QRC fingerprint 与本地持久化正文 fingerprint,精确校验 schema/原文及 secondary 行数;命中时只发轻量 not-modified 并继续 CurrentLine/CurrentWord,不再传 A2/legacy 正文。最终 100 个快速控制观察到 30 次传输跳过、估算节省 538,028 bytes、Preview cache hit 62;stale accepted、wrong CurrentWord、wrong artwork、visible false positive、duplicate control 和 cold fallback failure 均为 0。 + +最终压力还统一了 TrackInfo、PlaybackState、歌词、CurrentWord 和 A1 封面的 wire generation,并为 command write response 预留 radio window。封面 binary 使用 15ms 最小 pacing 后,313/313 次 command write 获得 callback,write timeout、response failed 和 L2CAP congestion 均为 0;切歌期间没有虚假“正在连接”,也没有 Sony/iOS 新旧封面 generation 分叉。 + +Cold Path 最终 p95:command→Track 632.6ms(相对 621.8ms +1.7%)、Track→current lyric 1210.1ms(-1.7%)、Track→Preview 707.2ms(+8.8%)、Track→HQ 707.2ms(+8.4%)、iOS decode 1ms、publish 18.5ms。可比项均未超过第二阶段 10% 回退边界,Preview/HQ 仍满足 Cold SLO;CurrentWord 只有 1 个 362ms 可关联样本,不据此宣称统计改善。 + +## 19. 第四阶段 Cold-Path 结果 + +第四阶段为每次转换增加本地 `handoffId` 和触发类型,并把 Track、current lyric、CurrentWord 拆到 command、MediaSession、identity、playback、queue、notify、iOS decode/publish/UI consume 的完整闭环。Clock Sync sample count 在一次运行内回退即判定整轮跨设备时钟不可信;启动/重连的同 Track refresh 不计入 Handoff。 + +包含后来已回退 response gate 的历史 30 次场景 p95: + +| metric | NEXT | PREVIOUS | Sony dispatch-next | Phase4 target | +|---|---:|---:|---:|---| +| command/Track T0 → Track publish | 1025.9ms | 1005.2ms | 148.2ms | 远程 ≤350ms,未满足 | +| Track → current lyric | 115.2ms | 94.3ms | 107.8ms | ≤500ms,满足 | +| word eligible → published | 193.0ms | 282.4ms | 279.1ms | ≤250ms,仅 NEXT 满足 | +| immediately eligible Track → first word | 172.2ms | 146.8ms | 225.4ms | ≤500ms,满足 | +| Track → Preview/HQ | 68.3ms | 79.4ms | 66.5ms | ≤800/2500ms,满足 | + +Sony scheduler 段已经收敛:eligible → scheduler p95 0ms、scheduler → enqueue p95 ≤1ms、enqueue → send p95 0ms。current lyric 的 lyrics ready 事件现在直接触发精确 current PlaybackState;CurrentWord 使用独立 executor、lyrics-ready 事件屏障和 250ms fallback,generation/sequence/position/anchor fence 不变。 + +远程 Track SLO 的主要剩余段是 QQ 音乐/MediaSession 的 dispatch → metadata observed(NEXT 773.3ms、PREVIOUS 781.4ms),不是 iOS decode/publish 或 Sony queue。PREVIOUS/Sony dispatch-next 的 word eligible → publish 目标差约 32/29ms,主要在 send 后到 CoreBluetooth 接收/接受;不得继续盲目调低 Sony scheduler 或图片 pacing。 + +第四阶段曾实验把合法 ATT command response 与同设备在途 notify 串行化到 callback 边界;该构建的 90 次转换与 100 次压力虽未观察到 command failure,但随后安装验证出现 iOS 和 Android Controller 媒体/控制回归,因此该实验由 `1154397` 回退,不能再作为当前最终结论。当前实现保持即时 ATT response,并通过 iOS 合并过期 PlaybackState fallback、Sony 在正式 trackId 变化前延迟 TrackInfo/AlbumArt 来减少竞争,未改变协议。 + +回退后的当前提交又完成 NEXT、PREVIOUS、Sony dispatch-next 各 30 次:三轮 Handoff 均 30/30 PASS,Track publish p95 分别为 1026.0ms、640.0ms、145.0ms;`lyrics ready → current line enqueue` p95 为 69.5ms、21.8ms、43.8ms。TrackInfo 现始终走普通 P0 队列,Clock Sync 探针在大媒体接收时有界延后并成对恢复。100 次快速压力中 100/100 写回调,stale accepted、duplicate control、command timeout、hard reconnect 均为 0。 + +完整状态机、当前/历史修改前后数据、资源与未完成项见 [COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。120 分钟自然播放完成 30 次真实转换,但约第 97 分钟出现 4 次 L2CAP write failure、1 次 Sony 系统 Bluetooth HCI timeout/process death;PlayerAgent 在约 4.4 秒内恢复连接,但 Soak 仍为 FAIL。当前逐字测试歌单每场景仅产生 2 个立即 eligible 样本,双控制器矩阵也按当前优先级延期,因此第四阶段仍不能标记完整完成。 + +后续 authority-acceptance fast lane 在 Sony 已确认 MediaSession 新身份时先建立正式 runtime generation 并发布现有 TrackInfo,再继续歌词、capability 和诊断工作;没有新增协议、UUID、持续轮询或预测展示。NEXT/PREVIOUS 30 次的 command → Track p95 分别为 546.4ms/391.8ms,metadata observed → Track accepted p95 为 30.5ms/14.0ms;前者仍受 QQ 音乐暴露 metadata 时机限制。带逐字 QRC 的 90 秒专用窗口记录到 27 条 iOS accepted,日志派生 CurrentWord latency p95=114ms、stale=0、main stall=0;该稳态结果不替代统一 Trace 的切歌首字验收。100 次快速交替切歌以 `STALE_CONTENT=0/duplicate_control=0` PASS。 + +2026-09-05 最终安装版 NEXT 5 次的 Trace 完整性仍为 PASS,但 command → Track p95=3153.6ms(dispatch → metadata p95=2838.2ms),Preview p95=2643.2ms,无 word eligible 样本。小样本保留为长尾反例,不能仅引用前一日 30 次数据宣称整体稳定达标;详见第四阶段报告第 21 节。 diff --git a/docs/RECONNECT_HEALTH_ARCHITECTURE.md b/docs/RECONNECT_HEALTH_ARCHITECTURE.md index 494b337..a36cd71 100644 --- a/docs/RECONNECT_HEALTH_ARCHITECTURE.md +++ b/docs/RECONNECT_HEALTH_ARCHITECTURE.md @@ -25,6 +25,10 @@ 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。 +11. Sony 对合法 command 保持即时 ATT response,随后按设备保留短 quiet window;业务命令继续异步执行。iOS 会取消已经过时的普通播放状态 fallback,但保护前台验证和 Health probe,避免频繁切歌把无用状态读取堆在真实控制之前。 ## 关键状态 @@ -40,6 +44,7 @@ - `connectionHealthState`:内部健康,如 `healthy`、`suspect`、`stale`、`disconnected`。 - `autoReconnectState`:`idle`、`reconnectScheduled`、`scanning`、`connecting` 等。 - `connectionAttemptId`:防止旧 scan/connect 回调污染当前状态。 +- `coreBluetoothRestoreInProgress`:隔离状态恢复和前台检查的竞态。 ## 不允许随便修改的点 @@ -48,16 +53,23 @@ - 不要让 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 序号应保持不变。 +- 高频切歌 write callback 缺失:同时检查 iOS `commandIntent/commandWriteStart/commandWriteCallback/commandRefreshSuperseded`、Sony `commandReceived`、notify callback 和 L2CAP failure。当前实现应即时响应 command,不应出现 `commandResponseDeferred/Released`。 +- 前后台恢复:看 `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。 + +第四阶段旧真机轮次曾在延迟 response 实验下观察到 90 次正常转换和 100 次压力中无 command failure,但该实验安装后又造成 iOS/Android 媒体与控制回归,已经回退,不能继续作为当前实现的验收结论。当前实现保持即时 ATT response,只合并 iOS 过期 fallback、让 Sony 在新身份确认前不广播旧 TrackInfo/封面,并把 Clock Sync 后台探针避让 FullLyrics/图片接收。回退后的 NEXT、PREVIOUS、Sony dispatch-next 各 30 次及 100 次压力均为 PASS;command timeout、hard reconnect、stale accepted 和 duplicate control 均为 0。修复边界见 [COLD_PATH_HANDOFF_V4.md](/Volumes/雷电/project/MusicBleController/docs/COLD_PATH_HANDOFF_V4.md)。 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/docs/V4_ACCEPTANCE_STATUS.md b/docs/V4_ACCEPTANCE_STATUS.md new file mode 100644 index 0000000..e1551da --- /dev/null +++ b/docs/V4_ACCEPTANCE_STATUS.md @@ -0,0 +1,65 @@ +# V4 阶段性交付与未达标指标 + +更新日期:2026-09-05。运行时证据基于提交 `8c55cd4234bc0c20c05f95f1e4ade429d3d08b7d`;本次合并准备只增加验收说明和 `master` CI 触发,不修改应用业务或协议。 + +## 主干接纳决定 + +维护者明确决定先将现有 V4 成果合并到 `master`,随后继续优化。该决定允许提前集成,不降低原验收阈值,也不把 FAIL、SKIPPED 或 NOT APPLICABLE 改为 PASS。 + +本次定位为 **V4 阶段性交付**,不是“V4 全量性能达标”或“第四阶段完成”。不创建正式发布标签。保留各阶段提交与回退记录,使用普通 merge commit;不 squash、rebase 或 force push。当前真机优化范围优先 iOS + Sony,Android Controller/双控制器的产品验收延期。 + +## 尚未满足的指标 + +以下时间单位均为 ms。9 月 4 日 NEXT/PREVIOUS 各 30 次是两个独立场景;9 月 5 日 NEXT 5 次是最终安装版补测,样本量较小,不与前两轮混算,也不隐藏其长尾反例。 + +| 项目 | 原目标 | 实测与证据 | 当前状态 | +|---|---|---|---| +| command → Track publish | p95 ≤350 | NEXT 30 次 546.4;PREVIOUS 30 次 391.8;最终 NEXT 5 次 3153.6(max 3591) | FAIL,长尾仍不稳定 | +| Track → current lyric | p95 ≤500 | NEXT 426.8(28 个);PREVIOUS 566.6(25 个);最终 NEXT 768.3(4 个) | 部分场景 FAIL,另有无本地 QRC/无当前行的覆盖缺口 | +| word eligible → publish | p95 ≤250,正常场景足够有效样本 | NEXT 189.0(4 个)、PREVIOUS 149.6(13 个);未达到每场景 30 个有效样本 | 样本内 p95 达标,正式验收未完成 | +| Track → first CurrentWord | 立即 eligible 场景 p95 ≤500,并按前奏/无逐字等原因分类 | NEXT 只有 2 个首字样本,p95 606.6;PREVIOUS 聚合 11 个,p95 1912,仍需分离等待分类;最终 NEXT 无 eligible 样本 | 未通过;覆盖与可比分类不足,不用稳态逐字数据替代 | +| Track → Preview | p95 ≤800 | NEXT 30 次 1338.5;PREVIOUS 缓存路径 92.2;最终 NEXT 5 次 2643.2 | 冷路径 FAIL | +| Track → HQ | p95 ≤2500 | NEXT 30 次 3600.5;PREVIOUS 缓存路径 92.2;最终 NEXT 2927.3(3 个,另缺 2 个) | 冷路径 FAIL,补测另有覆盖缺口 | +| 两小时稳定性 | 0 crash / hard reconnect / L2CAP failure | 历史 120 分钟轮次约第 97 分钟出现 4 次 L2CAP write failure、1 次 Sony 系统 Bluetooth HCI timeout/process death;约 4.4 秒恢复 | FAIL;PlayerAgent 自身无 crash/ANR 不抵消系统蓝牙失败 | +| 自然下一首跨端 SLO | 可信时钟和完整逐次 Trace | 120 分钟有 30 次真实自然转换;恢复的 iOS Trace 仅 7 个完整 UI Handoff,且蓝牙重启后时钟不可信 | NOT APPLICABLE,需重测,不能宣称自然场景达标 | +| Sony lint | 不增加历史问题;历史债务单独保留 | 20 errors / 33 warnings,未增加、未 suppress | lint 仍 FAIL(历史基线);“无新增”不等于 lint 全绿 | + +专用逐字 QRC 的 90 秒窗口记录了 27 条 iOS accepted,日志派生 latency p95=114、未观察到 stale/main stall。该窗口是稳态、可能节流的日志证据,不是切歌首字完整 SLO 证明。 + +## 明确延期或不适用的能力 + +- Android Controller 与双控制器混合 capability 真机矩阵:SKIPPED(当前范围)。CI 中 Controller 单测和构建通过不能替代真机并发验证。 +- 跨端预测预取、候选封面预热、iOS PredictiveMediaCache:SKIPPED。239 次转换的预测来源审计没有发现可用 MediaSession queue/activeQueueItemId,高置信候选覆盖率为 0。 +- Predictive Warm Path 的 ≤150/250/350/300ms 目标:NOT APPLICABLE。保留本地事件化索引预热、精确歌词缓存校验和 FullLyrics skip,不通过猜歌补齐功能。 +- 完整二进制控制协议、安全配对/受信任设备、BOOT_COMPLETED:后续独立范围,不随这次主干集成宣称完成。 + +## 已通过的检查及边界 + +- `8c55cd4` 的 [GitHub Actions](https://github.com/a3384379/MusicBleController/actions/runs/33938041361):iOS simulator XCTest、Sony 和 Android Controller 单测/Debug 构建均 PASS。 +- 最终 Sony APK 已覆盖安装;Sony 本地单测、Debug 构建 PASS。两端 quick smoke 均 PASS:Sony Required 8/8、Optional 5 PASS / 0 WARN / 2 SKIPPED;iOS Required 6/6、Optional 4 PASS / 4 WARN / 1 SKIPPED。 +- quick 脚本跳过构建/安装;Sony 构建/安装另行完成。本批没有重新安装 iOS 或执行 iOS 全量 smoke。iOS CurrentWord 本轮 quick 为 WARN,不能写成通过。 +- 100 次快速交替压力:100 个控制意图、53 次真实身份切换、39 个完整 Handoff 样本;`STALE_CONTENT=0`、`duplicate_control=0`、`malformed=0`。只用于这轮正确性验证,不等于 100 个正常性能样本或两小时稳定性通过。 +- 时延报告脚本单测 29/29 PASS;`git diff --check` PASS。 +- 本次合并准备不修改 BLE UUID、command/status 语义、A1/A2 Header 或应用运行路径。V4 历史上的 capability-gated 缓存校验等增量协议见 [BLE 协议](BLE_PROTOCOL.md),不能笼统声称整个 V4 从未有协议增强。 +- CI push 触发补充实际默认分支 `master`,保留 `main`、`codex/**` 和 PR 触发。源分支 CI、PR 集成结果与合并后的 master CI 应分别核对,不用源分支绿色冒充合并提交已通过。 +- 当前工作区的 4 个 `tools/deploy/` 未提交文件不在本次合并内容中;不会自动提交、清理或覆盖。 + +## 后续验收清单 + +- [ ] 分离 command dispatch → MediaSession metadata 的播放器等待与本地回调排队,复测 NEXT/PREVIOUS 正常场景。 +- [ ] 拆解封面请求/准备/编码前等待与 BLE 正式传输,修复冷 Preview/HQ 长尾;保持精确身份与 generation 栅栏。 +- [ ] 使用本地确有逐字 QRC 的测试集,补齐各场景 30 个有效首字/eligible 样本;独立记录前奏、无 QRC、无逐字与无当前行。 +- [ ] 定位 Sony 系统蓝牙栈故障,并用可持续保存、时钟可信的完整 Trace 重新完成两小时自然播放验收。 +- [ ] 在后续明确恢复双端优先级时完成 Android Controller/双控制器真实兼容矩阵。 +- [ ] 单独清理 Sony 历史 lint 债务,不通过整体 suppress 隐藏。 + +上述项只在有对应证据时关闭,不因合并到 `master` 自动关闭。 + +## 详细记录 + +- [第一阶段产品 UI](PRODUCT_UI_V2_IMPLEMENTATION.md) +- [第二阶段 Trace 与 SLO](REALTIME_SLO_V4.md) +- [第三阶段预测来源与缓存](PREDICTIVE_MEDIA_ENGINE_V4.md) +- [第四阶段完整测试与长尾记录](COLD_PATH_HANDOFF_V4.md) + +真实歌曲名、原始日志和个人设备标识只留在本机测试产物中,不纳入本次文档。 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/settings.gradle b/settings.gradle index 31da27a..5dc18f4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,22 +1,22 @@ pluginManagement { repositories { - maven { url 'https://maven.aliyun.com/repository/google' } - maven { url 'https://maven.aliyun.com/repository/public' } - maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } google() mavenCentral() gradlePluginPortal() + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + google() + mavenCentral() maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } - google() - mavenCentral() } } 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/prediction_source_audit_report.py b/tools/smoke/prediction_source_audit_report.py new file mode 100755 index 0000000..996ebbd --- /dev/null +++ b/tools/smoke/prediction_source_audit_report.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Build a privacy-safe V4 prediction-source audit from Sony device logs.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import statistics +from pathlib import Path +from typing import Any + + +TRACE_PREFIX = "[RealtimeTrace] " +RUNTIME_TRACK_RE = re.compile( + r"\[RuntimeCache\] track changed trackId=(\S+) songKey=(.*?) title=.*? generation=(\d+)" +) +QUEUE_DIAGNOSTIC_RE = re.compile( + r"queue diagnostic source=(\S+).*?hasQueue=(true|false).*?queueSize=(-?\d+)" + r".*?activeQueueId=(-?\d+)" +) +SELECTED_RE = re.compile( + r"\[PredictiveLyricsCandidate\] selected source=(\S+) confidence=([0-9.]+) " + r"title=(.*?) artist=(.*?) queueId=(-?\d+) mediaId=(.*?) reason=" +) +V4_SELECTED_RE = re.compile( + r"\[PredictionSource\] selected candidateId=(\S+) identityDigest=(\S+) " + r"source=(\S+) confidence=(CONFIRMED|STRONG|WEAK|NONE)" +) +PREDICTIVE_MEDIA_RE = re.compile( + r"\[PredictiveMedia\] stage=(\S+) candidateId=(\S+) identityDigest=(\S+) " + r"source=(\S+) confidence=(\S+) result=(\S+) reason=(.*)" +) +HISTORY_RE = re.compile( + r"\[PredictionSource\] history candidateId=(\S+) identityDigest=(\S+) " + r"confidence=WEAK count=(\d+)" +) +UNAVAILABLE_RE = re.compile( + r"\[(?:PredictiveLyricsCandidate|PredictionSource)\] " + r"source=(\S+) unavailable reason=([^ ]+)" +) +REJECTED_RE = re.compile( + r"\[(?:PredictiveLyricsCandidate|PredictionSource)\] " + r"rejected source=(\S+) reason=([^ ]+)" +) + + +def safe_digest(*values: str) -> str | None: + normalized = "|".join(" ".join(value.strip().lower().split()) for value in values) + if not normalized.strip("|"): + return None + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:24] + + +def parse_trace(line: str) -> dict[str, str] | None: + marker = line.find(TRACE_PREFIX) + if marker < 0: + return None + fields: dict[str, str] = {} + for token in line[marker + len(TRACE_PREFIX):].strip().split(): + if "=" not in token: + continue + key, value = token.split("=", 1) + fields[key] = value + return fields + + +def _identity_by_track_id(lines: list[str]) -> dict[str, str]: + identities: dict[str, str] = {} + for line in lines: + match = RUNTIME_TRACK_RE.search(line) + if not match: + continue + song_parts = match.group(2).split("|", 2) + if len(song_parts) < 2: + continue + digest = safe_digest(song_parts[0], song_parts[1]) + if digest: + identities[match.group(1)] = digest + return identities + + +def _new_pending() -> dict[str, Any]: + return { + "commandMonoMs": None, + "commandType": None, + "source": "none", + "numericConfidence": None, + "candidateIdentityDigest": None, + "candidateId": None, + "declaredConfidence": None, + "candidateQueueId": -1, + "candidateMediaIdAvailable": False, + "candidateTitleAvailable": False, + "candidateArtistAvailable": False, + "queueAvailable": False, + "queueSize": -1, + "activeQueueItemIdAvailable": False, + "rejectedReason": "no_candidate", + } + + +def classify_confidence(pending: dict[str, Any]) -> str: + if pending["declaredConfidence"] in {"CONFIRMED", "STRONG", "WEAK", "NONE"}: + return pending["declaredConfidence"] + if pending["source"] == "none": + return "NONE" + if pending["source"] == "history_transition": + return "WEAK" + complete_identity = ( + pending["candidateIdentityDigest"] is not None + and pending["candidateTitleAvailable"] + and pending["candidateArtistAvailable"] + ) + if ( + pending["queueAvailable"] + and pending["activeQueueItemIdAvailable"] + and pending["candidateQueueId"] >= 0 + and pending["candidateMediaIdAvailable"] + and complete_identity + ): + return "CONFIRMED" + if pending["queueAvailable"] and pending["candidateQueueId"] >= 0 and complete_identity: + return "STRONG" + return "WEAK" + + +def analyze(sony_text: str, expected_transitions: int = 100) -> dict[str, Any]: + lines = sony_text.splitlines() + identity_by_track = _identity_by_track_id(lines) + pending = _new_pending() + transitions: list[dict[str, Any]] = [] + queue_observations = 0 + queue_available_observations = 0 + active_queue_id_observations = 0 + source_counts: dict[str, int] = {} + rejected_reason_counts: dict[str, int] = {} + seen_trace_events: set[tuple[str, str, str, str]] = set() + pending_queue_observed = False + pending_rejections: set[tuple[str, str]] = set() + exact_identity_candidate_ids: set[str] = set() + mismatched_candidate_ids: set[str] = set() + + for line in lines: + trace = parse_trace(line) + if trace: + stage = trace.get("stage") + trace_key = ( + stage or "", + trace.get("monoMs", ""), + trace.get("commandSeq", ""), + trace.get("trackId", ""), + ) + if trace_key in seen_trace_events: + continue + seen_trace_events.add(trace_key) + command_type = trace.get("commandType") + if stage == "commandReceived" and command_type in {"NEXT", "PREVIOUS"}: + pending = _new_pending() + pending["commandMonoMs"] = int(trace.get("monoMs", "0") or 0) + pending["commandType"] = command_type + pending_queue_observed = False + pending_rejections.clear() + continue + if stage == "mediaSessionTrackChanged": + actual_mono = int(trace.get("monoMs", "0") or 0) + actual_track_id = trace.get("trackId", "") + confidence = classify_confidence(pending) + candidate_digest = pending["candidateIdentityDigest"] + actual_digest = identity_by_track.get(actual_track_id) + matched = None + if candidate_digest is not None and actual_digest is not None: + matched = candidate_digest == actual_digest + lead_time = None + command_mono = pending["commandMonoMs"] + if candidate_digest is not None and command_mono is not None: + lead_time = max(0, actual_mono - command_mono) + transitions.append({ + "index": len(transitions) + 1, + "source": pending["source"], + "confidence": confidence, + "candidateIdentityAvailable": candidate_digest is not None, + "candidateIdentityDigest": candidate_digest, + "candidateId": pending["candidateId"], + "actualIdentityDigest": actual_digest or safe_digest(actual_track_id), + "matched": matched, + "rejectedReason": None if candidate_digest else pending["rejectedReason"], + "queueAvailable": pending["queueAvailable"], + "activeQueueItemIdAvailable": pending["activeQueueItemIdAvailable"], + "predictionLeadTimeMs": lead_time, + "commandType": pending["commandType"] or "AUTOMATIC", + }) + pending = _new_pending() + pending_queue_observed = False + pending_rejections.clear() + continue + + queue_match = QUEUE_DIAGNOSTIC_RE.search(line) + if queue_match: + is_new_queue_observation = not pending_queue_observed + if is_new_queue_observation: + queue_observations += 1 + pending_queue_observed = True + pending["source"] = queue_match.group(1) + pending["queueAvailable"] = queue_match.group(2) == "true" + pending["queueSize"] = int(queue_match.group(3)) + pending["activeQueueItemIdAvailable"] = int(queue_match.group(4)) >= 0 + if pending["queueAvailable"] and is_new_queue_observation: + queue_available_observations += 1 + if pending["activeQueueItemIdAvailable"] and is_new_queue_observation: + active_queue_id_observations += 1 + continue + + selected_match = SELECTED_RE.search(line) + if selected_match: + title = selected_match.group(3).strip() + artist = selected_match.group(4).strip() + media_id = selected_match.group(6).strip() + pending["source"] = selected_match.group(1) + pending["numericConfidence"] = float(selected_match.group(2)) + pending["candidateIdentityDigest"] = safe_digest(title, artist) + pending["candidateQueueId"] = int(selected_match.group(5)) + pending["candidateMediaIdAvailable"] = media_id not in {"", "none", "-"} + pending["candidateTitleAvailable"] = bool(title) + pending["candidateArtistAvailable"] = bool(artist) + pending["rejectedReason"] = "" + continue + + v4_selected_match = V4_SELECTED_RE.search(line) + if v4_selected_match: + confidence = v4_selected_match.group(4) + pending["candidateId"] = v4_selected_match.group(1) + pending["candidateIdentityDigest"] = v4_selected_match.group(2) + pending["source"] = v4_selected_match.group(3) + pending["declaredConfidence"] = confidence + pending["candidateTitleAvailable"] = confidence != "NONE" + pending["candidateArtistAvailable"] = confidence != "NONE" + pending["candidateMediaIdAvailable"] = confidence == "CONFIRMED" + pending["candidateQueueId"] = 0 if confidence in {"CONFIRMED", "STRONG"} else -1 + pending["rejectedReason"] = "" + continue + + predictive_match = PREDICTIVE_MEDIA_RE.search(line) + if predictive_match: + stage = predictive_match.group(1) + candidate_id = predictive_match.group(2) + reason = predictive_match.group(7).strip() + if stage in { + "predictionPromotionAttempt", + "predictionPromoted", + } or (stage == "predictionRejected" and reason in { + "prewarm_not_ready", + "cache_promotion_miss", + }): + exact_identity_candidate_ids.add(candidate_id) + elif stage == "predictionRejected" and reason == "identity_mismatch": + mismatched_candidate_ids.add(candidate_id) + continue + + history_match = HISTORY_RE.search(line) + if history_match: + pending["candidateId"] = history_match.group(1) + pending["candidateIdentityDigest"] = history_match.group(2) + pending["source"] = "history_transition" + pending["declaredConfidence"] = "WEAK" + pending["candidateTitleAvailable"] = True + pending["candidateArtistAvailable"] = True + pending["rejectedReason"] = "" + continue + + unavailable_match = UNAVAILABLE_RE.search(line) + if unavailable_match: + pending["source"] = unavailable_match.group(1) + pending["rejectedReason"] = unavailable_match.group(2) + rejection_key = (pending["source"], pending["rejectedReason"]) + if rejection_key not in pending_rejections: + pending_rejections.add(rejection_key) + rejected_reason_counts[pending["rejectedReason"]] = ( + rejected_reason_counts.get(pending["rejectedReason"], 0) + 1 + ) + continue + + rejected_match = REJECTED_RE.search(line) + if rejected_match: + pending["source"] = rejected_match.group(1) + pending["rejectedReason"] = rejected_match.group(2) + rejection_key = (pending["source"], pending["rejectedReason"]) + if rejection_key not in pending_rejections: + pending_rejections.add(rejection_key) + rejected_reason_counts[pending["rejectedReason"]] = ( + rejected_reason_counts.get(pending["rejectedReason"], 0) + 1 + ) + + for transition in transitions: + candidate_id = transition.get("candidateId") + if candidate_id in exact_identity_candidate_ids: + transition["matched"] = True + elif candidate_id in mismatched_candidate_ids: + transition["matched"] = False + source_counts[transition["source"]] = source_counts.get(transition["source"], 0) + 1 + + eligible = sum(1 for item in transitions if item["commandType"] in {"NEXT", "PREVIOUS"}) + candidates = [item for item in transitions if item["candidateIdentityAvailable"]] + high_confidence = [ + item for item in candidates if item["confidence"] in {"CONFIRMED", "STRONG"} + ] + resolved = [item for item in high_confidence if item["matched"] is not None] + hits = sum(1 for item in resolved if item["matched"] is True) + misses = sum(1 for item in resolved if item["matched"] is False) + lead_times = [ + item["predictionLeadTimeMs"] + for item in high_confidence + if item["predictionLeadTimeMs"] is not None + ] + coverage = len(high_confidence) / eligible if eligible else 0.0 + accuracy = hits / len(resolved) if resolved else None + sufficient = len(transitions) >= expected_transitions + cross_device_usable = bool( + sufficient + and high_confidence + and accuracy is not None + and accuracy >= 0.99 + ) + + return { + "schemaVersion": 1, + "result": "PASS" if sufficient else "FAIL", + "failureReason": None if sufficient else "transition_sample_incomplete", + "expectedTransitions": expected_transitions, + "sourceAudit": { + "transitions": len(transitions), + "predictionEligibleCount": eligible, + "predictionCandidateCount": len(candidates), + "highConfidenceCandidateCount": len(high_confidence), + "predictionHitCount": hits, + "predictionMissCount": misses, + "predictionCoverage": round(coverage, 6), + "highConfidenceAccuracy": round(accuracy, 6) if accuracy is not None else None, + "averageLeadTimeMs": round(statistics.mean(lead_times), 2) if lead_times else None, + "queueObservationCount": queue_observations, + "queueAvailableObservationCount": queue_available_observations, + "activeQueueItemIdAvailableCount": active_queue_id_observations, + "sourceCounts": source_counts, + "rejectedReasonCounts": rejected_reason_counts, + "crossDevicePrefetchWorthwhile": cross_device_usable, + "localEventPrewarmWorthwhile": True, + "conclusion": ( + "high_confidence_candidate_available" + if high_confidence + else "no_high_confidence_candidate_exposed" + ), + }, + "transitions": transitions, + } + + +def write_summary(report: dict[str, Any], output_path: Path) -> None: + audit = report["sourceAudit"] + accuracy = audit["highConfidenceAccuracy"] + lead = audit["averageLeadTimeMs"] + lines = [ + "# Prediction Source Audit V4", + "", + f"- Result: {report['result']}", + f"- Transitions: {audit['transitions']}/{report['expectedTransitions']}", + f"- Eligible: {audit['predictionEligibleCount']}", + f"- Candidates: {audit['predictionCandidateCount']}", + f"- High-confidence candidates: {audit['highConfidenceCandidateCount']}", + f"- Coverage: {audit['predictionCoverage']:.2%}", + f"- Accuracy: {accuracy if accuracy is not None else 'NOT APPLICABLE'}", + f"- Average lead time: {str(lead) + ' ms' if lead is not None else 'NOT APPLICABLE'}", + f"- Cross-device prefetch worthwhile: {audit['crossDevicePrefetchWorthwhile']}", + "", + "| source | confidence | coverage | accuracy | average lead time | local prewarm | cross-device prefetch |", + "|---|---|---:|---:|---:|---|---|", + ( + f"| MediaSession queue | CONFIRMED/STRONG | {audit['predictionCoverage']:.2%} | " + f"{accuracy if accuracy is not None else 'N/A'} | " + f"{str(lead) + ' ms' if lead is not None else 'N/A'} | yes | " + f"{'yes' if audit['crossDevicePrefetchWorthwhile'] else 'no'} |" + ), + "| Playback history adjacency | WEAK | N/A | N/A | N/A | index-only | no |", + "| Notification next metadata | NONE | 0% | N/A | N/A | no | no |", + "", + "## Conclusion", + "", + ( + "A real high-confidence candidate source is available. Continue with bounded local " + "prewarm; cross-device prefetch still requires a measured BLE-value gate." + if audit["highConfidenceCandidateCount"] + else "This device/application version did not expose a high-confidence next-track " + "candidate. Keep the normal cold path, implement only event-driven local cache " + "prewarm/validation, and do not add cross-device prefetch." + ), + "", + ] + output_path.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sony-log", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--expected-transitions", type=int, default=100) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + sony_text = Path(args.sony_log).read_text(encoding="utf-8", errors="replace") + report = analyze(sony_text, args.expected_transitions) + report_path = output_dir / "report.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + write_summary(report, output_dir / "summary.md") + if args.json: + print(report_path.read_text(encoding="utf-8"), end="") + return 0 if report["result"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/smoke/prediction_source_audit_v4.sh b/tools/smoke/prediction_source_audit_v4.sh new file mode 100755 index 0000000..98d10b9 --- /dev/null +++ b/tools/smoke/prediction_source_audit_v4.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUNS=100 +OUTPUT_DIR="" +IOS_DEVICE="" +ANDROID_DEVICE="" +JSON_MODE=false + +usage() { + cat <<'EOF' +Usage: prediction_source_audit_v4.sh [options] + +Options: + --runs Actual transitions required. Default: 100. + --ios-device iPhone devicectl identifier. + --android-device Sony adb serial. + --output Output directory. + --sony-log Analyze an existing Sony full log without driving devices. + --json Print report.json. + -h, --help Show this help. +EOF +} + +SONY_LOG="" +while [[ $# -gt 0 ]]; do + case "$1" in + --runs) RUNS="${2:?--runs requires a count}"; shift 2 ;; + --ios-device) IOS_DEVICE="${2:?--ios-device requires an id}"; shift 2 ;; + --android-device) ANDROID_DEVICE="${2:?--android-device requires an id}"; shift 2 ;; + --output) OUTPUT_DIR="${2:?--output requires a directory}"; shift 2 ;; + --sony-log) SONY_LOG="${2:?--sony-log requires a path}"; shift 2 ;; + --json) JSON_MODE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if ! [[ "$RUNS" =~ ^[1-9][0-9]*$ ]] || (( RUNS > 500 )); then + echo "--runs must be between 1 and 500" >&2 + exit 2 +fi + +timestamp="$(date +%Y%m%d_%H%M%S)" +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/musicble_prediction_audit_v4/$timestamp}" +mkdir -p "$OUTPUT_DIR" + +if [[ -z "$SONY_LOG" ]]; then + realtime_args=(--runs "$RUNS" --output "$OUTPUT_DIR/realtime") + if [[ -n "$IOS_DEVICE" ]]; then + realtime_args+=(--ios-device "$IOS_DEVICE") + fi + if [[ -n "$ANDROID_DEVICE" ]]; then + realtime_args+=(--android-device "$ANDROID_DEVICE") + fi + "$SCRIPT_DIR/realtime_latency_v4_test.sh" "${realtime_args[@]}" + SONY_LOG="$OUTPUT_DIR/realtime/sony_full.log" +fi + +if [[ ! -s "$SONY_LOG" ]]; then + echo "Sony log is missing or empty: $SONY_LOG" >&2 + exit 1 +fi + +LC_ALL=C grep -E \ + '\[PredictionSource\]|\[PredictiveMedia\]|\[PredictiveLyricsCandidate\]|\[PredictiveLyrics\]|stage=(commandReceived|mediaSessionTrackChanged)|\[RuntimeCache\] track changed' \ + "$SONY_LOG" >"$OUTPUT_DIR/sony_events.log" || true + +report_args=( + --sony-log "$SONY_LOG" + --output-dir "$OUTPUT_DIR" + --expected-transitions "$RUNS" +) +if [[ "$JSON_MODE" == true ]]; then + report_args+=(--json) +fi +python3 "$SCRIPT_DIR/prediction_source_audit_report.py" "${report_args[@]}" + +echo "[PredictionSourceAuditV4] report=$OUTPUT_DIR/report.json" >&2 diff --git a/tools/smoke/predictive_media_report.py b/tools/smoke/predictive_media_report.py new file mode 100755 index 0000000..7e02518 --- /dev/null +++ b/tools/smoke/predictive_media_report.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Generate a privacy-safe Phase 3 predictive handoff report from V4 traces.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import statistics +from collections import Counter +from pathlib import Path +from typing import Any + + +TRACE_PREFIX = "[RealtimeTrace] " +PREDICTIVE_RE = re.compile( + r"\[PredictiveMedia\] stage=(\S+) candidateId=(\S+) identityDigest=(\S+) " + r"source=(\S+) confidence=(\S+) result=(\S+) reason=(.*)" +) + + +def parse_trace_lines(text: str, default_side: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + seen: set[tuple[Any, ...]] = set() + for source_line, line in enumerate(text.splitlines(), 1): + marker = line.find(TRACE_PREFIX) + if marker < 0: + continue + fields: dict[str, str] = {} + for token in line[marker + len(TRACE_PREFIX):].strip().split(): + if "=" in token: + key, value = token.split("=", 1) + fields[key] = value + try: + mono_ms = int(fields.get("monoMs", "")) + except ValueError: + continue + event = { + "side": fields.get("side", default_side), + "stage": fields.get("stage", ""), + "monoMs": mono_ms, + "trackId": none_if_dash(fields.get("trackId")), + "generation": int_or_none(fields.get("generation")), + "transferId": none_if_dash(fields.get("transferId")), + "payloadType": none_if_dash(fields.get("payloadType")), + "result": none_if_dash(fields.get("result")), + "reason": none_if_dash(fields.get("reason")), + "sourceLine": source_line, + } + identity = ( + event["side"], + event["stage"], + event["monoMs"], + event["trackId"], + event["generation"], + event["transferId"], + ) + if identity in seen: + continue + seen.add(identity) + events.append(event) + return events + + +def parse_predictive_lines(text: str) -> list[dict[str, str]]: + events = [] + seen = set() + for line in text.splitlines(): + match = PREDICTIVE_RE.search(line) + if not match: + continue + item = { + "stage": match.group(1), + "candidateId": match.group(2), + "identityDigest": match.group(3), + "source": match.group(4), + "confidence": match.group(5), + "result": match.group(6), + "reason": match.group(7).strip(), + } + identity = tuple(item.values()) + if identity not in seen: + seen.add(identity) + events.append(item) + return events + + +def int_or_none(value: str | None) -> int | None: + if value in {None, "", "-"}: + return None + try: + return int(value) + except ValueError: + return None + + +def none_if_dash(value: str | None) -> str | None: + return None if value in {None, "", "-"} else value + + +def percentile(values: list[int], fraction: float) -> float | int | None: + if not values: + return None + ordered = sorted(values) + rank = (len(ordered) - 1) * fraction + low = math.floor(rank) + high = math.ceil(rank) + if low == high: + return ordered[low] + result = ordered[low] + (ordered[high] - ordered[low]) * (rank - low) + return round(result, 2) + + +def metric(values: list[int], missing: int = 0) -> dict[str, Any]: + valid = [value for value in values if value >= 0] + return { + "count": len(valid), + "min": min(valid) if valid else None, + "avg": round(statistics.mean(valid), 2) if valid else None, + "p50": percentile(valid, 0.50), + "p95": percentile(valid, 0.95), + "p99": percentile(valid, 0.99), + "max": max(valid) if valid else None, + "missing": missing, + } + + +def load_json(path: str | None) -> dict[str, Any]: + if not path: + return {} + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def first_after( + events: list[dict[str, Any]], + start: dict[str, Any], + stages: set[str], +) -> dict[str, Any] | None: + candidates = [ + event for event in events + if event["side"] == "ios" + and event["stage"] in stages + and event["monoMs"] >= start["monoMs"] + and (not start["trackId"] or event["trackId"] == start["trackId"]) + and ( + start["generation"] is None + or event["generation"] is None + or event["generation"] == start["generation"] + ) + ] + return min(candidates, key=lambda event: event["monoMs"], default=None) + + +def ios_handoff_metrics(events: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + identities = sorted( + ( + event for event in events + if event["side"] == "ios" and event["stage"] == "trackIdentityAccepted" + ), + key=lambda event: event["monoMs"], + ) + targets = { + "trackToPlaybackPublish": {"playbackStatePublished", "trackIdentityAccepted"}, + "trackToCurrentLyric": {"currentLyricPublished", "lyricWindowPublished"}, + "trackToCurrentWord": {"currentWordPublished", "currentWordAccepted"}, + "trackToPreviewArt": {"previewPublished", "cachedArtworkPublished"}, + "trackToFullLyricsAvailable": {"fullLyricsPublished", "cachedLyricsPublished"}, + } + values: dict[str, list[int]] = {name: [] for name in targets} + missing: Counter[str] = Counter() + for identity in identities: + for name, stages in targets.items(): + if name == "trackToPlaybackPublish": + # iOS Track Identity is the local publication boundary. A Sony-to-iOS + # confirmation duration requires trusted cross-device clock mapping. + missing[name] += 1 + continue + target = first_after(events, identity, stages) + if target is None: + missing[name] += 1 + else: + values[name].append(target["monoMs"] - identity["monoMs"]) + return {name: metric(values[name], missing[name]) for name in targets} + + +def correctness(events: list[dict[str, Any]]) -> dict[str, int]: + def count_reason(*needles: str) -> int: + return sum( + 1 for event in events + if any(needle in (event.get("reason") or "") for needle in needles) + and event.get("result") in {"accepted", "published", "success"} + ) + + duplicate_controls = sum( + count - 1 for count in Counter( + (event["side"], event["stage"], event.get("transferId"), event["monoMs"]) + for event in events if event["stage"] == "mediaControlDispatchStart" + ).values() if count > 1 + ) + return { + "staleAccepted": count_reason("stale", "generation_mismatch"), + "wrongCurrentWord": count_reason("wrong_current_word"), + "wrongArtwork": count_reason("wrong_artwork"), + "visibleFalsePositive": count_reason("visible_false_positive"), + "duplicateControl": duplicate_controls, + "coldFallbackFailures": sum( + 1 for event in events + if event["stage"] == "predictionRejected" + and event.get("result") == "cold_fallback_failed" + ), + } + + +def analyze( + sony_text: str, + ios_text: str, + source_audit: dict[str, Any] | None = None, + realtime_report: dict[str, Any] | None = None, +) -> dict[str, Any]: + source_audit = source_audit or {} + realtime_report = realtime_report or {} + events = parse_trace_lines(sony_text, "sony") + parse_trace_lines(ios_text, "ios") + events.sort(key=lambda event: (event["side"], event["monoMs"], event["sourceLine"])) + predictive = parse_predictive_lines(sony_text) + stage_counts = Counter(event["stage"] for event in events) + sony_stage_counts = Counter( + event["stage"] for event in events if event["side"] == "sony" + ) + prediction_stage_counts = Counter(event["stage"] for event in predictive) + audit = source_audit.get("sourceAudit", {}) + high_confidence = int(audit.get("highConfidenceCandidateCount", 0) or 0) + warm_applicable = high_confidence > 0 + handoff = ios_handoff_metrics(events) + validation_hit_logs = re.findall( + r"\[FullLyricsCacheValidation\] hit\b[^\n]*bytesSaved=(\d+)", + sony_text, + ) + validation_miss_logs = re.findall( + r"\[FullLyricsCacheValidation\] miss\b", + sony_text, + ) + trace_saved_bytes = re.findall( + r"\[RealtimeTrace\][^\n]*\bstage=fullLyricsTransferSkipped\b[^\n]*\bbytesSaved=(\d+)", + sony_text, + ) + validation_hits = ( + len(validation_hit_logs) + if validation_hit_logs + else sony_stage_counts["cacheValidationHit"] + ) + validation_misses = ( + len(validation_miss_logs) + if validation_miss_logs + else sony_stage_counts["cacheValidationMiss"] + ) + transfer_skips = ( + len(validation_hit_logs) + if validation_hit_logs + else sony_stage_counts["fullLyricsTransferSkipped"] + ) + bytes_saved = ( + sum(int(value) for value in validation_hit_logs) + if validation_hit_logs + else sum(int(value) for value in trace_saved_bytes) + ) + cache = { + "fullLyricsCacheValidationHit": validation_hits, + "fullLyricsCacheValidationMiss": validation_misses, + "fullLyricsTransferSkipped": transfer_skips, + "fullLyricsBytesSaved": bytes_saved, + "previewCacheHit": sony_stage_counts["albumArtCacheHit"], + "previewPrefetchHit": sony_stage_counts["prefetchPromoted"], + "hqCacheHit": sony_stage_counts["hqCacheHit"], + } + accuracy = audit.get("highConfidenceAccuracy") + prediction = { + "eligibleTransitions": int(audit.get("predictionEligibleCount", 0) or 0), + "candidateCount": int(audit.get("predictionCandidateCount", 0) or 0), + "highConfidenceCount": high_confidence, + "hitCount": int(audit.get("predictionHitCount", 0) or 0), + "missCount": int(audit.get("predictionMissCount", 0) or 0), + "accuracy": accuracy, + "visibleFalsePositive": correctness(events)["visibleFalsePositive"], + "expiredCount": prediction_stage_counts["predictionExpired"], + "rejectedCount": prediction_stage_counts["predictionRejected"], + "prewarmQueued": prediction_stage_counts["predictionPrewarmQueued"], + "prewarmReady": prediction_stage_counts["predictionReady"], + "prewarmPromoted": prediction_stage_counts["predictionPromoted"], + } + result = "PASS" + failure_reason = None + if source_audit and source_audit.get("result") != "PASS": + result = "FAIL" + failure_reason = "prediction_source_audit_failed" + if realtime_report and realtime_report.get("result") != "PASS": + result = "FAIL" + failure_reason = failure_reason or "realtime_trace_failed" + correctness_metrics = correctness(events) + if any(correctness_metrics.values()): + result = "FAIL" + failure_reason = failure_reason or "correctness_regression" + + return { + "schemaVersion": 1, + "result": result, + "failureReason": failure_reason, + "prediction": prediction, + "prewarm": { + "queued": prediction["prewarmQueued"], + "ready": prediction["prewarmReady"], + "promoted": prediction["prewarmPromoted"], + "expired": prediction["expiredCount"], + "cancelled": prediction_stage_counts["predictionInvalidated"], + }, + "cache": cache, + "latency": { + "warmApplicable": warm_applicable, + "warmResult": "TRACE_CORRELATION_UNAVAILABLE" if warm_applicable else "NOT_APPLICABLE", + "warm": { + name: metric([], high_confidence if warm_applicable else 0) + for name in handoff + }, + "cold": handoff, + "phase2RealtimeMetrics": realtime_report.get("metrics", {}), + }, + "correctness": correctness_metrics, + "resources": { + "additionalMemoryBytes": None, + "prefetchBytesSent": 0, + "prefetchPacketsSent": 0, + "prefetchCancelledPackets": 0, + "timerCountChange": 0, + "mainThreadIOChange": 0, + }, + "trace": { + "eventCount": len(events), + "stageCounts": dict(sorted(stage_counts.items())), + "predictionStageCounts": dict(sorted(prediction_stage_counts.items())), + }, + } + + +def write_summary(report: dict[str, Any], path: Path) -> None: + prediction = report["prediction"] + cache = report["cache"] + lines = [ + "# Predictive Media V4 Report", + "", + f"- Result: {report['result']}", + f"- Warm path: {report['latency']['warmResult']}", + f"- Eligible transitions: {prediction['eligibleTransitions']}", + f"- High-confidence candidates: {prediction['highConfidenceCount']}", + f"- Accuracy: {prediction['accuracy'] if prediction['accuracy'] is not None else 'NOT APPLICABLE'}", + f"- Visible false positives: {prediction['visibleFalsePositive']}", + f"- FullLyrics validation hits: {cache['fullLyricsCacheValidationHit']}", + f"- FullLyrics transfers skipped: {cache['fullLyricsTransferSkipped']}", + f"- FullLyrics bytes saved: {cache['fullLyricsBytesSaved']}", + "", + "Warm metrics remain NOT APPLICABLE when the player exposes no high-confidence queue candidate.", + "All unavailable measurements are null/missing rather than fabricated as zero latency.", + "", + ] + path.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sony-trace", required=True) + parser.add_argument("--ios-trace", required=True) + parser.add_argument("--source-audit") + parser.add_argument("--realtime-report") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + sony_text = Path(args.sony_trace).read_text(encoding="utf-8", errors="replace") + ios_text = Path(args.ios_trace).read_text(encoding="utf-8", errors="replace") + report = analyze( + sony_text, + ios_text, + source_audit=load_json(args.source_audit), + realtime_report=load_json(args.realtime_report), + ) + (output_dir / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + write_summary(report, output_dir / "summary.md") + raw_events = parse_trace_lines(sony_text, "sony") + parse_trace_lines(ios_text, "ios") + (output_dir / "raw_events.jsonl").write_text( + "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in raw_events), + encoding="utf-8", + ) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["result"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/smoke/predictive_media_v4_test.sh b/tools/smoke/predictive_media_v4_test.sh new file mode 100755 index 0000000..e36920b --- /dev/null +++ b/tools/smoke/predictive_media_v4_test.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUNS=30 +FAST_SWITCH=false +DURATION_MINUTES=0 +OUTPUT_DIR="" +IOS_DEVICE="" +ANDROID_DEVICE="" +JSON_MODE=false + +usage() { + cat <<'EOF' +Usage: predictive_media_v4_test.sh [options] + +Options: + --runs Requested transition samples. Default: 30. + --fast-switch Alternate NEXT/PREVIOUS at 650 ms intervals. + --duration-minutes Repeat bounded 30-sample rounds for a soak test. + --ios-device iPhone devicectl identifier. + --android-device Sony adb serial. + --output Output directory. + --json Print report.json. + -h, --help Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --runs) RUNS="${2:?--runs requires a count}"; shift 2 ;; + --fast-switch) FAST_SWITCH=true; shift ;; + --duration-minutes) DURATION_MINUTES="${2:?--duration-minutes requires a count}"; shift 2 ;; + --ios-device) IOS_DEVICE="${2:?--ios-device requires an id}"; shift 2 ;; + --android-device) ANDROID_DEVICE="${2:?--android-device requires an id}"; shift 2 ;; + --output) OUTPUT_DIR="${2:?--output requires a directory}"; shift 2 ;; + --json) JSON_MODE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if ! [[ "$RUNS" =~ ^[1-9][0-9]*$ ]] || (( RUNS > 500 )); then + echo "--runs must be between 1 and 500" >&2 + exit 2 +fi +if ! [[ "$DURATION_MINUTES" =~ ^[0-9]+$ ]] || (( DURATION_MINUTES > 720 )); then + echo "--duration-minutes must be between 0 and 720" >&2 + exit 2 +fi + +timestamp="$(date +%Y%m%d_%H%M%S)" +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/musicble_predictive_v4/$timestamp}" +mkdir -p "$OUTPUT_DIR" +: >"$OUTPUT_DIR/sony_trace.log" +: >"$OUTPUT_DIR/ios_trace.log" +: >"$OUTPUT_DIR/sony_full.log" +: >"$OUTPUT_DIR/ios_full.log" + +run_round() { + local round_dir="$1" + local round_runs="$2" + local args=(--runs "$round_runs" --output "$round_dir") + if [[ "$FAST_SWITCH" == true || "$DURATION_MINUTES" -gt 0 ]]; then + args+=(--fast-switch) + fi + if [[ -n "$IOS_DEVICE" ]]; then args+=(--ios-device "$IOS_DEVICE"); fi + if [[ -n "$ANDROID_DEVICE" ]]; then args+=(--android-device "$ANDROID_DEVICE"); fi + if ! "$SCRIPT_DIR/realtime_latency_v4_test.sh" "${args[@]}" >/dev/null; then + echo "[PredictiveMediaV4] realtime round incomplete: $round_dir" >&2 + fi + for name in sony_trace.log ios_trace.log sony_full.log ios_full.log; do + if [[ -s "$round_dir/$name" ]]; then + cat "$round_dir/$name" >>"$OUTPUT_DIR/$name" + fi + done +} + +if (( DURATION_MINUTES > 0 )); then + deadline=$(( $(date +%s) + DURATION_MINUTES * 60 )) + round=0 + while (( $(date +%s) < deadline )); do + round=$((round + 1)) + run_round "$OUTPUT_DIR/round_$round" 30 + done + REALTIME_REPORT="" + EXPECTED_TRANSITIONS=1 +else + run_round "$OUTPUT_DIR/realtime" "$RUNS" + REALTIME_REPORT="$OUTPUT_DIR/realtime/report.json" + # Fast-switch is a control-pressure test: Android must receive every command, + # while the player is allowed to coalesce rapid commands into fewer track changes. + # The separate source audit remains responsible for collecting 100 real transitions. + if [[ "$FAST_SWITCH" == true ]]; then + EXPECTED_TRANSITIONS=1 + else + EXPECTED_TRANSITIONS="$RUNS" + fi +fi + +SOURCE_AUDIT_DIR="$OUTPUT_DIR/source_audit" +mkdir -p "$SOURCE_AUDIT_DIR" +if ! python3 "$SCRIPT_DIR/prediction_source_audit_report.py" \ + --sony-log "$OUTPUT_DIR/sony_full.log" \ + --output-dir "$SOURCE_AUDIT_DIR" \ + --expected-transitions "$EXPECTED_TRANSITIONS"; then + echo "[PredictiveMediaV4] prediction source audit incomplete" >&2 +fi + +report_args=( + --sony-trace "$OUTPUT_DIR/sony_full.log" + --ios-trace "$OUTPUT_DIR/ios_full.log" + --source-audit "$SOURCE_AUDIT_DIR/report.json" + --output-dir "$OUTPUT_DIR" +) +if [[ -n "$REALTIME_REPORT" && -s "$REALTIME_REPORT" ]]; then + report_args+=(--realtime-report "$REALTIME_REPORT") +fi +if [[ "$JSON_MODE" == true ]]; then report_args+=(--json); fi + +python3 - "$OUTPUT_DIR/compatibility_matrix.json" <<'PY' +import json +import sys +from pathlib import Path +matrix = { + "newSony_newIOS": "OBSERVED_IN_THIS_RUN", + "newSony_oldIOS": "NOT_RUN", + "oldSony_newIOS": "NOT_RUN", + "newSony_newIOS_oldController": "NOT_RUN", + "newControllerWithoutCapability": "NOT_RUN", + "mixedCapabilityDualController": "NOT_RUN", +} +Path(sys.argv[1]).write_text(json.dumps(matrix, indent=2) + "\n", encoding="utf-8") +PY + +python3 "$SCRIPT_DIR/predictive_media_report.py" "${report_args[@]}" +echo "[PredictiveMediaV4] report=$OUTPUT_DIR/report.json" >&2 diff --git a/tools/smoke/realtime_latency_report.py b/tools/smoke/realtime_latency_report.py new file mode 100755 index 0000000..7f6b504 --- /dev/null +++ b/tools/smoke/realtime_latency_report.py @@ -0,0 +1,1463 @@ +#!/usr/bin/env python3 +"""Build the V4 realtime latency report from privacy-safe trace lines.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import statistics +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Callable, Iterable, Optional + + +TRACE_MARKER = "[RealtimeTrace] " +METRIC_NAMES = ( + "commandIntentToWriteStartMs", + "writeStartToCallbackMs", + "commandCallbackToSonyReceiveMs", + "sonyReceiveToDispatchEndMs", + "dispatchEndToMetadataObservedMs", + "metadataObservedToTrackAcceptedMs", + "trackAcceptedToPlaybackReadyMs", + "playbackReadyToQueueStartMs", + "playbackReadyToNotifyStartMs", + "playbackQueueWaitMs", + "playbackNotifyStartToCallbackMs", + "notifyCallbackToIosReceiveMs", + "iosReceiveToDecodeMs", + "iosDecodeToPublishMs", + "publishToUiConsumeMs", + "totalCommandToTrackPublishMs", + "trackAcceptedToLyricReadyMs", + "lyricReadyToCurrentLineEnqueueMs", + "currentLineEnqueueToPublishMs", + "trackAcceptedToWordEligibleMs", + "lyricReadyToWordEligibleMs", + "wordEligibleToSchedulerCreatedMs", + "schedulerCreatedToFirstEnqueueMs", + "firstEnqueueToSendMs", + "sendToReceiveMs", + "receiveToAcceptMs", + "acceptToPublishMs", + "wordEligibleToPublishMs", + "trackAcceptedToFirstWordMs", + "commandToSonyReceiveMs", + "commandToTrackPublishMs", + "trackToCurrentLyricMs", + "trackToCurrentWordMs", + "trackToPreviewArtMs", + "trackToHqArtMs", + "lyricReadyToPendingFlushMs", + "pendingFlushToSendStartMs", + "fullLyricsSendDurationMs", + "lyricReadyToFullLyricsPublishMs", + "previewSendDurationMs", + "notifyQueueWaitMs", + "iOSDecodeDurationMs", + "iOSPublishDurationMs", +) + +LYRIC_MISSING_REASONS = { + "NO_QRC_FILE", + "WAITING_QQMUSIC_CACHE", + "TRACK_IDENTITY_INCOMPLETE", + "EXACT_CACHE_MISS", + "PARSED_CACHE_INVALID", + "LYRIC_LOAD_RUNNING", + "NO_LINE_AT_POSITION", + "POSITION_ANCHOR_UNTRUSTED", + "GENERATION_MISMATCH", + "TRACK_MISMATCH", + "STALE_PACKET", + "IOS_CACHE_CORRUPTED", + "NOT_SUPPORTED", + "UNKNOWN", +} + + +@dataclass(frozen=True) +class Event: + side: str + stage: str + mono_ms: int + command_seq: Optional[int] = None + command_type: Optional[str] = None + track_id: Optional[str] = None + generation: Optional[int] = None + transfer_id: Optional[str] = None + payload_type: Optional[str] = None + queue_wait_ms: Optional[int] = None + processing_ms: Optional[int] = None + chunk_index: Optional[int] = None + chunk_count: Optional[int] = None + result: Optional[str] = None + reason: Optional[str] = None + handoff_id: Optional[str] = None + trigger_type: Optional[str] = None + position_anchor_ms: Optional[int] = None + line_index: Optional[int] = None + word_timing_status: Optional[str] = None + cache_source: Optional[str] = None + failure_reason: Optional[str] = None + source_line: int = 0 + + def as_dict(self) -> dict: + return { + "side": self.side, + "stage": self.stage, + "monoMs": self.mono_ms, + "commandSeq": self.command_seq, + "commandType": self.command_type, + "trackId": self.track_id, + "generation": self.generation, + "transferId": self.transfer_id, + "payloadType": self.payload_type, + "queueWaitMs": self.queue_wait_ms, + "processingMs": self.processing_ms, + "chunkIndex": self.chunk_index, + "chunkCount": self.chunk_count, + "result": self.result, + "reason": self.reason, + "handoffId": self.handoff_id, + "triggerType": self.trigger_type, + "positionAnchorMs": self.position_anchor_ms, + "lineIndex": self.line_index, + "wordTimingStatus": self.word_timing_status, + "cacheSource": self.cache_source, + "failureReason": self.failure_reason, + "sourceLine": self.source_line, + } + + +def _optional_text(value: Optional[str]) -> Optional[str]: + return None if value in (None, "", "-") else value + + +def _optional_int(value: Optional[str]) -> Optional[int]: + value = _optional_text(value) + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +def parse_trace_line(line: str, source_line: int = 0) -> Optional[Event]: + marker_index = line.find(TRACE_MARKER) + if marker_index < 0: + return None + fields = {} + for token in line[marker_index + len(TRACE_MARKER):].strip().split(): + if "=" not in token: + continue + key, value = token.split("=", 1) + fields[key] = value + side = _optional_text(fields.get("side")) + stage = _optional_text(fields.get("stage")) + mono_ms = _optional_int(fields.get("monoMs")) + if side is None or stage is None or mono_ms is None: + return None + return Event( + side=side, + stage=stage, + mono_ms=mono_ms, + command_seq=_optional_int(fields.get("commandSeq")), + command_type=_optional_text(fields.get("commandType")), + track_id=_optional_text(fields.get("trackId")), + generation=_optional_int(fields.get("generation")), + transfer_id=_optional_text(fields.get("transferId")), + payload_type=_optional_text(fields.get("payloadType")), + queue_wait_ms=_optional_int(fields.get("queueWaitMs")), + processing_ms=_optional_int(fields.get("processingMs")), + chunk_index=_optional_int(fields.get("chunkIndex")), + chunk_count=_optional_int(fields.get("chunkCount")), + result=_optional_text(fields.get("result")), + reason=_optional_text(fields.get("reason")), + handoff_id=_optional_text(fields.get("handoffId")), + trigger_type=_optional_text(fields.get("triggerType")), + position_anchor_ms=_optional_int(fields.get("positionAnchorMs")), + line_index=_optional_int(fields.get("lineIndex")), + word_timing_status=_optional_text(fields.get("wordTimingStatus")), + cache_source=_optional_text(fields.get("cacheSource")), + failure_reason=_optional_text(fields.get("failureReason")), + source_line=source_line, + ) + + +def load_events(paths: Iterable[Path]) -> tuple[list[Event], int]: + events = [] + malformed = 0 + source_line = 0 + for path in paths: + if not path.exists(): + continue + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + source_line += 1 + if TRACE_MARKER not in line: + continue + event = parse_trace_line(line, source_line) + if event is None: + malformed += 1 + else: + events.append(event) + return events, malformed + + +def discover_clock_sync(paths: Iterable[Path]) -> tuple[bool, Optional[int]]: + pattern = re.compile( + r"\[ClockSync\]\s+pong\b.*\boffsetMs=(-?\d+)\b.*\bsamples=(\d+)\b" + r".*\bconfident=(true|false)\b", + re.IGNORECASE, + ) + latest = None + previous_sample_count = None + reset_seen = False + for path in paths: + if not path.exists(): + continue + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + match = pattern.search(line) + if match: + sample_count = int(match.group(2)) + if (previous_sample_count is not None + and sample_count < previous_sample_count): + reset_seen = True + previous_sample_count = sample_count + latest = (match.group(3).lower() == "true", int(match.group(1))) + # One offset must never be applied across a hard reconnect. Even if the + # final segment later converges, its offset cannot validate events from the + # earlier segment; keep cross-device metrics unavailable for that run. + if reset_seen: + return False, None + return latest or (False, None) + + +def percentile(values: list[int], value: float) -> Optional[float]: + if not values: + return None + ordered = sorted(values) + rank = min(max(value, 0.0), 100.0) / 100.0 * (len(ordered) - 1) + lower = math.floor(rank) + upper = math.ceil(rank) + if lower == upper: + return float(ordered[lower]) + fraction = rank - lower + return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction + + +def summarize(values: list[int], missing: int) -> dict: + return { + "count": len(values), + "min": min(values) if values else None, + "avg": statistics.fmean(values) if values else None, + "p50": percentile(values, 50), + "p95": percentile(values, 95), + "p99": percentile(values, 99), + "max": max(values) if values else None, + "missing": missing, + "missingCount": missing, + } + + +def _track_key(event: Event) -> Optional[tuple]: + if not event.track_id: + return None + return (event.track_id[:12], event.generation) + + +def _track_id_key(event: Event) -> Optional[tuple]: + return (event.track_id[:12],) if event.track_id else None + + +def _ready_track_key(event: Event) -> Optional[tuple]: + if event.stage == "lyricReady" and event.result != "ready": + return None + return _track_key(event) + + +def _command_key(event: Event) -> Optional[tuple]: + if event.command_seq is None: + return None + return (event.command_seq, event.command_type) + + +def _control_command_key(event: Event) -> Optional[tuple]: + if event.command_type not in {"NEXT", "PREVIOUS"}: + return None + return _command_key(event) + + +def _transfer_key(event: Event) -> Optional[tuple]: + if event.transfer_id: + return (event.transfer_id, event.generation) + return _track_key(event) + + +def _command_or_handoff_key(event: Event) -> Optional[tuple]: + if event.handoff_id: + return ("handoff", event.handoff_id) + command = _command_key(event) + return ("command", *command) if command is not None else None + + +def _control_command_or_handoff_key(event: Event) -> Optional[tuple]: + is_control = event.command_type in {"NEXT", "PREVIOUS"} + is_control_handoff = ( + event.trigger_type in {"IOS_NEXT", "IOS_PREVIOUS"} + or bool(event.handoff_id and event.handoff_id.startswith("command-")) + ) + if not is_control and not is_control_handoff: + return None + return _command_or_handoff_key(event) + + +def _media_key(event: Event) -> Optional[tuple]: + track = _track_key(event) + if track is not None: + return ("track", *track) + if event.handoff_id: + return ("handoff", event.handoff_id) + return None + + +def _handoff_or_media_key(event: Event) -> Optional[tuple]: + if event.handoff_id: + return ("handoff", event.handoff_id) + return _media_key(event) + + +def first_correlated_events( + events: list[Event], + side: str, + stages: set[str], + canonical_stage: str, + key: Callable[[Event], Optional[tuple]], +) -> list[Event]: + first_by_key = {} + for event in sorted(events, key=lambda item: (item.mono_ms, item.source_line)): + if event.side != side or event.stage not in stages: + continue + event_key = key(event) + if event_key is None or event_key in first_by_key: + continue + first_by_key[event_key] = replace(event, stage=canonical_stage) + return list(first_by_key.values()) + + +def first_events_after( + events: list[Event], + starts: list[Event], + side: str, + stages: set[str], + canonical_stage: str, + key: Callable[[Event], Optional[tuple]], +) -> list[Event]: + selected = [] + for start in starts: + start_key = key(start) + if start_key is None: + continue + candidates = [ + event for event in events + if event.side == side + and event.stage in stages + and key(event) == start_key + and event.mono_ms >= start.mono_ms + ] + if candidates: + selected.append(replace( + min(candidates, key=lambda item: (item.mono_ms, item.source_line)), + stage=canonical_stage, + )) + return selected + + +def _same_track(left: tuple, right: tuple) -> bool: + left_id, left_generation = left + right_id, right_generation = right + return left_id == right_id and ( + left_generation is None + or right_generation is None + or left_generation == right_generation + ) + + +def paired_durations( + events: list[Event], + start_side: str, + start_stage: str, + end_side: str, + end_stage: str, + key: Callable[[Event], Optional[tuple]], + categories: Counter, + cross_device: bool = False, + clock_trusted: bool = False, + sony_to_ios_offset_ms: Optional[int] = None, + cross_device_tolerance_ms: int = 75, +) -> tuple[list[int], int]: + starts = [ + event for event in events + if event.side == start_side + and event.stage == start_stage + and key(event) is not None + ] + ends = [ + event for event in events + if event.side == end_side + and event.stage == end_stage + and key(event) is not None + ] + if cross_device and (not clock_trusted or sony_to_ios_offset_ms is None): + if starts or ends: + categories["untrusted_clock"] += max(len(starts), len(ends)) + return [], max(len(starts), len(ends)) + + ends_by_key = defaultdict(list) + for event in ends: + event_key = key(event) + if event_key is not None: + ends_by_key[event_key].append(event) + for grouped in ends_by_key.values(): + grouped.sort(key=lambda event: event.mono_ms) + + values = [] + used_end_lines = set() + missing = 0 + for start in sorted(starts, key=lambda event: event.mono_ms): + event_key = key(start) + start_ms = start.mono_ms + if cross_device and start.side == "sony": + start_ms += sony_to_ios_offset_ms or 0 + candidates = ends_by_key.get(event_key, []) + eligible = [] + for end in candidates: + end_ms = end.mono_ms + if cross_device and end.side == "sony": + end_ms += sony_to_ios_offset_ms or 0 + minimum_end_ms = ( + start_ms - cross_device_tolerance_ms + if cross_device else start_ms + ) + if end.source_line not in used_end_lines and end_ms >= minimum_end_ms: + eligible.append((end_ms, end)) + if not eligible: + if candidates: + categories["out_of_order"] += 1 + missing += 1 + continue + end_ms, end = eligible[0] + used_end_lines.add(end.source_line) + duration = end_ms - start_ms + if duration < 0: + categories["clock_uncertainty_clamped"] += 1 + duration = 0 + if duration > 120_000: + categories["extreme"] += 1 + values.append(duration) + # Additional or standalone end events are legitimate for streaming and + # cache-hit stages. A closure is incomplete only when a measured start has + # no matching end. + return values, missing + + +def derive_track_t0( + events: list[Event], + categories: Counter, + clock_trusted: bool, + sony_to_ios_offset_ms: Optional[int], +) -> tuple[list[Event], list[int], int]: + """Correlate each visible iOS track with its scenario-specific T0. + + Manual NEXT/PREVIOUS uses the iOS command intent. Sony-local/automatic + changes use the Sony MediaSession identity mapped onto the iOS monotonic + clock only when clockSyncV1 is trusted. + """ + identities = sorted( + ( + event for event in events + if event.side == "ios" + and event.stage == "trackIdentityAccepted" + and event.result == "changed" + ), + key=lambda event: event.mono_ms, + ) + controls = sorted( + ( + event for event in events + if event.side == "ios" + and event.stage == "commandIntent" + and event.command_type in {"NEXT", "PREVIOUS"} + ), + key=lambda event: event.mono_ms, + ) + t0_events = [] + track_publish_values = [] + missing = 0 + + if controls: + used_identity_lines = set() + for index, control in enumerate(controls): + next_control_ms = ( + controls[index + 1].mono_ms + if index + 1 < len(controls) + else None + ) + candidates = [ + identity for identity in identities + if identity.source_line not in used_identity_lines + and identity.mono_ms >= control.mono_ms + and ( + next_control_ms is None + or identity.mono_ms < next_control_ms + ) + ] + if not candidates: + missing += 1 + continue + identity = candidates[0] + used_identity_lines.add(identity.source_line) + duration = identity.mono_ms - control.mono_ms + if duration > 120_000: + categories["extreme"] += 1 + track_publish_values.append(duration) + t0_events.append( + Event( + side="t0", + stage="trackT0", + mono_ms=control.mono_ms, + command_seq=control.command_seq, + command_type=control.command_type, + track_id=identity.track_id, + generation=identity.generation, + result="manual", + source_line=-(index + 1), + ) + ) + return t0_events, track_publish_values, missing + + sony_identities = sorted( + ( + event for event in events + if event.side == "sony" and event.stage == "trackIdentityAccepted" + ), + key=lambda event: event.mono_ms, + ) + if sony_identities or identities: + if not clock_trusted or sony_to_ios_offset_ms is None: + categories["untrusted_clock"] += max(len(sony_identities), len(identities)) + return [], [], max(len(sony_identities), len(identities)) + + identities_by_key = defaultdict(list) + for identity in identities: + identity_key = _track_key(identity) + if identity_key is not None: + identities_by_key[identity_key].append(identity) + used_identity_lines = set() + for index, sony_identity in enumerate(sony_identities): + identity_key = _track_key(sony_identity) + mapped_t0_ms = sony_identity.mono_ms + sony_to_ios_offset_ms + candidates = [ + identity for identity in identities_by_key.get(identity_key, []) + if identity.source_line not in used_identity_lines + and identity.mono_ms >= mapped_t0_ms + ] + if not candidates: + missing += 1 + continue + identity = candidates[0] + used_identity_lines.add(identity.source_line) + duration = identity.mono_ms - mapped_t0_ms + if duration > 120_000: + categories["extreme"] += 1 + track_publish_values.append(duration) + t0_events.append( + Event( + side="t0", + stage="trackT0", + mono_ms=mapped_t0_ms, + track_id=identity.track_id, + generation=identity.generation, + result="automatic", + source_line=-(index + 1), + ) + ) + if sony_identities: + first_mapped_t0 = sony_identities[0].mono_ms + sony_to_ios_offset_ms + missing += sum( + 1 for identity in identities + if identity.source_line not in used_identity_lines + and identity.mono_ms >= first_mapped_t0 + ) + return t0_events, track_publish_values, missing + + +def classify_transition_samples( + events: list[Event], + clock_trusted: bool, + sony_to_ios_offset_ms: Optional[int] = None, +) -> tuple[list[dict], list[dict]]: + anchor_candidates = [ + event for event in events + if ( + event.side == "ios" + and event.stage == "commandIntent" + and event.command_type in {"NEXT", "PREVIOUS"} + ) or ( + event.side == "sony" + and event.stage == "mediaSessionMetadataObserved" + ) + ] + # Sony and iOS monotonic clocks are not directly sortable. When a remote + # command and its Sony MediaSession observation share the same command + # sequence, keep the iOS intent as the sample anchor; it carries the stable + # handoff id even when Android's bounded product log truncates a legacy line. + anchors_by_identity = {} + for candidate in anchor_candidates: + identity = candidate.handoff_id or ( + f"command-{candidate.command_seq}" + if candidate.command_seq is not None + else f"track-{candidate.track_id}-{candidate.generation}" + ) + existing = anchors_by_identity.get(identity) + if existing is None or ( + candidate.side == "ios" + and candidate.stage == "commandIntent" + and existing.stage != "commandIntent" + ): + anchors_by_identity[identity] = candidate + anchors = list(anchors_by_identity.values()) + samples = [] + missing_events = [] + seen = set() + used_ios_identity_lines = set() + for index, anchor in enumerate(sorted(anchors, key=lambda item: item.mono_ms)): + anchor_identity = anchor.handoff_id or ( + f"command-{anchor.command_seq}" if anchor.command_seq is not None + else f"track-{anchor.track_id}-{anchor.generation}" + ) + if anchor_identity in seen: + continue + seen.add(anchor_identity) + linked_ios_identity = None + if ( + anchor.side == "sony" + and anchor.stage == "mediaSessionMetadataObserved" + and anchor.track_id + and clock_trusted + and sony_to_ios_offset_ms is not None + ): + mapped_anchor_ms = anchor.mono_ms + sony_to_ios_offset_ms + ios_identity_candidates = [ + candidate for candidate in events + if candidate.side == "ios" + and candidate.stage == "trackIdentityAccepted" + and candidate.result == "changed" + and candidate.source_line not in used_ios_identity_lines + and candidate.track_id + and candidate.track_id[:12] == anchor.track_id[:12] + and mapped_anchor_ms - 100 <= candidate.mono_ms <= mapped_anchor_ms + 15_000 + ] + if ios_identity_candidates: + linked_ios_identity = min( + ios_identity_candidates, + key=lambda candidate: ( + abs(candidate.mono_ms - mapped_anchor_ms), + candidate.mono_ms, + candidate.source_line, + ), + ) + used_ios_identity_lines.add(linked_ios_identity.source_line) + upper_bound = anchor.mono_ms + 15_000 + grouped = [] + for candidate in events: + if candidate.side == anchor.side and ( + candidate.mono_ms < anchor.mono_ms - 100 + or candidate.mono_ms > upper_bound + ): + continue + same_handoff = bool( + anchor.handoff_id + and candidate.handoff_id == anchor.handoff_id + ) + same_linked_ios_handoff = bool( + linked_ios_identity + and linked_ios_identity.handoff_id + and candidate.handoff_id == linked_ios_identity.handoff_id + ) + same_command = bool( + anchor.command_seq is not None + and candidate.command_seq == anchor.command_seq + ) + same_track = bool( + anchor.track_id + and candidate.track_id + and candidate.track_id[:12] == anchor.track_id[:12] + and ( + linked_ios_identity is not None + and candidate.side == "ios" + and candidate.generation == linked_ios_identity.generation + or linked_ios_identity is None + and ( + anchor.generation is None + or candidate.generation is None + or anchor.generation == candidate.generation + ) + ) + and ( + linked_ios_identity is None + or candidate.side != "ios" + or abs(candidate.mono_ms - linked_ios_identity.mono_ms) <= 15_000 + ) + ) + if ( + same_handoff + or same_linked_ios_handoff + or same_command + or same_track + ): + grouped.append(candidate) + stages = {event.stage for event in grouped} + is_command = anchor.stage == "commandIntent" + required = { + "commandIntent", "commandEnqueued", "commandWriteStart", "commandWriteCallback", + "commandReceived", "commandValidated", "mediaControlDispatchStart", + "mediaControlDispatchEnd", "mediaSessionMetadataObserved", + "notificationMetadataObserved", "trackIdentityCandidate", + "trackIdentityAccepted", "mediaGenerationCreated", + "playbackReadStart", "playbackReady", "playbackEnqueued", + "playbackDequeued", "playbackNotifyStart", "playbackNotifyCallback", + "playbackNotifyReceived", "playbackDecodeEnd", "playbackStatePublished", + "nowPlayingStateConsumed", + } + if not is_command: + required -= { + "commandIntent", "commandEnqueued", "commandWriteStart", + "commandWriteCallback", "commandReceived", "commandValidated", + "mediaControlDispatchStart", "mediaControlDispatchEnd", + } + absent = sorted(required - stages) + classifications = [] + ios_track_changed = any( + event.side == "ios" + and event.stage == "trackIdentityAccepted" + and event.result == "changed" + for event in grouped + ) + if not ios_track_changed: + classifications.append("NO_TRACK_CHANGE") + if is_command and not any(event.side == "sony" for event in grouped): + classifications.append("COMMAND_ONLY") + lyric_ready = [event for event in grouped if event.stage == "lyricReady"] + if lyric_ready and not any(event.result == "ready" for event in lyric_ready): + classifications.append("NO_LYRICS") + lyric_missing_reasons = sorted({ + reason + for event in grouped + if event.stage.startswith("lyric") + for reason in [(event.failure_reason or event.reason or "").upper()] + if reason in LYRIC_MISSING_REASONS + }) + classifications.extend(lyric_missing_reasons) + if any(event.word_timing_status == "LINE_ONLY" for event in grouped): + classifications.append("LINE_ONLY_LYRICS") + if any(event.word_timing_status == "AVAILABLE" for event in grouped): + classifications.append("WORD_TIMING_AVAILABLE") + if any( + event.stage == "currentWordNotEligible" and event.reason == "INTRO_WAIT" + for event in grouped + ): + classifications.append("INTRO_WAIT") + if any(event.stage in {"lyricRuntimeCacheHit", "lyricParsedCacheHit"} for event in grouped): + classifications.append("LYRIC_CACHE_HIT") + if any(event.stage == "lyricParsedCacheMiss" for event in grouped): + classifications.append("LYRIC_CACHE_MISS") + if any(event.stage in {"albumArtCacheHit", "prefetchLocalCacheHit"} for event in grouped): + classifications.append("PREVIEW_CACHE_HIT") + if any(event.stage in {"previewSendStart", "previewPublished"} for event in grouped): + classifications.append("PREVIEW_TRANSFER") + if not clock_trusted: + classifications.append("CLOCK_SYNC_UNTRUSTED") + if any( + event.stage in {"trackIdentityRejected", "currentWordRejected"} + and (event.reason or "").upper() in { + "STALE_PACKET", "GENERATION_MISMATCH", "SEQUENCE_OLD", + "POSITION_STALE", "TRACK_MISMATCH", + } + for event in grouped + ): + classifications.append("STALE_REJECTED") + if any(event.result in {"failure", "failed"} for event in grouped): + classifications.append("FAILED") + if absent: + classifications.append("TRACE_INCOMPLETE") + elif ios_track_changed: + classifications.append("COMPLETE") + sample_id = anchor.handoff_id or f"sample-{index + 1}" + sample = { + "sampleId": sample_id, + "handoffId": anchor.handoff_id, + "triggerType": anchor.trigger_type or ( + "IOS_NEXT" if anchor.command_type == "NEXT" + else "IOS_PREVIOUS" if anchor.command_type == "PREVIOUS" + else "UNKNOWN" + ), + "trackIdSummary": anchor.track_id[:12] if anchor.track_id else None, + "generation": anchor.generation, + "eventCount": len(grouped), + "classifications": sorted(set(classifications)), + "lyricMissingReasons": lyric_missing_reasons, + "missingEvents": absent, + } + samples.append(sample) + if absent: + missing_events.append({ + "sampleId": sample_id, + "missingEvents": absent, + }) + return samples, missing_events + + +def analyze( + events: list[Event], + malformed: int = 0, + clock_trusted: bool = False, + sony_to_ios_offset_ms: Optional[int] = None, +) -> dict: + categories = Counter() + categories["malformed"] = malformed + if not events: + categories["empty"] += 1 + + identity_counts = Counter( + ( + event.side, + event.stage, + event.mono_ms, + event.command_seq, + event.track_id, + event.generation, + event.transfer_id, + ) + for event in events + ) + categories["duplicate"] = sum(count - 1 for count in identity_counts.values() if count > 1) + + accepted_generation_stages = { + "trackIdentityAccepted", + "playbackStatePublished", + "currentLyricPublished", + "currentWordAccepted", + "currentWordPublished", + "lyricWindowPublished", + "fullLyricsPublished", + "previewPublished", + "hqPublished", + } + latest_generation = {} + for event in sorted(events, key=lambda item: (item.side, item.mono_ms, item.source_line)): + if ( + event.stage not in accepted_generation_stages + or event.generation is None + or event.generation <= 0 + ): + continue + previous = latest_generation.get(event.side) + if previous is not None and event.generation < previous: + categories["stale_generation"] += 1 + latest_generation[event.side] = max(previous or event.generation, event.generation) + + current_ios_track: Optional[tuple] = None + for event in sorted( + (event for event in events if event.side == "ios"), + key=lambda item: (item.mono_ms, item.source_line), + ): + if event.stage == "trackIdentityAccepted": + current_ios_track = _track_key(event) + continue + event_track = _track_key(event) + if current_ios_track is None or event_track is None: + continue + if event.stage in {"currentWordAccepted", "currentWordPublished"}: + if not _same_track(event_track, current_ios_track): + categories["wrong_current_word"] += 1 + elif event.stage in {"previewPublished", "hqPublished"}: + if not _same_track(event_track, current_ios_track): + categories["wrong_artwork"] += 1 + elif event.stage in {"lyricWindowPublished", "fullLyricsPublished"}: + if not _same_track(event_track, current_ios_track): + categories["wrong_lyrics"] += 1 + + dispatch_counts = Counter( + (event.command_seq, event.command_type) + for event in events + if event.side == "sony" + and event.stage == "mediaControlDispatchStart" + and _control_command_key(event) is not None + ) + receive_counts = Counter( + (event.command_seq, event.command_type) + for event in events + if event.side == "sony" + and event.stage == "commandReceived" + and _control_command_key(event) is not None + ) + categories["duplicate_control"] = sum( + count - 1 for count in dispatch_counts.values() if count > 1 + ) + categories["control_reconnect_resend"] = sum( + count - 1 for count in receive_counts.values() if count > 1 + ) + + metrics = {} + metric_samples = {} + + phase4_events = list(events) + phase4_events.extend(first_correlated_events( + events, + "sony", + {"mediaSessionMetadataObserved", "notificationMetadataObserved"}, + "metadataObserved", + _command_or_handoff_key, + )) + first_stage_specs = ( + ("sony", {"trackIdentityAccepted"}, "firstSonyTrackAccepted", _media_key), + ("sony", {"playbackReady", "playbackStateReady"}, "firstPlaybackReady", _media_key), + ("sony", {"playbackEnqueued"}, "firstPlaybackEnqueued", _media_key), + ("sony", {"playbackNotifyStart"}, "firstPlaybackNotifyStart", _media_key), + ("sony", {"playbackNotifyCallback"}, "firstPlaybackNotifyCallback", _media_key), + ("ios", {"nowPlayingStateConsumed"}, "firstUiConsumed", _media_key), + ("sony", {"lyricReady"}, "firstLyricReady", _media_key), + ("sony", {"currentWordEligible"}, "firstWordEligible", _media_key), + ("ios", {"trackIdentityAccepted"}, "firstIosTrackAccepted", _media_key), + ) + for side, stages, canonical, key_function in first_stage_specs: + source_events = events + if canonical == "firstIosTrackAccepted": + # A reconnect baseline can refresh the already-visible identity + # before the requested measurement starts. It is not a handoff and + # must not make the old generation eligible for first-word SLOs. + source_events = [ + event for event in events + if event.stage != "trackIdentityAccepted" + or event.side != "ios" + or event.result == "changed" + ] + phase4_events.extend(first_correlated_events( + source_events, side, stages, canonical, key_function, + )) + # Keep every non-empty line publication available for pairing. The first + # publication for a generation can legitimately predate lyricsReady (for + # example, a cached/stale UI slice). Cross-device pairing below selects the + # first exact-identity publication after the Sony enqueue using clockSync. + phase4_events.extend( + replace(event, stage="firstLinePublished") + for event in events + if event.side == "ios" + and event.stage == "lyricCurrentLinePublished" + and event.result != "empty" + and _media_key(event) is not None + ) + lyric_ready_events = [ + event for event in phase4_events if event.stage == "firstLyricReady" + ] + phase4_events.extend(first_events_after( + events, + lyric_ready_events, + "sony", + {"lyricCurrentLineEnqueued"}, + "firstLineEnqueued", + _media_key, + )) + identified_playback_receives = [ + event for event in events + if event.stage != "playbackNotifyReceived" or bool(event.track_id) + ] + phase4_events.extend(first_correlated_events( + identified_playback_receives, + "ios", + {"playbackNotifyReceived"}, + "firstPlaybackNotifyReceived", + _media_key, + )) + # playbackState JSON does not carry track identity. The receive trace is + # recorded immediately before decode and already owns the accepted handoff + # identity, so inherit it onto the adjacent decode event instead of pairing + # unrelated payloads that merely share a handoff window. + playback_receives = [ + event for event in phase4_events + if event.side == "ios" and event.stage == "firstPlaybackNotifyReceived" + ] + playback_decodes = [] + for receive in playback_receives: + candidates = [ + event for event in events + if event.side == "ios" + and event.stage == "playbackDecodeEnd" + and event.payload_type == "playbackState" + and event.mono_ms >= receive.mono_ms + and event.mono_ms - receive.mono_ms <= 100 + and ( + not receive.handoff_id + or event.handoff_id == receive.handoff_id + ) + ] + if candidates: + decoded = min(candidates, key=lambda item: (item.mono_ms, item.source_line)) + canonical_decode = replace( + decoded, + stage="firstPlaybackDecodeEnd", + track_id=receive.track_id, + generation=receive.generation, + handoff_id=receive.handoff_id or decoded.handoff_id, + trigger_type=receive.trigger_type or decoded.trigger_type, + ) + playback_decodes.append(canonical_decode) + phase4_events.append(canonical_decode) + playback_publishes = first_events_after( + events, + playback_decodes, + "ios", + {"playbackStatePublished"}, + "firstPlaybackPublished", + _media_key, + ) + phase4_events.extend(playback_publishes) + accepted_media_keys = { + _media_key(event) + for event in phase4_events + if event.stage == "firstIosTrackAccepted" + and _media_key(event) is not None + } + phase4_events = [ + event for event in phase4_events + if event.stage != "firstWordEligible" + or _media_key(event) in accepted_media_keys + ] + eligible_events = [ + event for event in phase4_events if event.stage == "firstWordEligible" + ] + first_eligible_ms_by_key = { + _media_key(event): event.mono_ms + for event in eligible_events + if _media_key(event) is not None + } + intro_wait_before_first_word_keys = { + _media_key(event) + for event in events + if event.side == "sony" + and event.stage in {"currentWordNotEligible", "currentWordEligibilityEvaluated"} + and event.reason == "INTRO_WAIT" + and _media_key(event) in first_eligible_ms_by_key + and event.mono_ms <= first_eligible_ms_by_key[_media_key(event)] + } + categories["intro_wait_before_first_word"] += len( + intro_wait_before_first_word_keys + ) + phase4_events.extend( + replace(event, stage="firstIosTrackAcceptedImmediateWord") + for event in phase4_events + if event.stage == "firstIosTrackAccepted" + and _media_key(event) not in intro_wait_before_first_word_keys + ) + scheduler_events = first_events_after( + events, + eligible_events, + "sony", + {"currentWordSchedulerCreated"}, + "wordSchedulerCreated", + _media_key, + ) + enqueue_events = first_events_after( + events, + scheduler_events, + "sony", + {"currentWordImmediateSnapshotEnqueued", "currentWordBoundaryEnqueued"}, + "wordEnqueued", + _media_key, + ) + send_events = first_events_after( + events, + enqueue_events, + "sony", + {"currentWordSendStart"}, + "wordSendStart", + _media_key, + ) + phase4_events.extend(scheduler_events) + phase4_events.extend(enqueue_events) + phase4_events.extend(send_events) + for side, stages, canonical in ( + ("ios", {"currentWordReceived"}, "wordReceived"), + ("ios", {"currentWordAccepted"}, "wordAccepted"), + ("ios", {"currentWordPublished"}, "wordPublished"), + ): + phase4_events.extend(first_correlated_events( + events, side, stages, canonical, _media_key, + )) + + def add_phase4_pair( + name: str, + start_side: str, + start_stage: str, + end_side: str, + end_stage: str, + key: Callable[[Event], Optional[tuple]], + cross_device: bool = False, + cross_device_tolerance_ms: int = 75, + ) -> None: + values, missing = paired_durations( + phase4_events, + start_side, + start_stage, + end_side, + end_stage, + key, + categories, + cross_device=cross_device, + clock_trusted=clock_trusted, + sony_to_ios_offset_ms=sony_to_ios_offset_ms, + cross_device_tolerance_ms=cross_device_tolerance_ms, + ) + metric_samples[name] = values + metrics[name] = summarize(values, missing) + + phase4_pair_specs = ( + ("commandIntentToWriteStartMs", "ios", "commandIntent", "ios", "commandWriteStart", _control_command_or_handoff_key, False), + ("writeStartToCallbackMs", "ios", "commandWriteStart", "ios", "commandWriteCallback", _control_command_or_handoff_key, False), + ("commandCallbackToSonyReceiveMs", "ios", "commandWriteCallback", "sony", "commandReceived", _control_command_or_handoff_key, True), + ("sonyReceiveToDispatchEndMs", "sony", "commandReceived", "sony", "mediaControlDispatchEnd", _control_command_or_handoff_key, False), + ("dispatchEndToMetadataObservedMs", "sony", "mediaControlDispatchEnd", "sony", "metadataObserved", _control_command_or_handoff_key, False), + ("metadataObservedToTrackAcceptedMs", "sony", "mediaSessionMetadataObserved", "sony", "firstSonyTrackAccepted", _handoff_or_media_key, False), + ("trackAcceptedToPlaybackReadyMs", "sony", "firstSonyTrackAccepted", "sony", "firstPlaybackReady", _media_key, False), + ("playbackReadyToQueueStartMs", "sony", "firstPlaybackReady", "sony", "firstPlaybackEnqueued", _media_key, False), + ("playbackReadyToNotifyStartMs", "sony", "firstPlaybackReady", "sony", "firstPlaybackNotifyStart", _media_key, False), + ("playbackNotifyStartToCallbackMs", "sony", "firstPlaybackNotifyStart", "sony", "firstPlaybackNotifyCallback", _media_key, False), + ("notifyCallbackToIosReceiveMs", "sony", "firstPlaybackNotifyCallback", "ios", "firstPlaybackNotifyReceived", _media_key, True), + ("iosReceiveToDecodeMs", "ios", "firstPlaybackNotifyReceived", "ios", "firstPlaybackDecodeEnd", _media_key, False), + ("iosDecodeToPublishMs", "ios", "firstPlaybackDecodeEnd", "ios", "firstPlaybackPublished", _media_key, False), + ("publishToUiConsumeMs", "ios", "firstIosTrackAccepted", "ios", "firstUiConsumed", _media_key, False), + ("totalCommandToTrackPublishMs", "ios", "commandIntent", "ios", "firstIosTrackAccepted", _control_command_or_handoff_key, False), + ("trackAcceptedToLyricReadyMs", "sony", "firstSonyTrackAccepted", "sony", "firstLyricReady", _media_key, False), + ("lyricReadyToCurrentLineEnqueueMs", "sony", "firstLyricReady", "sony", "firstLineEnqueued", _media_key, False), + ("currentLineEnqueueToPublishMs", "sony", "firstLineEnqueued", "ios", "firstLinePublished", _media_key, True, 0), + ("trackAcceptedToWordEligibleMs", "sony", "firstSonyTrackAccepted", "sony", "firstWordEligible", _media_key, False), + ("lyricReadyToWordEligibleMs", "sony", "firstLyricReady", "sony", "firstWordEligible", _media_key, False), + ("wordEligibleToSchedulerCreatedMs", "sony", "firstWordEligible", "sony", "wordSchedulerCreated", _media_key, False), + ("schedulerCreatedToFirstEnqueueMs", "sony", "wordSchedulerCreated", "sony", "wordEnqueued", _media_key, False), + ("firstEnqueueToSendMs", "sony", "wordEnqueued", "sony", "wordSendStart", _media_key, False), + ("sendToReceiveMs", "sony", "wordSendStart", "ios", "wordReceived", _media_key, True), + ("receiveToAcceptMs", "ios", "wordReceived", "ios", "wordAccepted", _media_key, False), + ("acceptToPublishMs", "ios", "wordAccepted", "ios", "wordPublished", _media_key, False), + ("wordEligibleToPublishMs", "sony", "firstWordEligible", "ios", "wordPublished", _media_key, True), + ("trackAcceptedToFirstWordMs", "ios", "firstIosTrackAcceptedImmediateWord", "ios", "wordPublished", _media_key, False), + ) + for pair_spec in phase4_pair_specs: + add_phase4_pair(*pair_spec) + + playback_dequeues = [ + event for event in events + if event.side == "sony" and event.stage == "playbackDequeued" + ] + playback_queue_values = [ + event.queue_wait_ms for event in playback_dequeues + if event.queue_wait_ms is not None + ] + metric_samples["playbackQueueWaitMs"] = playback_queue_values + metrics["playbackQueueWaitMs"] = summarize( + playback_queue_values, + len(playback_dequeues) - len(playback_queue_values), + ) + + track_t0_events, track_publish_values, track_publish_missing = derive_track_t0( + events, + categories, + clock_trusted, + sony_to_ios_offset_ms, + ) + metric_samples["commandToTrackPublishMs"] = track_publish_values + metrics["commandToTrackPublishMs"] = summarize( + track_publish_values, + track_publish_missing, + ) + + usable_artwork_events = [ + replace(event, stage="usableArtworkPublished") + for event in events + if event.side == "ios" and event.stage in {"previewPublished", "hqPublished"} + ] + track_identity_t0_events = [ + replace(event, side="t0", stage="trackT0") + for event in events + if event.side == "ios" + and event.stage == "trackIdentityAccepted" + and event.result == "changed" + ] + track_pair_specs = ( + ("trackToCurrentLyricMs", "currentLyricPublished", _track_key), + ("trackToCurrentWordMs", "currentWordPublished", _track_key), + ("trackToPreviewArtMs", "usableArtworkPublished", _track_id_key), + ("trackToHqArtMs", "hqPublished", _track_id_key), + ) + track_analysis_events = [*events, *usable_artwork_events, *track_identity_t0_events] + for name, end_stage, key in track_pair_specs: + values, missing = paired_durations( + track_analysis_events, + "t0", + "trackT0", + "ios", + end_stage, + key, + categories, + ) + metric_samples[name] = values + metrics[name] = summarize(values, missing) + + pair_specs = ( + ("commandToSonyReceiveMs", "ios", "commandIntent", "sony", "commandReceived", _control_command_key, True), + ("lyricReadyToPendingFlushMs", "sony", "lyricReady", "sony", "pendingFlush", _ready_track_key, False), + ("pendingFlushToSendStartMs", "sony", "pendingFlush", "sony", "fullLyricsSendStart", _track_key, False), + ("fullLyricsSendDurationMs", "sony", "fullLyricsSendStart", "sony", "fullLyricsSendEnd", _transfer_key, False), + ("lyricReadyToFullLyricsPublishMs", "sony", "lyricReady", "ios", "fullLyricsPublished", _ready_track_key, True), + ("previewSendDurationMs", "sony", "previewSendStart", "sony", "previewSendEnd", _track_key, False), + ( + "iOSPublishDurationMs", + "ios", + "playbackDecodeEnd", + "ios", + "playbackStatePublished", + lambda event: ("playbackState",) + if event.payload_type == "playbackState" + else None, + False, + ), + ) + pending_track_keys = { + _track_key(event) + for event in events + if event.side == "sony" and event.stage == "pendingQueued" + } + for name, start_side, start_stage, end_side, end_stage, key, cross in pair_specs: + analysis_events = events + if name == "lyricReadyToPendingFlushMs": + analysis_events = [ + event for event in events + if event.stage != "lyricReady" or _track_key(event) in pending_track_keys + ] + values, missing = paired_durations( + analysis_events, + start_side, + start_stage, + end_side, + end_stage, + key, + categories, + cross_device=cross, + clock_trusted=clock_trusted, + sony_to_ios_offset_ms=sony_to_ios_offset_ms, + ) + metric_samples[name] = values + metrics[name] = summarize(values, missing) + + direct_specs = { + "notifyQueueWaitMs": ("sony", {"notifyDequeued"}, "queue_wait_ms"), + "iOSDecodeDurationMs": ("ios", {"playbackDecodeEnd"}, "processing_ms"), + } + for name, (side, stages, attribute) in direct_specs.items(): + candidates = [e for e in events if e.side == side and e.stage in stages] + values = [getattr(e, attribute) for e in candidates if getattr(e, attribute) is not None] + metric_samples[name] = values + metrics[name] = summarize(values, len(candidates) - len(values)) + + for name in METRIC_NAMES: + metrics.setdefault(name, summarize([], 0)) + + delay_thresholds = { + "IOS_WRITE_DELAY": ("writeStartToCallbackMs", 120), + "SONY_COMMAND_DELAY": ("sonyReceiveToDispatchEndMs", 120), + "MEDIASESSION_SWITCH_DELAY": ("dispatchEndToMetadataObservedMs", 300), + "METADATA_OBSERVATION_DELAY": ("metadataObservedToTrackAcceptedMs", 100), + "TRACK_ACCEPTANCE_DELAY": ("trackAcceptedToPlaybackReadyMs", 100), + "PLAYBACK_READ_DELAY": ("trackAcceptedToPlaybackReadyMs", 150), + "NOTIFY_QUEUE_DELAY": ("playbackQueueWaitMs", 100), + "BLE_NOTIFY_DELAY": ("playbackNotifyStartToCallbackMs", 100), + "IOS_PUBLISH_DELAY": ("iosDecodeToPublishMs", 100), + "COMMAND_DELAY": ("commandToSonyReceiveMs", 200), + "TRACK_IDENTITY_DELAY": ("commandToTrackPublishMs", 300), + "LYRIC_READY_DELAY": ("trackToCurrentLyricMs", 500), + "PENDING_FLUSH_DELAY": ("lyricReadyToPendingFlushMs", 100), + "SEND_QUEUE_DELAY": ("notifyQueueWaitMs", 200), + "CURRENT_WORD_DELAY": ("trackToCurrentWordMs", 500), + "ARTWORK_DELAY": ("trackToPreviewArtMs", 800), + "IOS_DECODE_DELAY": ("iOSDecodeDurationMs", 50), + } + classifications = { + category: sum(1 for value in metric_samples.get(metric, []) if value > threshold) + for category, (metric, threshold) in delay_thresholds.items() + } + classifications["CLOCK_SYNC_UNTRUSTED"] = 1 if categories["untrusted_clock"] else 0 + classifications["TRACE_INCOMPLETE"] = ( + malformed + + categories["empty"] + + categories["out_of_order"] + + sum(metric["missing"] for metric in metrics.values()) + ) + classifications["STALE_CONTENT"] = ( + categories["stale_generation"] + + categories["wrong_current_word"] + + categories["wrong_artwork"] + + categories["wrong_lyrics"] + ) + slow_samples = sorted( + ( + { + "traceId": f"{name}-{index + 1}", + "trackIdSummary": None, + "slowestStage": name, + "durationMs": value, + "cacheHit": None, + "longTaskCompetition": None, + "currentWordPreempted": None, + "retry": None, + } + for name, samples in metric_samples.items() + for index, value in enumerate(samples) + ), + key=lambda sample: sample["durationMs"], + reverse=True, + )[:10] + + samples, missing_events = classify_transition_samples( + events, + clock_trusted and sony_to_ios_offset_ms is not None, + sony_to_ios_offset_ms, + ) + lyric_missing_reason_counts = Counter( + reason + for sample in samples + for reason in sample.get("lyricMissingReasons", []) + ) + + return { + "schemaVersion": 2, + "clock": { + "crossDeviceTrusted": clock_trusted and sony_to_ios_offset_ms is not None, + "sonyToIosOffsetMs": sony_to_ios_offset_ms, + }, + "eventCount": len(events), + "sideCounts": dict(Counter(event.side for event in events)), + "stageCounts": dict(Counter(event.stage for event in events)), + "metrics": metrics, + "categories": classifications, + "diagnostics": dict(sorted(categories.items())), + "slowSamples": slow_samples, + "samples": samples, + "lyricMissingReasonCounts": dict(sorted(lyric_missing_reason_counts.items())), + "missingEvents": missing_events, + } + + +def render_summary(report: dict) -> str: + lines = [ + "# MusicBleController V4 Real-time SLO Summary", + "", + f"Events: {report['eventCount']}", + f"Cross-device clock trusted: {report['clock']['crossDeviceTrusted']}", + "", + "| Metric | Count | P50 | P95 | P99 | Max | Missing |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + for name in METRIC_NAMES: + metric = report["metrics"][name] + value = lambda key: "-" if metric[key] is None else f"{metric[key]:.1f}" + lines.append( + f"| {name} | {metric['count']} | {value('p50')} | {value('p95')} | " + f"{value('p99')} | {value('max')} | {metric['missing']} |" + ) + lines.extend(("", "## Diagnostic categories", "")) + if report["categories"]: + lines.extend(f"- {key}: {value}" for key, value in report["categories"].items()) + else: + lines.append("- none") + lines.extend(("", "## Lyric wait and missing reasons", "")) + if report["lyricMissingReasonCounts"]: + lines.extend( + f"- {key}: {value}" + for key, value in report["lyricMissingReasonCounts"].items() + ) + else: + lines.append("- none") + return "\n".join(lines) + "\n" + + +def write_combined_trace(paths: list[Path], destination: Path) -> None: + """Preserve the report bundle contract even when a trace source is empty.""" + contents = [ + path.read_text(encoding="utf-8", errors="replace") + for path in paths + if path.exists() + ] + with destination.open("w", encoding="utf-8") as output: + for content in contents: + output.write(content) + if content and not content.endswith("\n"): + output.write("\n") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sony-trace", type=Path, action="append", default=[]) + parser.add_argument("--ios-trace", type=Path, action="append", default=[]) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--clock-trusted", action="store_true") + parser.add_argument("--sony-to-ios-offset-ms", type=int) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + trace_paths = [*args.sony_trace, *args.ios_trace] + events, malformed = load_events(trace_paths) + discovered_trusted, discovered_offset = discover_clock_sync(args.ios_trace) + clock_trusted = args.clock_trusted or discovered_trusted + sony_to_ios_offset_ms = ( + args.sony_to_ios_offset_ms + if args.sony_to_ios_offset_ms is not None + else discovered_offset + ) + report = analyze( + events, + malformed=malformed, + clock_trusted=clock_trusted, + sony_to_ios_offset_ms=sony_to_ios_offset_ms, + ) + args.output_dir.mkdir(parents=True, exist_ok=True) + write_combined_trace(args.sony_trace, args.output_dir / "sony_trace.log") + write_combined_trace(args.ios_trace, args.output_dir / "ios_trace.log") + (args.output_dir / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (args.output_dir / "summary.md").write_text(render_summary(report), encoding="utf-8") + (args.output_dir / "sample_classification.json").write_text( + json.dumps(report.get("samples", []), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (args.output_dir / "missing_events.json").write_text( + json.dumps(report.get("missingEvents", []), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + with (args.output_dir / "raw_events.jsonl").open("w", encoding="utf-8") as handle: + for event in events: + handle.write(json.dumps(event.as_dict(), ensure_ascii=False) + "\n") + if args.json: + json.dump(report, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/smoke/realtime_latency_v4_test.sh b/tools/smoke/realtime_latency_v4_test.sh new file mode 100755 index 0000000..79cb14a --- /dev/null +++ b/tools/smoke/realtime_latency_v4_test.sh @@ -0,0 +1,477 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +IOS_DEVICE_ID="${IOS_DEVICE_ID:-}" +ANDROID_DEVICE_ID="${ANDROID_DEVICE_ID:-}" +BUNDLE_ID="${BUNDLE_ID:-com.sqz.IOSBleFeasibility}" +RUNS=30 +FAST_SWITCH=false +MODE="next" +OUTPUT_DIR_ARG="" +JSON_OUTPUT=false +DURATION_MINUTES=0 + +usage() { + cat <<'EOF' +Usage: realtime_latency_v4_test.sh [options] + +Options: + --runs Number of samples. Default: 30. + --fast-switch Alternate NEXT/PREVIOUS at 650 ms intervals. + --previous Use PREVIOUS for the normal scenario. + --auto Equivalent automatic scenario via Sony MediaSession events. + --natural Observe real natural autoplay without sending controls. + --duration-minutes Observation time for --natural. + --ios-device iPhone devicectl identifier. + --android-device Sony adb serial. + --output Output directory. + --json Print report.json. + -h, --help Show this help. + +Examples: + realtime_latency_v4_test.sh --runs 30 --json + realtime_latency_v4_test.sh --runs 100 --fast-switch --json + +The app and PlayerAgent debug builds must already be installed. The script performs +the hard iOS BLE precheck before sending any command. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --runs) + RUNS="${2:?--runs requires a count}" + shift 2 + ;; + --fast-switch) + FAST_SWITCH=true + MODE="fast" + shift + ;; + --previous) + MODE="previous" + shift + ;; + --auto) + MODE="auto" + shift + ;; + --natural) + MODE="natural" + shift + ;; + --duration-minutes) + DURATION_MINUTES="${2:?--duration-minutes requires a count}" + shift 2 + ;; + --ios-device) + IOS_DEVICE_ID="${2:?--ios-device requires an id}" + shift 2 + ;; + --android-device) + ANDROID_DEVICE_ID="${2:?--android-device requires an id}" + shift 2 + ;; + --output) + OUTPUT_DIR_ARG="${2:?--output requires a directory}" + shift 2 + ;; + --json) + JSON_OUTPUT=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! [[ "$RUNS" =~ ^[1-9][0-9]*$ ]] || (( RUNS > 500 )); then + echo "--runs must be between 1 and 500" >&2 + exit 2 +fi + +timestamp="$(date +%Y%m%d_%H%M%S)" +OUT_DIR="${OUTPUT_DIR_ARG:-/tmp/musicble_phase4/$timestamp}" +mkdir -p "$OUT_DIR" + +log() { + echo "[RealtimeV4] $*" >&2 +} + +find_adb() { + if [[ -n "${ADB_BIN:-}" && -x "${ADB_BIN:-}" ]]; then + echo "$ADB_BIN" + return + fi + if command -v adb >/dev/null 2>&1; then + command -v adb + return + fi + local candidate + for candidate in \ + "${ANDROID_HOME:-}/platform-tools/adb" \ + "${ANDROID_SDK_ROOT:-}/platform-tools/adb" \ + "$HOME/Library/Android/sdk/platform-tools/adb" \ + /opt/homebrew/bin/adb \ + /usr/local/bin/adb; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + echo "$candidate" + return + fi + done + return 1 +} + +detect_ios() { + if [[ -n "$IOS_DEVICE_ID" ]]; then + return + fi + local list_file="$OUT_DIR/devicectl_devices.txt" + xcrun devicectl list devices >"$list_file" + IOS_DEVICE_ID="$(python3 - "$list_file" <<'PY' +import re +import sys +from pathlib import Path +ids = [] +pattern = re.compile(r"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}") +for line in Path(sys.argv[1]).read_text(errors="replace").splitlines(): + match = pattern.search(line) + lowered = line.lower() + usable = re.search(r"\b(?:connected|available)\b", lowered) is not None + if match and "iphone" in lowered and usable: + ids.append(match.group(0)) +if len(ids) == 1: + print(ids[0]) +else: + raise SystemExit(1) +PY + )" || return 1 +} + +detect_android() { + ADB_BIN="$(find_adb)" || return 1 + export ADB_BIN + if [[ -n "$ANDROID_DEVICE_ID" ]]; then + return + fi + "$ADB_BIN" devices >"$OUT_DIR/adb_devices.txt" + ANDROID_DEVICE_ID="$(python3 - "$OUT_DIR/adb_devices.txt" <<'PY' +import sys +from pathlib import Path +rows = [] +for line in Path(sys.argv[1]).read_text(errors="replace").splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + rows.append(parts[0]) +if len(rows) == 1: + print(rows[0]) +else: + raise SystemExit(1) +PY + )" || return 1 +} + +copy_ios_log() { + local destination="$1" + local current_log="${destination}.current" + local old_log="${destination}.old" + local copied=false + local attempt + rm -f "$current_log" "$old_log" + : >"$OUT_DIR/devicectl_copy.out" + : >"$OUT_DIR/devicectl_copy.err" + for attempt in 1 2 3; do + rm -f "$current_log" + if xcrun devicectl --timeout 20 device copy from \ + --device "$IOS_DEVICE_ID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source Documents/Logs/ios_ble.log \ + --destination "$current_log" \ + >>"$OUT_DIR/devicectl_copy.out" 2>>"$OUT_DIR/devicectl_copy.err"; then + copied=true + break + fi + sleep 1 + done + if [[ "$copied" != true || ! -s "$current_log" ]]; then + return 1 + fi + xcrun devicectl --timeout 12 device copy from \ + --device "$IOS_DEVICE_ID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source Documents/Logs/ios_ble.old.log \ + --destination "$old_log" \ + >"$OUT_DIR/devicectl_old_copy.out" 2>"$OUT_DIR/devicectl_old_copy.err" || true + if [[ -s "$old_log" ]]; then + cat "$old_log" "$current_log" >"$destination" + else + mv "$current_log" "$destination" + fi +} + +emit_failure() { + local reason="$1" + : >"$OUT_DIR/sony_trace.log" + : >"$OUT_DIR/ios_trace.log" + python3 "$SCRIPT_DIR/realtime_latency_report.py" \ + --sony-trace "$OUT_DIR/sony_trace.log" \ + --ios-trace "$OUT_DIR/ios_trace.log" \ + --output-dir "$OUT_DIR" + python3 - "$OUT_DIR/report.json" "$reason" <<'PY' +import json +import sys +from pathlib import Path +path = Path(sys.argv[1]) +data = json.loads(path.read_text()) +data["result"] = "FAIL" +data["failureReason"] = sys.argv[2] +path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") +PY + if [[ "$JSON_OUTPUT" == true ]]; then + cat "$OUT_DIR/report.json" + fi + exit 1 +} + +detect_ios || emit_failure "connected_iPhone_not_found" +detect_android || emit_failure "connected_Sony_not_found" + +SONY_FULL_LOG="$OUT_DIR/sony_full.log" +IOS_FULL_LOG="$OUT_DIR/ios_full.log" +SONY_TRACE_LOG="$OUT_DIR/sony_trace.log" +IOS_TRACE_LOG="$OUT_DIR/ios_trace.log" + +"$ADB_BIN" -s "$ANDROID_DEVICE_ID" logcat -c || true +"$ADB_BIN" -s "$ANDROID_DEVICE_ID" logcat -v threadtime >"$SONY_FULL_LOG" & +LOGCAT_PID=$! +cleanup() { + if kill -0 "$LOGCAT_PID" 2>/dev/null; then + kill "$LOGCAT_PID" 2>/dev/null || true + fi + wait "$LOGCAT_PID" 2>/dev/null || true +} +trap cleanup EXIT + +launch_args=( + --launch-arg --smoke-realtime-v4 + --launch-arg "--smoke-realtime-runs=$RUNS" + --launch-arg "--smoke-realtime-mode=$MODE" +) +if [[ "$FAST_SWITCH" == true ]]; then + launch_args+=(--launch-arg --smoke-realtime-fast-switch) +fi + +log "precheck ios=$IOS_DEVICE_ID sony=$ANDROID_DEVICE_ID mode=$MODE runs=$RUNS" +if ! OUT_DIR="$OUT_DIR" IOS_DEVICE_ID="$IOS_DEVICE_ID" BUNDLE_ID="$BUNDLE_ID" \ + "$SCRIPT_DIR/ios_ble_precheck.sh" --timeout 8 "${launch_args[@]}" --json \ + >"$OUT_DIR/ios_ble_precheck_stdout.json" \ + 2>"$OUT_DIR/ios_ble_precheck_stderr.log"; then + emit_failure "ios_ble_precheck_failed" +fi + +if [[ "$MODE" == "auto" ]]; then + "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell log -t RealtimeV4 \ + "measurement_start mode=auto" >/dev/null 2>&1 || true + log "running automatic-equivalent Sony MediaSession events" + for _ in $(seq 1 "$RUNS"); do + # `input keyevent 87` is routed through the foreground input target on some + # Sony builds and can be silently dropped after the first event. The public + # media_session shell command dispatches the same standard NEXT action to + # the active MediaSession and is stable for repeatable automatic-equivalent + # measurements. This does not call a QQ Music private API. + "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell cmd media_session dispatch next + sleep 4 + done + sleep 12 +elif [[ "$MODE" == "natural" ]]; then + if ! [[ "$DURATION_MINUTES" =~ ^[1-9][0-9]*$ ]]; then + emit_failure "natural_duration_minutes_required" + fi + "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell log -t RealtimeV4 \ + "measurement_start mode=natural" >/dev/null 2>&1 || true + log "observing natural autoplay for ${DURATION_MINUTES} minutes" + sleep "$((DURATION_MINUTES * 60))" +else + duration="$(python3 - "$RUNS" "$FAST_SWITCH" <<'PY' +import math +import sys +runs = int(sys.argv[1]) +fast = sys.argv[2].lower() == "true" +print(math.ceil(runs * (0.65 if fast else 4.0) + 20)) +PY + )" + log "collecting for ${duration}s" + sleep "$duration" +fi + +copy_ios_log "$IOS_FULL_LOG" || emit_failure "ios_log_copy_failed" +cleanup +trap - EXIT + +if [[ "$MODE" == "auto" || "$MODE" == "natural" ]]; then + LC_ALL=C awk ' + /RealtimeV4: measurement_start mode=(auto|natural)/ { capture=1; next } + capture && /\[RealtimeTrace\]/ { print } + ' "$SONY_FULL_LOG" >"$SONY_TRACE_LOG" +else + grep -F '[RealtimeTrace]' "$SONY_FULL_LOG" >"$SONY_TRACE_LOG" || true +fi +grep -E '\[RealtimeTrace\]|\[ClockSync\].*pong' "$IOS_FULL_LOG" >"$IOS_TRACE_LOG" || true + +python3 "$SCRIPT_DIR/realtime_latency_report.py" \ + --sony-trace "$SONY_TRACE_LOG" \ + --ios-trace "$IOS_TRACE_LOG" \ + --output-dir "$OUT_DIR" + +COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD)" +BRANCH="$(git -C "$ROOT_DIR" branch --show-current)" +python3 - "$OUT_DIR/report.json" "$MODE" "$RUNS" "$FAST_SWITCH" "$IOS_DEVICE_ID" "$ANDROID_DEVICE_ID" "$COMMIT" "$BRANCH" "$SONY_FULL_LOG" "$IOS_FULL_LOG" "$OUT_DIR/summary.md" <<'PY' +import json +import re +import sys +from pathlib import Path +path = Path(sys.argv[1]) +data = json.loads(path.read_text()) +sony_text = Path(sys.argv[9]).read_text(encoding="utf-8", errors="replace") +ios_text = Path(sys.argv[10]).read_text(encoding="utf-8", errors="replace") +protocol_matches = re.findall(r"protocolVersion=(\d+)", ios_text) +mtu_matches = re.findall(r"\bMTU changed:.*\bmtu=(\d+)", sony_text) +ios_mtu_matches = re.findall(r"\[RealtimeV4\].*\bmtu=(\d+)", ios_text) +clock_matches = re.findall( + r"\[ClockSync\].*\bsamples=(\d+)\b.*\bconfident=(true|false)", + ios_text, + flags=re.IGNORECASE, +) +stage_counts = data.get("stageCounts", {}) +mode = sys.argv[2] +requested_runs = int(sys.argv[3]) +fast_switch = sys.argv[4].lower() == "true" +trigger_override = { + "auto": "SONY_MEDIA_SESSION_NEXT", + "natural": "NATURAL_AUTOPLAY", +}.get(mode) +if trigger_override: + for sample in data.get("samples", []): + sample["triggerType"] = trigger_override +control_attempt_count = len(re.findall( + r"\[RealtimeTrace\][^\n]*\bside=sony\b[^\n]*\bstage=commandReceived\b" + r"[^\n]*\bcommandType=(?:NEXT|PREVIOUS)\b", + sony_text, +)) +observed_transition_count = stage_counts.get("mediaSessionTrackChanged", 0) +expected_triggers = { + "next": {"IOS_NEXT"}, + "previous": {"IOS_PREVIOUS"}, + "fast": {"IOS_NEXT", "IOS_PREVIOUS"}, + "auto": {"SONY_MEDIA_SESSION_NEXT"}, + "natural": {"NATURAL_AUTOPLAY"}, +}.get(mode, set()) +eligible_samples = [ + sample for sample in data.get("samples", []) + if sample.get("triggerType") in expected_triggers +] +sample_count = sum( + "COMPLETE" in sample.get("classifications", []) + for sample in eligible_samples +) +track_change_count = sum( + "NO_TRACK_CHANGE" not in sample.get("classifications", []) + and "COMMAND_ONLY" not in sample.get("classifications", []) + for sample in eligible_samples +) +complete_handoff_sample_count = sample_count +correctness_clean = ( + data.get("categories", {}).get("STALE_CONTENT", 0) == 0 + and data.get("diagnostics", {}).get("duplicate_control", 0) == 0 +) +if fast_switch: + # A 650 ms pressure command intentionally overlaps the normal handoff SLO. + # Validate delivery and correctness for every requested control; report the + # resulting transitions separately instead of demanding one settled track + # publication per command. + sample_count = control_attempt_count + complete = ( + data.get("eventCount", 0) > 0 + and control_attempt_count >= requested_runs + and correctness_clean + ) +else: + complete = data.get("eventCount", 0) > 0 and sample_count >= requested_runs +data.update({ + "result": "PASS" if complete else "FAIL", + "failureReason": None if complete else "trace_sample_incomplete", + "scenario": { + "mode": mode, + "runs": requested_runs, + "fastSwitch": fast_switch, + }, + "devices": {"ios": sys.argv[5], "sony": sys.argv[6]}, + "basicInfo": { + "commit": sys.argv[7], + "branch": sys.argv[8], + "protocolVersion": int(protocol_matches[-1]) if protocol_matches else None, + "mtu": int((mtu_matches or ios_mtu_matches)[-1]) + if (mtu_matches or ios_mtu_matches) else None, + "clockSyncTrusted": data.get("clock", {}).get("crossDeviceTrusted", False), + "clockSyncSampleCount": int(clock_matches[-1][0]) if clock_matches else 0, + "sampleCount": sample_count, + "completeHandoffSampleCount": complete_handoff_sample_count, + "trackChangeSampleCount": track_change_count, + "controlAttemptCount": control_attempt_count, + "observedTransitionCount": observed_transition_count, + "cacheState": { + "runtimeHit": data.get("stageCounts", {}).get("runtimeCacheHit", 0), + "runtimeMiss": data.get("stageCounts", {}).get("runtimeCacheMiss", 0), + "artworkHit": data.get("stageCounts", {}).get("albumArtCacheHit", 0), + }, + }, +}) +path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") +(path.parent / "sample_classification.json").write_text( + json.dumps(data.get("samples", []), ensure_ascii=False, indent=2) + "\n" +) +summary_path = Path(sys.argv[11]) +summary = summary_path.read_text(encoding="utf-8") +title, body = summary.split("\n\n", 1) +basic = data["basicInfo"] +summary_path.write_text( + title + "\n\n## Basic information\n\n" + + f"- Commit: {basic['commit']}\n" + + f"- Branch: {basic['branch']}\n" + + f"- iOS device: {data['devices']['ios']}\n" + + f"- Sony device: {data['devices']['sony']}\n" + + f"- Protocol version: {basic['protocolVersion']}\n" + + f"- MTU: {basic['mtu']}\n" + + f"- Clock sync trusted: {basic['clockSyncTrusted']}\n" + + f"- Samples: {sample_count}/{requested_runs}\n\n" + + f"- Observed transitions: {observed_transition_count}\n\n" + + body, + encoding="utf-8", +) +PY + +log "report=$OUT_DIR/report.json" +if [[ "$JSON_OUTPUT" == true ]]; then + cat "$OUT_DIR/report.json" +else + cat "$OUT_DIR/summary.md" +fi + +python3 - "$OUT_DIR/report.json" <<'PY' +import json +import sys +from pathlib import Path +data = json.loads(Path(sys.argv[1]).read_text()) +raise SystemExit(0 if data.get("result") == "PASS" else 1) +PY 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", diff --git a/tools/smoke/tests/test_prediction_source_audit_report.py b/tools/smoke/tests/test_prediction_source_audit_report.py new file mode 100644 index 0000000..93b97dd --- /dev/null +++ b/tools/smoke/tests/test_prediction_source_audit_report.py @@ -0,0 +1,149 @@ +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "prediction_source_audit_report.py" +SPEC = importlib.util.spec_from_file_location("prediction_source_audit_report", MODULE_PATH) +REPORT = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(REPORT) + + +def trace(stage, mono, track="-", generation=1, command="-"): + return ( + "[RealtimeTrace] side=sony " + f"stage={stage} monoMs={mono} commandSeq=1 commandType={command} " + f"trackId={track} generation={generation} transferId=- payloadType=- " + "queueWaitMs=- processingMs=- chunkIndex=- chunkCount=- result=ok reason=-" + ) + + +class PredictionSourceAuditReportTests(unittest.TestCase): + def test_queue_null_is_none_and_privacy_safe(self): + text = "\n".join([ + trace("commandReceived", 100, command="NEXT"), + "[PredictiveLyricsCandidate] queue diagnostic source=manual_next_with_queue " + "hasQueue=false queueSize=-1 activeQueueId=-1 currentQueueId=-1", + "[PredictiveLyricsCandidate] source=manual_next_with_queue unavailable reason=queue_null", + trace("mediaSessionTrackChanged", 200, track="actual", generation=2), + "[RuntimeCache] track changed trackId=actual songKey=Secret|Artist|Album " + "title=Secret generation=2", + ]) + report = REPORT.analyze(text, expected_transitions=1) + self.assertEqual(report["result"], "PASS") + self.assertEqual(report["sourceAudit"]["highConfidenceCandidateCount"], 0) + self.assertEqual(report["sourceAudit"]["queueAvailableObservationCount"], 0) + self.assertNotIn("Secret", str(report)) + + def test_confirmed_queue_candidate_matches_actual_identity(self): + text = "\n".join([ + trace("commandReceived", 100, command="NEXT"), + "[PredictiveLyricsCandidate] queue diagnostic source=manual_next_with_queue " + "hasQueue=true queueSize=3 activeQueueId=10 currentQueueId=10", + "[PredictiveLyricsCandidate] selected source=manual_next_with_queue " + "confidence=1.0 title=Next Song artist=Artist queueId=11 mediaId=stable-11 " + "reason=manual next with visible queue", + trace("mediaSessionTrackChanged", 260, track="actual", generation=2), + "[RuntimeCache] track changed trackId=actual songKey=Next Song|Artist|Album " + "title=Next Song generation=2", + ]) + report = REPORT.analyze(text, expected_transitions=1) + audit = report["sourceAudit"] + self.assertEqual(audit["highConfidenceCandidateCount"], 1) + self.assertEqual(audit["predictionHitCount"], 1) + self.assertEqual(audit["highConfidenceAccuracy"], 1.0) + self.assertEqual(audit["averageLeadTimeMs"], 160) + + def test_incomplete_transition_sample_fails(self): + report = REPORT.analyze("", expected_transitions=1) + self.assertEqual(report["result"], "FAIL") + self.assertEqual(report["failureReason"], "transition_sample_incomplete") + + def test_log_buffer_duplicate_is_counted_once(self): + command = trace("commandReceived", 100, command="NEXT") + queue = ( + "[PredictiveLyricsCandidate] queue diagnostic source=manual_next_with_queue " + "hasQueue=false queueSize=-1 activeQueueId=-1 currentQueueId=-1" + ) + changed = trace("mediaSessionTrackChanged", 200, track="actual", generation=2) + text = "\n".join([command, command, queue, queue, changed, changed]) + + report = REPORT.analyze(text, expected_transitions=1) + + self.assertEqual(report["sourceAudit"]["transitions"], 1) + self.assertEqual(report["sourceAudit"]["queueObservationCount"], 1) + + def test_v4_privacy_safe_candidate_uses_exact_promotion_result(self): + text = "\n".join([ + trace("commandReceived", 100, command="NEXT"), + "[PredictionSource] queue diagnostic source=manual_next_with_queue " + "hasQueue=true queueSize=3 activeQueueId=10 currentQueueId=10 " + "currentIdentityDigest=abc metadataTitlePresent=true " + "metadataArtistPresent=true metadataAlbumPresent=true " + "metadataDurationMs=180000 metadataMediaIdPresent=true " + "completeQueueIdentityCount=3", + "[PredictionSource] selected candidateId=candidate-1 " + "identityDigest=0123456789abcdef01234567 " + "source=manual_next_with_queue confidence=CONFIRMED", + trace("mediaSessionTrackChanged", 240, track="actual", generation=2), + "[PredictiveMedia] stage=predictionPromotionAttempt candidateId=candidate-1 " + "identityDigest=0123456789abcdef01234567 " + "source=manual_next_with_queue confidence=CONFIRMED result=started reason=", + "[PredictiveMedia] stage=predictionPromoted candidateId=candidate-1 " + "identityDigest=0123456789abcdef01234567 " + "source=manual_next_with_queue confidence=CONFIRMED result=success reason=", + ]) + + report = REPORT.analyze(text, expected_transitions=1) + audit = report["sourceAudit"] + self.assertEqual(audit["highConfidenceCandidateCount"], 1) + self.assertEqual(audit["predictionHitCount"], 1) + self.assertEqual(audit["highConfidenceAccuracy"], 1.0) + self.assertNotIn("Secret Song", str(report)) + + def test_v4_identity_mismatch_is_a_prediction_miss(self): + text = "\n".join([ + trace("commandReceived", 100, command="PREVIOUS"), + "[PredictionSource] selected candidateId=candidate-2 " + "identityDigest=abcdef0123456789abcdef01 " + "source=manual_previous_with_queue confidence=STRONG", + trace("mediaSessionTrackChanged", 260, track="actual", generation=2), + "[PredictiveMedia] stage=predictionRejected candidateId=candidate-2 " + "identityDigest=abcdef0123456789abcdef01 " + "source=manual_previous_with_queue confidence=STRONG " + "result=rejected reason=identity_mismatch", + ]) + + report = REPORT.analyze(text, expected_transitions=1) + audit = report["sourceAudit"] + self.assertEqual(audit["predictionMissCount"], 1) + self.assertEqual(audit["highConfidenceAccuracy"], 0.0) + + def test_history_candidate_is_counted_as_weak_only(self): + text = "\n".join([ + trace("commandReceived", 100, command="NEXT"), + "[PredictionSource] history candidateId=history-1 " + "identityDigest=abcdef0123456789abcdef01 confidence=WEAK count=2", + trace("mediaSessionTrackChanged", 200, track="actual", generation=2), + ]) + report = REPORT.analyze(text, expected_transitions=1) + audit = report["sourceAudit"] + self.assertEqual(audit["predictionCandidateCount"], 1) + self.assertEqual(audit["highConfidenceCandidateCount"], 0) + self.assertEqual(report["transitions"][0]["confidence"], "WEAK") + + def test_v4_unavailable_reason_is_counted(self): + text = "\n".join([ + trace("commandReceived", 100, command="NEXT"), + "[PredictionSource] queue diagnostic source=manual_next_with_queue " + "hasQueue=false queueSize=-1 activeQueueId=-1 currentQueueId=-1", + "[PredictionSource] source=manual_next_with_queue unavailable reason=queue_null", + trace("mediaSessionTrackChanged", 200, track="actual", generation=2), + ]) + report = REPORT.analyze(text, expected_transitions=1) + self.assertEqual(report["sourceAudit"]["rejectedReasonCounts"]["queue_null"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/smoke/tests/test_predictive_media_report.py b/tools/smoke/tests/test_predictive_media_report.py new file mode 100644 index 0000000..8a9e3f8 --- /dev/null +++ b/tools/smoke/tests/test_predictive_media_report.py @@ -0,0 +1,124 @@ +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "predictive_media_report.py" +SPEC = importlib.util.spec_from_file_location("predictive_media_report", MODULE_PATH) +REPORT = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(REPORT) + + +def trace(stage, mono, side="ios", track="track-1", generation=1, result="published", reason="-"): + return ( + f"[RealtimeTrace] side={side} stage={stage} monoMs={mono} " + f"commandSeq=- commandType=- trackId={track} generation={generation} " + f"transferId=- payloadType=fullLyrics queueWaitMs=- processingMs=- " + f"chunkIndex=- chunkCount=- result={result} reason={reason}" + ) + + +def audit(high=0, hits=0, misses=0): + return { + "result": "PASS", + "sourceAudit": { + "predictionEligibleCount": 10, + "predictionCandidateCount": high, + "highConfidenceCandidateCount": high, + "predictionHitCount": hits, + "predictionMissCount": misses, + "highConfidenceAccuracy": hits / (hits + misses) if hits + misses else None, + }, + } + + +class PredictiveMediaReportTests(unittest.TestCase): + def test_no_candidate_marks_warm_not_applicable(self): + report = REPORT.analyze("", "", audit()) + self.assertEqual(report["latency"]["warmResult"], "NOT_APPLICABLE") + + def test_candidate_hit_is_reported_without_fabricated_warm_latency(self): + report = REPORT.analyze("", "", audit(high=1, hits=1)) + self.assertEqual(report["prediction"]["hitCount"], 1) + self.assertEqual(report["latency"]["warmResult"], "TRACE_CORRELATION_UNAVAILABLE") + self.assertEqual(report["latency"]["warm"]["trackToCurrentLyric"]["count"], 0) + + def test_candidate_error_preserves_accuracy(self): + report = REPORT.analyze("", "", audit(high=1, misses=1)) + self.assertEqual(report["prediction"]["accuracy"], 0.0) + + def test_prewarm_complete_is_counted(self): + sony = "\n".join([ + "[PredictiveMedia] stage=predictionPrewarmQueued candidateId=c1 " + "identityDigest=d1 source=queue confidence=CONFIRMED result=queued reason=", + "[PredictiveMedia] stage=predictionReady candidateId=c1 " + "identityDigest=d1 source=queue confidence=CONFIRMED result=ready reason=", + ]) + report = REPORT.analyze(sony, "", audit()) + self.assertEqual(report["prewarm"]["ready"], 1) + + def test_prewarm_incomplete_has_no_ready_count(self): + sony = ( + "[PredictiveMedia] stage=predictionPrewarmQueued candidateId=c1 " + "identityDigest=d1 source=queue confidence=CONFIRMED result=queued reason=" + ) + report = REPORT.analyze(sony, "", audit()) + self.assertEqual(report["prewarm"]["ready"], 0) + + def test_cache_hit_is_counted(self): + report = REPORT.analyze( + trace("cacheValidationHit", 100, side="sony"), + trace("cacheValidationHit", 100), + audit(), + ) + self.assertEqual(report["cache"]["fullLyricsCacheValidationHit"], 1) + + def test_full_lyrics_skip_and_saved_bytes_are_counted(self): + sony = trace("fullLyricsTransferSkipped", 100, side="sony") + " bytesSaved=4096" + report = REPORT.analyze(sony, "", audit()) + self.assertEqual(report["cache"]["fullLyricsTransferSkipped"], 1) + self.assertEqual(report["cache"]["fullLyricsBytesSaved"], 4096) + + def test_missing_trace_stays_missing(self): + report = REPORT.analyze("not a trace", "not a trace", audit()) + self.assertEqual(report["trace"]["eventCount"], 0) + self.assertEqual(report["latency"]["cold"]["trackToCurrentWord"]["count"], 0) + + def test_out_of_order_events_are_sorted_before_latency_calculation(self): + ios = "\n".join([ + trace("currentLyricPublished", 250), + trace("trackIdentityAccepted", 100, result="accepted"), + ]) + report = REPORT.analyze("", ios, audit()) + self.assertEqual(report["latency"]["cold"]["trackToCurrentLyric"]["p95"], 150) + + def test_stale_accepted_event_fails_correctness(self): + ios = trace("currentWordPublished", 100, result="accepted", reason="stale_generation") + report = REPORT.analyze("", ios, audit()) + self.assertEqual(report["result"], "FAIL") + self.assertEqual(report["correctness"]["staleAccepted"], 1) + + def test_dual_transfer_ids_remain_distinct(self): + sony = "\n".join([ + trace("fullLyricsSendStart", 100, side="sony").replace("transferId=-", "transferId=a"), + trace("fullLyricsSendStart", 100, side="sony").replace("transferId=-", "transferId=b"), + ]) + report = REPORT.analyze(sony, "", audit()) + self.assertEqual(report["trace"]["stageCounts"]["fullLyricsSendStart"], 2) + + def test_warm_and_cold_sections_always_exist(self): + ios = "\n".join([ + trace("trackIdentityAccepted", 100, result="accepted"), + trace("fullLyricsPublished", 200), + ]) + report = REPORT.analyze("", ios, audit()) + self.assertIn("warm", report["latency"]) + self.assertEqual( + report["latency"]["cold"]["trackToFullLyricsAvailable"]["p95"], + 100, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/smoke/tests/test_realtime_latency_report.py b/tools/smoke/tests/test_realtime_latency_report.py new file mode 100644 index 0000000..06c70e5 --- /dev/null +++ b/tools/smoke/tests/test_realtime_latency_report.py @@ -0,0 +1,656 @@ +import importlib.util +import sys +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "realtime_latency_report.py" +SPEC = importlib.util.spec_from_file_location("realtime_latency_report", MODULE_PATH) +REPORT = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = REPORT +SPEC.loader.exec_module(REPORT) + + +def event(side, stage, mono, **kwargs): + if side == "ios" and stage == "trackIdentityAccepted": + kwargs.setdefault("result", "changed") + return REPORT.Event(side=side, stage=stage, mono_ms=mono, **kwargs) + + +class RealtimeLatencyReportTests(unittest.TestCase): + def phase4_complete_events(self): + common = {"handoff_id": "command-9", "track_id": "track-a", "generation": 4} + return [ + event("ios", "commandIntent", 0, command_seq=9, command_type="NEXT", handoff_id="command-9", trigger_type="IOS_NEXT", source_line=1), + event("ios", "commandEnqueued", 1, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=2), + event("ios", "commandWriteStart", 2, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=3), + event("ios", "commandWriteCallback", 4, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=4), + event("sony", "commandReceived", 5, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=5), + event("sony", "commandValidated", 6, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=6), + event("sony", "mediaControlDispatchStart", 7, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=7), + event("sony", "mediaControlDispatchEnd", 8, command_seq=9, command_type="NEXT", handoff_id="command-9", source_line=8), + event("sony", "notificationMetadataObserved", 9, handoff_id="command-9", source_line=9), + event("sony", "playbackReadStart", 9, handoff_id="command-9", source_line=10), + event("sony", "mediaSessionMetadataObserved", 10, **common, source_line=11), + event("sony", "trackIdentityCandidate", 10, **common, source_line=12), + event("sony", "trackIdentityAccepted", 11, **common, source_line=13), + event("sony", "mediaGenerationCreated", 12, **common, source_line=14), + event("sony", "playbackReady", 13, **common, source_line=15), + event("sony", "playbackEnqueued", 14, **common, source_line=16), + event("sony", "playbackDequeued", 15, queue_wait_ms=1, **common, source_line=17), + event("sony", "playbackNotifyStart", 16, **common, source_line=18), + event("sony", "playbackNotifyCallback", 17, **common, source_line=19), + event("ios", "playbackNotifyReceived", 18, **common, source_line=20), + event("ios", "playbackDecodeEnd", 19, payload_type="playbackState", **common, source_line=21), + event("ios", "trackIdentityAccepted", 20, result="changed", **common, source_line=22), + event("ios", "playbackStatePublished", 21, payload_type="playbackState", **common, source_line=23), + event("ios", "nowPlayingStateConsumed", 22, **common, source_line=24), + event("sony", "lyricReady", 23, result="ready", word_timing_status="AVAILABLE", cache_source="PARSED", **common, source_line=25), + event("sony", "lyricCurrentLineEnqueued", 24, **common, source_line=26), + event("ios", "lyricCurrentLinePublished", 25, **common, source_line=27), + event("sony", "currentWordEligible", 26, word_timing_status="AVAILABLE", **common, source_line=28), + event("sony", "currentWordSchedulerCreated", 27, **common, source_line=29), + event("sony", "currentWordImmediateSnapshotEnqueued", 28, **common, source_line=30), + event("sony", "currentWordSendStart", 29, **common, source_line=31), + event("ios", "currentWordReceived", 30, **common, source_line=32), + event("ios", "currentWordAccepted", 31, **common, source_line=33), + event("ios", "currentWordPublished", 32, **common, source_line=34), + ] + + def test_phase4_complete_handoff_breakdown_and_classification(self): + report = REPORT.analyze( + self.phase4_complete_events(), + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + self.assertEqual(report["metrics"]["commandIntentToWriteStartMs"]["p50"], 2) + self.assertEqual(report["metrics"]["dispatchEndToMetadataObservedMs"]["p50"], 1) + self.assertEqual(report["metrics"]["metadataObservedToTrackAcceptedMs"]["p50"], 1) + self.assertEqual(report["metrics"]["publishToUiConsumeMs"]["p50"], 2) + self.assertEqual(report["metrics"]["wordEligibleToPublishMs"]["p50"], 6) + self.assertIn("COMPLETE", report["samples"][0]["classifications"]) + self.assertEqual(report["missingEvents"], []) + + def test_phase4_prefers_ios_command_anchor_across_unrelated_monotonic_clocks(self): + events = self.phase4_complete_events() + events.append(event( + "sony", + "mediaSessionMetadataObserved", + 1, + command_seq=9, + command_type="NEXT", + track_id="track-a", + generation=4, + source_line=90, + )) + samples, _ = REPORT.classify_transition_samples(events, clock_trusted=True) + self.assertEqual(len(samples), 1) + self.assertEqual(samples[0]["handoffId"], "command-9") + self.assertEqual(samples[0]["triggerType"], "IOS_NEXT") + + def test_phase4_correlates_automatic_handoff_across_trace_id_and_digest_width(self): + common_sony = { + "handoff_id": "sony-100-7", + "track_id": "123456789abc-full-digest", + } + common_ios = { + "handoff_id": "ios-1100-1", + "track_id": "123456789abc", + "generation": 7, + } + events = [ + event("sony", "notificationMetadataObserved", 99, **common_sony, source_line=1), + event("sony", "playbackReadStart", 99, **common_sony, source_line=2), + event("sony", "mediaSessionMetadataObserved", 100, **common_sony, source_line=3), + event("sony", "trackIdentityCandidate", 101, **common_sony, source_line=4), + event("sony", "trackIdentityAccepted", 102, generation=7, **common_sony, source_line=5), + event("sony", "mediaGenerationCreated", 103, generation=7, **common_sony, source_line=6), + event("sony", "playbackReady", 104, generation=7, **common_sony, source_line=7), + event("sony", "playbackEnqueued", 105, generation=7, **common_sony, source_line=8), + event("sony", "playbackDequeued", 106, generation=7, **common_sony, source_line=9), + event("sony", "playbackNotifyStart", 107, generation=7, **common_sony, source_line=10), + event("sony", "playbackNotifyCallback", 108, generation=7, **common_sony, source_line=11), + event("ios", "trackIdentityAccepted", 1100, result="changed", **common_ios, source_line=12), + event("ios", "nowPlayingStateConsumed", 1101, **common_ios, source_line=13), + event("ios", "playbackNotifyReceived", 1102, **common_ios, source_line=14), + event("ios", "playbackDecodeEnd", 1103, **common_ios, source_line=15), + event("ios", "playbackStatePublished", 1104, **common_ios, source_line=16), + ] + samples, missing = REPORT.classify_transition_samples( + events, + clock_trusted=True, + sony_to_ios_offset_ms=1_000, + ) + self.assertEqual(len(samples), 1) + self.assertIn("COMPLETE", samples[0]["classifications"]) + self.assertEqual(samples[0]["eventCount"], len(events)) + self.assertEqual(missing, []) + + def test_phase4_automatic_refresh_is_not_counted_as_track_change(self): + command_stages = { + "commandIntent", "commandEnqueued", "commandWriteStart", + "commandWriteCallback", "commandReceived", "commandValidated", + "mediaControlDispatchStart", "mediaControlDispatchEnd", + } + events = [] + for item in self.phase4_complete_events(): + if item.stage in command_stages: + continue + if item.side == "ios" and item.stage == "trackIdentityAccepted": + item = replace(item, result="refreshed") + events.append(item) + + samples, missing = REPORT.classify_transition_samples( + events, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + + self.assertEqual(len(samples), 1) + self.assertEqual(missing, []) + self.assertIn("NO_TRACK_CHANGE", samples[0]["classifications"]) + self.assertNotIn("COMPLETE", samples[0]["classifications"]) + + def test_phase4_missing_ios_and_sony_events_are_named(self): + ios_missing = [ + item for item in self.phase4_complete_events() + if item.stage != "nowPlayingStateConsumed" + ] + report = REPORT.analyze(ios_missing, clock_trusted=True, sony_to_ios_offset_ms=0) + self.assertIn("nowPlayingStateConsumed", report["missingEvents"][0]["missingEvents"]) + sony_missing = [ + item for item in self.phase4_complete_events() + if item.stage != "mediaGenerationCreated" + ] + report = REPORT.analyze(sony_missing, clock_trusted=True, sony_to_ios_offset_ms=0) + self.assertIn("mediaGenerationCreated", report["missingEvents"][0]["missingEvents"]) + + def test_phase4_current_line_uses_first_enqueue_after_lyrics_ready(self): + events = [ + item for item in self.phase4_complete_events() + if item.stage != "lyricCurrentLineEnqueued" + ] + events.extend([ + event( + "sony", "lyricCurrentLineEnqueued", 5, + track_id="track-a", generation=4, source_line=80, + ), + event( + "sony", "lyricCurrentLineEnqueued", 27, + track_id="track-a", generation=4, source_line=81, + ), + ]) + report = REPORT.analyze( + events, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + self.assertEqual( + report["metrics"]["lyricReadyToCurrentLineEnqueueMs"]["p50"], + 4, + ) + + def test_phase4_current_line_publish_ignores_earlier_cached_slice(self): + events = self.phase4_complete_events() + events.append(event( + "ios", "lyricCurrentLinePublished", 10, + track_id="track-a", generation=4, result="published", source_line=82, + )) + report = REPORT.analyze( + events, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + metric = report["metrics"]["currentLineEnqueueToPublishMs"] + self.assertEqual(metric["count"], 1) + self.assertEqual(metric["p50"], 1) + self.assertEqual(metric["missing"], 0) + + def test_phase4_currentword_slo_ignores_reconnect_identity_refresh(self): + shifted_handoff = [ + REPORT.replace( + item, + mono_ms=item.mono_ms + 1_000, + source_line=item.source_line + 20, + ) + for item in self.phase4_complete_events() + ] + reconnect_baseline = [ + event( + "sony", "currentWordEligible", 1, + track_id="track-old", generation=3, source_line=1, + ), + event( + "sony", "currentWordSchedulerCreated", 2, + track_id="track-old", generation=3, source_line=2, + ), + event( + "sony", "currentWordImmediateSnapshotEnqueued", 3, + track_id="track-old", generation=3, source_line=3, + ), + event( + "sony", "currentWordSendStart", 4, + track_id="track-old", generation=3, source_line=4, + ), + event( + "ios", "trackIdentityAccepted", 5, result="refreshed", + track_id="track-old", generation=3, source_line=5, + ), + event( + "ios", "currentWordReceived", 900, + track_id="track-old", generation=3, source_line=6, + ), + event( + "ios", "currentWordAccepted", 901, + track_id="track-old", generation=3, source_line=7, + ), + event( + "ios", "currentWordPublished", 902, + track_id="track-old", generation=3, source_line=8, + ), + ] + report = REPORT.analyze( + [*reconnect_baseline, *shifted_handoff], + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + metric = report["metrics"]["wordEligibleToPublishMs"] + self.assertEqual(metric["count"], 1) + self.assertEqual(metric["p50"], 6) + + def test_phase4_command_only_is_not_a_handoff_sample(self): + report = REPORT.analyze([ + event("ios", "commandIntent", 1, command_seq=3, command_type="NEXT", handoff_id="command-3", source_line=1), + event("ios", "commandEnqueued", 2, command_seq=3, command_type="NEXT", handoff_id="command-3", source_line=2), + ]) + labels = report["samples"][0]["classifications"] + self.assertIn("NO_TRACK_CHANGE", labels) + self.assertIn("COMMAND_ONLY", labels) + self.assertNotIn("COMPLETE", labels) + + def test_phase4_control_segments_ignore_protocol_commands(self): + events = self.phase4_complete_events() + events.extend([ + event( + "ios", "commandIntent", 40, command_seq=10, + command_type="CAPABILITIES", source_line=40, + ), + event( + "ios", "commandWriteStart", 140, command_seq=10, + command_type="CAPABILITIES", source_line=41, + ), + event( + "ios", "commandWriteCallback", 340, command_seq=10, + command_type="CAPABILITIES", source_line=42, + ), + ]) + report = REPORT.analyze( + events, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + self.assertEqual(report["metrics"]["commandIntentToWriteStartMs"]["count"], 1) + self.assertEqual(report["metrics"]["commandIntentToWriteStartMs"]["p50"], 2) + self.assertEqual(report["metrics"]["writeStartToCallbackMs"]["p50"], 2) + + def test_phase4_handoff_pairs_payloads_without_generation(self): + events = self.phase4_complete_events() + normalized = [] + for item in events: + if item.stage in { + "mediaSessionMetadataObserved", + "playbackDecodeEnd", + }: + item = REPORT.replace(item, track_id="", generation=None) + normalized.append(item) + report = REPORT.analyze( + normalized, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + self.assertEqual(report["metrics"]["metadataObservedToTrackAcceptedMs"]["p50"], 1) + self.assertEqual(report["metrics"]["iosReceiveToDecodeMs"]["p50"], 1) + self.assertEqual(report["metrics"]["publishToUiConsumeMs"]["p50"], 2) + + def test_phase4_line_only_intro_and_rejected_word_classification(self): + events = self.phase4_complete_events() + events.extend([ + event("sony", "currentWordNotEligible", 24, reason="INTRO_WAIT", word_timing_status="LINE_ONLY", handoff_id="command-9", track_id="track-a", generation=4, source_line=40), + event("ios", "currentWordRejected", 31, reason="SEQUENCE_OLD", handoff_id="command-9", track_id="track-a", generation=4, source_line=41), + event("sony", "lyricParsedCacheMiss", 12, handoff_id="command-9", track_id="track-a", generation=4, source_line=42), + ]) + report = REPORT.analyze(events) + labels = report["samples"][0]["classifications"] + self.assertIn("LINE_ONLY_LYRICS", labels) + self.assertIn("INTRO_WAIT", labels) + self.assertIn("STALE_REJECTED", labels) + self.assertIn("LYRIC_CACHE_MISS", labels) + self.assertEqual(report["metrics"]["trackAcceptedToFirstWordMs"]["count"], 0) + self.assertEqual(report["diagnostics"]["intro_wait_before_first_word"], 1) + + def test_phase4_records_qqmusic_cache_wait_without_calling_ready_track_no_lyrics(self): + events = self.phase4_complete_events() + events.insert( + 25, + event( + "sony", "lyricReady", 22, + result="not_ready", + failure_reason="WAITING_QQMUSIC_CACHE", + handoff_id="command-9", + track_id="track-a", + generation=4, + source_line=70, + ), + ) + + report = REPORT.analyze( + events, + clock_trusted=True, + sony_to_ios_offset_ms=0, + ) + + sample = report["samples"][0] + self.assertIn("WAITING_QQMUSIC_CACHE", sample["classifications"]) + self.assertEqual( + sample["lyricMissingReasons"], + ["WAITING_QQMUSIC_CACHE"], + ) + self.assertNotIn("NO_LYRICS", sample["classifications"]) + self.assertEqual( + report["lyricMissingReasonCounts"], + {"WAITING_QQMUSIC_CACHE": 1}, + ) + + def test_phase4_dual_handoffs_remain_isolated(self): + first = self.phase4_complete_events() + second = [ + REPORT.replace( + item, + handoff_id="command-10", + command_seq=10 if item.command_seq is not None else None, + track_id="track-b" if item.track_id is not None else None, + generation=5 if item.generation is not None else None, + mono_ms=item.mono_ms + 100, + source_line=item.source_line + 100, + ) + for item in first + ] + report = REPORT.analyze(first + second, clock_trusted=True, sony_to_ios_offset_ms=0) + self.assertEqual(len(report["samples"]), 2) + self.assertEqual({sample["handoffId"] for sample in report["samples"]}, {"command-9", "command-10"}) + def test_normal_trace_and_percentiles(self): + events = [ + event( + "ios", "commandIntent", 90, command_seq=1, command_type="NEXT", + source_line=1, + ), + event("ios", "trackIdentityAccepted", 100, track_id="t", generation=1, source_line=2), + event("ios", "currentWordPublished", 160, track_id="t", generation=1, source_line=3), + event("ios", "currentWordPublished", 170, track_id="t", generation=1, source_line=4), + event("sony", "notifyDequeued", 200, queue_wait_ms=12, source_line=5), + event("ios", "playbackDecodeEnd", 220, processing_ms=4, source_line=6), + event( + "sony", "pendingQueued", 290, track_id="t", generation=1, + source_line=7, + ), + event( + "sony", "lyricReady", 300, track_id="t", generation=1, + result="ready", source_line=8, + ), + event("sony", "pendingFlush", 325, track_id="t", generation=1, source_line=9), + ] + report = REPORT.analyze(events) + self.assertEqual(report["metrics"]["commandToTrackPublishMs"]["p50"], 10) + self.assertEqual(report["metrics"]["trackToCurrentWordMs"]["p50"], 60) + self.assertEqual(report["metrics"]["trackToCurrentWordMs"]["count"], 1) + self.assertEqual(report["metrics"]["notifyQueueWaitMs"]["avg"], 12) + self.assertEqual(report["metrics"]["iOSDecodeDurationMs"]["max"], 4) + self.assertEqual(report["metrics"]["lyricReadyToPendingFlushMs"]["p50"], 25) + + def test_missing_and_empty(self): + report = REPORT.analyze([ + event( + "ios", "commandIntent", 90, command_seq=1, command_type="NEXT", + source_line=1, + ), + event("ios", "trackIdentityAccepted", 100, track_id="t", generation=1, source_line=2), + ]) + self.assertEqual(report["metrics"]["trackToCurrentWordMs"]["missing"], 1) + self.assertEqual(REPORT.analyze([])["diagnostics"]["empty"], 1) + + def test_duplicate_out_of_order_stale_and_extreme(self): + duplicate = event("ios", "trackIdentityAccepted", 200, track_id="t", generation=2, source_line=1) + events = [ + event( + "ios", "commandIntent", 190, command_seq=1, command_type="NEXT", + source_line=7, + ), + duplicate, + event("ios", "trackIdentityAccepted", 200, track_id="t", generation=2, source_line=2), + event("ios", "currentWordPublished", 100, track_id="t", generation=2, source_line=3), + event("ios", "trackIdentityAccepted", 300, track_id="t", generation=1, source_line=4), + event("sony", "fullLyricsSendStart", 1, track_id="x", generation=1, source_line=5), + event("sony", "fullLyricsSendEnd", 130_002, track_id="x", generation=1, source_line=6), + ] + report = REPORT.analyze(events) + self.assertGreaterEqual(report["diagnostics"]["duplicate"], 1) + self.assertGreaterEqual(report["diagnostics"]["out_of_order"], 1) + self.assertGreaterEqual(report["diagnostics"]["stale_generation"], 1) + self.assertGreaterEqual(report["diagnostics"]["extreme"], 1) + + def test_unknown_generation_zero_is_not_reported_as_stale(self): + report = REPORT.analyze([ + event( + "ios", "trackIdentityAccepted", 100, + track_id="t", generation=1, source_line=1, + ), + event( + "ios", "playbackStatePublished", 110, + track_id="t", generation=0, source_line=2, + ), + ]) + self.assertEqual(report["diagnostics"].get("stale_generation", 0), 0) + + def test_cross_device_requires_trusted_clock(self): + events = [ + event("ios", "commandIntent", 100, command_seq=7, command_type="NEXT", source_line=1), + event("sony", "commandReceived", 30, command_seq=7, command_type="NEXT", source_line=2), + ] + blocked = REPORT.analyze(events) + self.assertEqual(blocked["metrics"]["commandToSonyReceiveMs"]["count"], 0) + self.assertEqual(blocked["categories"]["CLOCK_SYNC_UNTRUSTED"], 1) + trusted = REPORT.analyze(events, clock_trusted=True, sony_to_ios_offset_ms=90) + self.assertEqual(trusted["metrics"]["commandToSonyReceiveMs"]["p50"], 20) + near_zero = REPORT.analyze(events, clock_trusted=True, sony_to_ios_offset_ms=65) + self.assertEqual(near_zero["metrics"]["commandToSonyReceiveMs"]["p50"], 0) + self.assertEqual(near_zero["diagnostics"]["clock_uncertainty_clamped"], 1) + + def test_manual_track_pairing_does_not_shift_after_missed_command(self): + events = [ + event( + "ios", "commandIntent", 100, command_seq=1, + command_type="NEXT", source_line=1, + ), + event( + "ios", "commandIntent", 200, command_seq=2, + command_type="NEXT", source_line=2, + ), + event( + "ios", "trackIdentityAccepted", 240, track_id="new", + generation=2, source_line=3, + ), + ] + report = REPORT.analyze(events) + metric = report["metrics"]["commandToTrackPublishMs"] + self.assertEqual(metric["count"], 1) + self.assertEqual(metric["p50"], 40) + self.assertEqual(metric["missing"], 1) + + def test_track_media_metrics_start_at_accepted_identity_not_command(self): + events = [ + event( + "ios", "commandIntent", 100, command_seq=1, + command_type="NEXT", source_line=1, + ), + event( + "ios", "trackIdentityAccepted", 200, track_id="new", + generation=2, source_line=2, + ), + event( + "ios", "currentLyricPublished", 260, track_id="new", + generation=2, source_line=3, + ), + event( + "ios", "currentWordPublished", 270, track_id="new", + generation=2, source_line=4, + ), + event( + "ios", "previewPublished", 280, track_id="new", + generation=2, source_line=5, + ), + ] + report = REPORT.analyze(events) + self.assertEqual(report["metrics"]["commandToTrackPublishMs"]["p50"], 100) + self.assertEqual(report["metrics"]["trackToCurrentLyricMs"]["p50"], 60) + self.assertEqual(report["metrics"]["trackToCurrentWordMs"]["p50"], 70) + self.assertEqual(report["metrics"]["trackToPreviewArtMs"]["p50"], 80) + + def test_automatic_t0_requires_trusted_clock(self): + events = [ + event( + "sony", "trackIdentityAccepted", 100, track_id="t", generation=3, + source_line=1, + ), + event( + "ios", "trackIdentityAccepted", 210, track_id="t", generation=3, + source_line=2, + ), + event( + "ios", "currentLyricPublished", 260, track_id="t", generation=3, + source_line=3, + ), + ] + blocked = REPORT.analyze(events) + self.assertEqual(blocked["metrics"]["commandToTrackPublishMs"]["count"], 0) + trusted = REPORT.analyze( + events, + clock_trusted=True, + sony_to_ios_offset_ms=80, + ) + self.assertEqual(trusted["metrics"]["commandToTrackPublishMs"]["p50"], 30) + self.assertEqual(trusted["metrics"]["trackToCurrentLyricMs"]["p50"], 50) + + def test_correctness_flags_stale_publish_and_duplicate_control(self): + events = [ + event( + "ios", "trackIdentityAccepted", 100, track_id="new", generation=2, + source_line=1, + ), + event( + "ios", "currentWordPublished", 110, track_id="old", generation=1, + source_line=2, + ), + event( + "ios", "previewPublished", 120, track_id="old", generation=1, + source_line=3, + ), + event( + "sony", "commandReceived", 10, command_seq=5, command_type="NEXT", + source_line=4, + ), + event( + "sony", "commandReceived", 11, command_seq=5, command_type="NEXT", + source_line=5, + ), + event( + "sony", "mediaControlDispatchStart", 12, command_seq=5, + command_type="NEXT", source_line=6, + ), + event( + "sony", "mediaControlDispatchStart", 13, command_seq=5, + command_type="NEXT", source_line=7, + ), + ] + report = REPORT.analyze(events) + self.assertGreaterEqual(report["categories"]["STALE_CONTENT"], 1) + self.assertEqual(report["diagnostics"]["wrong_current_word"], 1) + self.assertEqual(report["diagnostics"]["wrong_artwork"], 1) + self.assertEqual(report["diagnostics"]["duplicate_control"], 1) + self.assertEqual(report["diagnostics"]["control_reconnect_resend"], 1) + + def test_parser_rejects_malformed_and_ignores_payload_body(self): + line = ( + "prefix [RealtimeTrace] side=ios stage=playbackDecodeEnd monoMs=10 " + "commandSeq=- commandType=- trackId=t generation=1 transferId=- " + "payloadType=playbackState queueWaitMs=- processingMs=3 chunkIndex=- " + "chunkCount=- result=success reason=-" + ) + parsed = REPORT.parse_trace_line(line, 8) + self.assertEqual(parsed.processing_ms, 3) + self.assertEqual(parsed.source_line, 8) + self.assertIsNone(REPORT.parse_trace_line("[RealtimeTrace] side=ios stage=x")) + + def test_clock_sync_discovery_requires_confident_sample(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ios.log" + path.write_text( + "[ClockSync] pong seq=1 bestRttMs=20 offsetMs=123 " + "jitterMs=4 samples=3 confident=true\n" + ) + self.assertEqual(REPORT.discover_clock_sync([path]), (True, 123)) + + def test_clock_sync_discovery_allows_initial_convergence(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ios.log" + path.write_text( + "[ClockSync] pong seq=1 offsetMs=120 jitterMs=8 samples=1 confident=false\n" + "[ClockSync] pong seq=2 offsetMs=122 jitterMs=4 samples=2 confident=false\n" + "[ClockSync] pong seq=3 offsetMs=123 jitterMs=2 samples=3 confident=true\n" + ) + self.assertEqual(REPORT.discover_clock_sync([path]), (True, 123)) + + def test_clock_sync_discovery_rejects_offset_across_reconnect(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ios.log" + path.write_text( + "[ClockSync] pong seq=5 offsetMs=123 jitterMs=2 samples=5 confident=true\n" + "[ClockSync] pong seq=6 offsetMs=451 jitterMs=9 samples=1 confident=false\n" + "[ClockSync] pong seq=8 offsetMs=450 jitterMs=2 samples=3 confident=true\n" + ) + self.assertEqual(REPORT.discover_clock_sync([path]), (False, None)) + + def test_report_bundle_contains_required_files_without_trace_input(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "report" + self.assertEqual(REPORT.main(["--output-dir", str(output)]), 0) + required = { + "report.json", + "summary.md", + "raw_events.jsonl", + "sony_trace.log", + "ios_trace.log", + "sample_classification.json", + "missing_events.json", + } + self.assertEqual({path.name for path in output.iterdir()}, required) + + def test_report_bundle_preserves_in_place_trace_sources(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + sony = output / "sony_trace.log" + ios = output / "ios_trace.log" + sony.write_text("sony-event\n", encoding="utf-8") + ios.write_text("ios-event\n", encoding="utf-8") + self.assertEqual(REPORT.main([ + "--sony-trace", str(sony), + "--ios-trace", str(ios), + "--output-dir", str(output), + ]), 0) + self.assertEqual(sony.read_text(encoding="utf-8"), "sony-event\n") + self.assertEqual(ios.read_text(encoding="utf-8"), "ios-event\n") + + +if __name__ == "__main__": + unittest.main()