From e3dc034d3b8c3a6ef662ec77b332969bbdb9c2a3 Mon Sep 17 00:00:00 2001 From: sqz Date: Sat, 25 Jul 2026 23:20:55 +0800 Subject: [PATCH 01/13] feat: optimize QQ Music cross-device performance --- .github/workflows/build.yml | 52 ++ .../project.pbxproj | 135 ++++ .../IOSBleFeasibility/AlbumArtReceiver.swift | 472 +++++++++--- .../ArtworkEnhancementManager.swift | 2 +- .../IOSBleFeasibility/ArtworkImageCache.swift | 191 +++++ .../BLEBinaryChunkCodec.swift | 67 ++ .../BLEObservableStateModels.swift | 86 +++ .../IOSBleFeasibility/BLETestManager.swift | 719 +++++++++++++++++- .../IOSBleFeasibility/ContentView.swift | 139 +++- .../LastNowPlayingSnapshotStore.swift | 133 ++++ .../IOSBleFeasibility/MediaLoadingState.swift | 74 ++ .../PlaybackHistoryView.swift | 16 +- .../IOSBleFeasibility/PreferencesStore.swift | 68 +- .../IOSBleFeasibility/PreferencesView.swift | 22 + .../PerformanceStabilityTests.swift | 187 +++++ PlayerAgentApp/src/debug/AndroidManifest.xml | 1 + .../debug/PlayerAgentDebugControlReceiver.kt | 9 + .../playeragent/ble/BleGattServerManager.kt | 377 ++++----- .../example/playeragent/ble/BleLinkProfile.kt | 132 ++++ .../example/playeragent/ble/BleNotifyQueue.kt | 288 +++++-- .../playeragent/ble/BleTransferCodec.kt | 22 + .../playeragent/ble/CompressedLyricsCache.kt | 63 ++ .../ble/MediaTransferCoordinators.kt | 132 ++++ .../media/MediaExecutionContext.kt | 49 ++ .../service/PlayerAgentForegroundService.kt | 2 +- .../playeragent/ble/BleLinkProfileTest.kt | 42 + .../ble/BleNotifyQueuePolicyTest.kt | 17 + .../playeragent/ble/BleProtocolChaosTest.kt | 100 +++ .../playeragent/ble/BleTransferCodecTest.kt | 37 + .../ble/CompressedLyricsCacheTest.kt | 54 ++ .../ble/MediaTransferCoordinatorsTest.kt | 88 +++ .../media/MediaExecutionContextTest.kt | 24 + docs/ALBUM_ART_ARCHITECTURE.md | 14 +- docs/BLE_PROTOCOL.md | 21 +- docs/IOS_ARCHITECTURE.md | 20 +- docs/LYRICS_ARCHITECTURE.md | 8 + docs/RECONNECT_HEALTH_ARCHITECTURE.md | 8 +- docs/SMOKE_TEST_GUIDE.md | 18 +- tools/android-smoke-tests/README.md | 10 +- .../android_build_install.sh | 12 +- tools/ios-smoke-tests/ios_launch.sh | 27 + tools/smoke/README.md | 4 +- tools/smoke/ios_ble_precheck.sh | 29 +- tools/smoke/lyrics_timeline_v34_test.sh | 6 +- tools/smoke/reconnect_sync_v28_test.sh | 32 +- 45 files changed, 3544 insertions(+), 465 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibility/BLEBinaryChunkCodec.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibility/LastNowPlayingSnapshotStore.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibility/MediaLoadingState.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/media/MediaExecutionContext.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleProtocolChaosTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/CompressedLyricsCacheTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/MediaExecutionContextTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a1844eb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Cross-platform build and tests + +on: + push: + branches: + - main + - "codex/**" + pull_request: + +concurrency: + group: cross-platform-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + name: Android unit tests and debug APK + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + - name: Test and assemble PlayerAgent + run: bash ./gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug + - uses: actions/upload-artifact@v4 + with: + name: PlayerAgent-debug-apk + path: PlayerAgentApp/build/outputs/apk/debug/*.apk + + ios: + name: iOS simulator XCTest + runs-on: macos-15 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 16.2 when available + run: | + if [ -d /Applications/Xcode_16.2.app ]; then + sudo xcode-select -s /Applications/Xcode_16.2.app + fi + xcodebuild -version + - name: Run unsigned simulator tests + run: | + xcodebuild \ + -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ + -scheme sonyMusic \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj b/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj index 4bdc143..84521c4 100644 --- a/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj +++ b/IOSBleFeasibility/IOSBleFeasibility.xcodeproj/project.pbxproj @@ -36,6 +36,11 @@ 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 */; }; 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 +54,7 @@ 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 */; }; + C10000000000000000000001 /* PerformanceStabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* PerformanceStabilityTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -59,6 +65,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 */ @@ -108,6 +121,11 @@ A20000000000000000000030 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.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 = ""; }; 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 +134,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 +153,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C30000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -141,6 +168,7 @@ children = ( A40000000000000000000002 /* IOSBleFeasibility */, B40000000000000000000001 /* SonyMusicLiveActivityExtension */, + C40000000000000000000001 /* IOSBleFeasibilityTests */, A40000000000000000000003 /* Products */, ); sourceTree = ""; @@ -153,6 +181,11 @@ A20000000000000000000008 /* FullLyricsView.swift */, A20000000000000000000009 /* DebugToolsView.swift */, A20000000000000000000031 /* AlbumArtReceiver.swift */, + A20000000000000000000033 /* ArtworkImageCache.swift */, + A20000000000000000000034 /* LastNowPlayingSnapshotStore.swift */, + A20000000000000000000035 /* MediaLoadingState.swift */, + A20000000000000000000036 /* BLEObservableStateModels.swift */, + A20000000000000000000037 /* BLEBinaryChunkCodec.swift */, A20000000000000000000032 /* SelfHealingEngine.swift */, B20000000000000000000001 /* SonyMusicActivityAttributes.swift */, B20000000000000000000002 /* LiveActivityManager.swift */, @@ -190,6 +223,7 @@ children = ( A20000000000000000000006 /* sonyMusic.app */, B20000000000000000000007 /* SonyMusicLiveActivityExtension.appex */, + C20000000000000000000002 /* IOSBleFeasibilityTests.xctest */, ); name = Products; sourceTree = ""; @@ -206,6 +240,14 @@ path = SonyMusicLiveActivityExtension; sourceTree = ""; }; + C40000000000000000000001 /* IOSBleFeasibilityTests */ = { + isa = PBXGroup; + children = ( + C20000000000000000000001 /* PerformanceStabilityTests.swift */, + ); + path = IOSBleFeasibilityTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -245,6 +287,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 +331,10 @@ }; }; }; + C50000000000000000000001 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = A50000000000000000000001; + }; }; }; buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXProject "IOSBleFeasibility" */; @@ -288,6 +352,7 @@ targets = ( A50000000000000000000001 /* sonyMusic */, B50000000000000000000001 /* SonyMusicLiveActivityExtension */, + C50000000000000000000001 /* IOSBleFeasibilityTests */, ); }; /* End PBXProject section */ @@ -308,6 +373,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C30000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -334,6 +406,11 @@ 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 */, A10000000000000000000029 /* SelfHealingEngine.swift in Sources */, A10000000000000000000019 /* LyricDisplayMode.swift in Sources */, A10000000000000000000021 /* LyricDiagnostic.swift in Sources */, @@ -365,6 +442,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 +458,11 @@ target = B50000000000000000000001 /* SonyMusicLiveActivityExtension */; targetProxy = B80000000000000000000001 /* PBXContainerItemProxy */; }; + C80000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* sonyMusic */; + targetProxy = C80000000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -571,6 +661,42 @@ }; 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_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_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 +727,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..6f9002f 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift @@ -160,6 +160,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 @@ -205,6 +206,7 @@ final class AlbumArtReceiver { private var predictiveOfferTimes: [String: Date] = [:] private var predictiveHqRequestTimes: [String: Date] = [:] private var sourceRefreshAttemptedAlbumArtIDs: Set = [] + private var albumArtCacheLookupToken: UUID? private let albumArtCacheWriteQueue = DispatchQueue( label: "com.sqz.IOSBleFeasibility.albumArtCacheWrite", qos: .utility @@ -256,34 +258,38 @@ 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 - if let cached = cachedEnhancedAlbumArt(id: id), + if let image = hqValidation.image, hqValidation.valid, !hqNeedsRefresh { cancelAlbumArtFallback() cancelHqAlbumArtRequest() recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) - setAlbumArtDisplay(image: cached.image, id: id, quality: .enhanced, reason: "offer cacheHit") - delegate?.albumArtPublishLiveArtwork(image: cached.image, key: id, reason: "enhancedCacheHit") - let message = "[AlbumArtCache] display quality=enhanced id=\(id), skip hq request" - log(message) - consoleLog(message) - log("[AlbumArtRefresh] skip reason=fresh_cache id=\(id) source=\(hqValidation.source)") - delegate?.albumArtSendCommand(cmd: "ALBUM_ART_SKIP", extra: ["id": id, "quality": "hq"]) - } else if let image = hqValidation.image, - hqValidation.valid, - !hqNeedsRefresh { - cancelAlbumArtFallback() - cancelHqAlbumArtRequest() - recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) setAlbumArtDisplay(image: image, id: id, quality: .hq, reason: "offer cacheHit") delegate?.albumArtPublishLiveArtwork(image: image, key: id, reason: "cacheHit") - startArtworkEnhancementFromCachedHq(id: id, reason: "hq cacheHit") + loadEnhancedAlbumArtIfAvailable(id: id, reason: "offer cacheHit") let message = "[AlbumArtCache] display quality=hq id=\(id), enhance from cache" log(message) consoleLog(message) @@ -475,16 +481,15 @@ final class AlbumArtReceiver { } func handleBinaryChunk(_ data: Data) { - guard data.count > 6 else { + guard let chunk = BLEBinaryChunkCodec.decode(data, expectedMagic: 0xA1) else { let message = "[AlbumArtBinary] invalid chunk" log(message) consoleLog(message) return } - let bytes = [UInt8](data.prefix(6)) - let qualityCode = bytes[1] - let index = (Int(bytes[2]) << 8) | Int(bytes[3]) - let totalChunks = (Int(bytes[4]) << 8) | Int(bytes[5]) + let qualityCode = chunk.kindCode + let index = chunk.index + let totalChunks = chunk.total let quality: String switch qualityCode { case 2: @@ -504,7 +509,7 @@ final class AlbumArtReceiver { consoleLog(message) return } - let payload = data.subdata(in: 6.. 0, reason != "reset" { let message = "[AlbumArt-iOS] cancel in-flight reason=\(reason)" @@ -1482,7 +1545,8 @@ 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) @@ -1618,27 +1682,185 @@ 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) + } } - if let image = hqValidation.image, - hqValidation.shouldDisplayWhileRefreshing { - return (image, "hq") + } + + 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 data = try? Data(contentsOf: imageURL, options: [.mappedIfSafe]) + let size = data?.count ?? 0 + let image = ArtworkImageCache.shared.memoryImage( + artworkId: id, + quality: resolvedQuality, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) ?? data.flatMap { + ArtworkImageCache.downsampledImage( + data: $0, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) + } + if let image { + ArtworkImageCache.shared.store( + image, + artworkId: id, + quality: resolvedQuality, + maximumPixelSize: ArtworkImageCache.mainArtworkMaximumPixelSize + ) } - let previewValidation = validateAlbumArtCache(id: id, preferredQuality: "preview") - if let image = previewValidation.image, - previewValidation.shouldDisplayWhileRefreshing { - return (image, "preview") + let metadata = readAlbumArtMetadata(from: metadataURL) + let pixelWidth = metadata?.pixelWidth ?? image?.pixelWidth ?? 0 + let pixelHeight = metadata?.pixelHeight ?? image?.pixelHeight ?? 0 + let source = normalizedAlbumArtSource(metadata?.source) + let ttlMs = albumArtCacheTtlMs(source: source) + let savedAt = metadata?.effectiveSavedAt ?? fileModifiedAt(imageURL) ?? 0 + let ageMs: Int64 = savedAt > 0 + ? max(0, Int64((Date().timeIntervalSince1970 - savedAt) * 1_000)) + : Int64.max + let expired = ageMs > ttlMs + let hardExpired = ageMs > ALBUM_ART_HARD_CACHE_TTL_MS + let storedQuality = preferredQuality == nil + ? (try? String( + contentsOf: albumArtQualityURL(id: id, quality: nil), + encoding: .utf8 + ))?.trimmingCharacters(in: .whitespacesAndNewlines) + : preferredQuality + let quality = preferredQuality ?? (storedQuality == "preview" ? "preview" : "hq") + let placeholder = image.map { + isLikelyPlaceholderAlbumArt($0, dataSize: size) + } ?? false + + let reason: String + let valid: Bool + if !exists { + reason = "missing" + valid = false + } else if image == nil || size <= 0 { + reason = "decode_failed" + valid = false + } else if placeholder { + reason = "placeholder" + valid = false + } else if hardExpired { + reason = "hard_ttl_expired" + valid = false + } else if expired { + reason = "source_ttl_expired" + valid = true + } else if source == "notificationLargeIcon" || source == "unknown" { + reason = "source_requires_refresh" + valid = true + } else if pixelWidth <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT || + pixelHeight <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT { + reason = "small_cache_requires_refresh" + valid = true + } else { + reason = "ok" + valid = true } - let legacyValidation = validateAlbumArtCache(id: id, preferredQuality: nil) - if let image = legacyValidation.image, - legacyValidation.shouldDisplayWhileRefreshing { - return (image, legacyValidation.quality) + + return AlbumArtCacheValidation( + image: image, + quality: quality, + source: source, + ageMs: ageMs, + ttlMs: ttlMs, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + bytes: size, + valid: valid, + expired: expired, + reason: reason + ) + } + + private func logAlbumArtValidation( + id: String, + validation: AlbumArtCacheValidation + ) { + let message = + "[AlbumArtCache] validate id=\(id) valid=\(validation.valid) " + + "quality=\(validation.quality) source=\(validation.source) " + + "ageMs=\(validation.ageMs == Int64.max ? -1 : validation.ageMs) " + + "ttlMs=\(validation.ttlMs) reason=\(validation.reason) " + + "size=\(validation.bytes) " + + "pixelSize=\(validation.pixelWidth)x\(validation.pixelHeight)" + log(message) + consoleLog(message) + } + + private func loadEnhancedAlbumArtIfAvailable(id: String, reason: String) { + guard artworkEnhancementEnabled else { return } + let target = artworkEnhancementTargetPixelSize + let sharpness = artworkEnhancementSharpness + ArtworkImageCache.shared.performIO { [weak self] in + guard let self else { return } + let result = ArtworkEnhancementManager.shared.cachedEnhancedArtwork( + artworkId: id, + targetPixelSize: target, + sharpness: sharpness + ) + DispatchQueue.main.async { [weak self] in + guard let self, self.currentAlbumArtID == id else { return } + if let result, result.shouldAutoDisplay { + self.setAlbumArtDisplay( + image: result.image, + id: id, + quality: .enhanced, + reason: "\(reason) enhanced" + ) + self.delegate?.albumArtPublishLiveArtwork( + image: result.image, + key: id, + reason: "enhancedCacheHit" + ) + } else { + self.startArtworkEnhancementFromCachedHq( + id: id, + reason: reason + ) + } + } } - return nil } private func cachedEnhancedAlbumArt(id: String, allowLowGain: Bool = false) -> ArtworkEnhancementResult? { @@ -1723,13 +1945,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( @@ -1987,19 +2233,7 @@ final class AlbumArtReceiver { let firstRefreshAttempt = sourceRefreshAttemptedAlbumArtIDs.insert(id).inserted 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" - ) - } else if let image = selected.image, selected.valid { + if let image = selected.image, selected.valid { let displayQuality = selected.quality == "preview" ? ArtworkDisplayQuality.preview : ArtworkDisplayQuality.hq @@ -2014,6 +2248,12 @@ final class AlbumArtReceiver { key: id, reason: "staleWhileRevalidate" ) + if displayQuality == .hq { + loadEnhancedAlbumArtIfAvailable( + id: id, + reason: "stale-while-revalidate" + ) + } } updateArtworkEnhancementStatus(message: "refreshing cache") let staleReason = selected.expired ? "source_ttl_expired" : reason @@ -2049,6 +2289,7 @@ final class AlbumArtReceiver { } 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) @@ -2413,14 +2654,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/ArtworkEnhancementManager.swift b/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift index 32117a4..093858b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift @@ -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..54489c1 --- /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 { + 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..f68e400 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift @@ -0,0 +1,86 @@ +import Combine +import UIKit + +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 +} + +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 PlaybackClockPolicy { + static func shouldRun( + isPlaying: Bool, + durationMs: Int64, + appLifecycleState: String + ) -> Bool { + isPlaying && durationMs > 0 && appLifecycleState == "active" + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index eb9bb89..8ee2088 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 @@ -28,6 +31,9 @@ private let CONNECTION_SUBSCRIBE_NOTIFY_TIMEOUT_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_CONNECTED_MIN_HOLD_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_DISCONNECTED_CONFIRM_MS: Int64 = 1_000 private let COMMAND_WRITE_CALLBACK_TIMEOUT_MS: Int64 = 2_500 +private let COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS: Int64 = 3_000 +private let COMMAND_WRITE_LATE_CALLBACK_FENCE_MS: Int64 = 1_000 +private let COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT = 2 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 @@ -138,6 +144,8 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var connectedDeviceName = "-" @Published private(set) var artworkDisplayQuality: ArtworkDisplayQuality = .placeholder @Published private(set) var artworkEnhancementStatus = ArtworkEnhancementDebugStatus() + @Published private(set) var isShowingLastNowPlayingSnapshot = false + @Published private(set) var mediaLoadingState = MediaLoadingState() @Published private(set) var artworkEnhancementTargetPixelSize = 780 @Published private(set) var artworkEnhancementSharpness = 0.30 @Published private(set) var remoteLogText = "" @@ -193,8 +201,23 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var currentWordAverageUpdateIntervalMs: Int64 = 0 @Published private(set) var currentWordLastLatencyMs: Int64 = 0 + let connectionStateModel = ObservableStateSlice(BLEConnectionViewState()) + let playbackStateModel = ObservableStateSlice(BLEPlaybackViewState()) + let lyricsStateModel = ObservableStateSlice(BLELyricsViewState()) + let artworkStateModel = ObservableStateSlice(BLEArtworkViewState()) + let diagnosticsStateModel = ObservableStateSlice(BLEDiagnosticsViewState()) + private let preferences = PreferencesStore.shared - private lazy var centralManager = CBCentralManager(delegate: self, queue: nil) + private var stateModelCancellable: AnyCancellable? + private var stateModelSyncScheduled = false + private lazy var centralManager = CBCentralManager( + delegate: self, + queue: nil, + options: [ + CBCentralManagerOptionRestoreIdentifierKey: CENTRAL_RESTORE_IDENTIFIER, + CBCentralManagerOptionShowPowerAlertKey: true + ] + ) private lazy var peripheralManager = CBPeripheralManager(delegate: self, queue: nil) private var sonyPeripheral: CBPeripheral? @@ -204,6 +227,9 @@ final class BLETestManager: NSObject, ObservableObject { private var commandWriteInflight: [CommandWriteInfo] = [] private var pendingCommandWrites: [PendingCommandWrite] = [] private var commandWriteTimeoutWorkItem: DispatchWorkItem? + private var commandWriteRecoveryWorkItem: DispatchWorkItem? + private var commandWriteRecoveryUntilMs: Int64 = 0 + private var consecutiveCommandWriteTimeouts = 0 private var volumeWriteInFlightSeq: UInt64? private var lastVolumeSendAtMs: Int64 = 0 private var lastVolumeRequestedValue: Int? @@ -226,12 +252,14 @@ final class BLETestManager: NSObject, ObservableObject { private var currentLiveArtworkKey: String? private var currentLiveArtworkRevision = 0 private var lastLiveActivityRequestAt = Date.distantPast + private var pendingLiveActivityUpdateWorkItem: DispatchWorkItem? private var lastLiveActivityRequestTrackID = "" private var lastLiveActivityLyricTrackID = "" private var lastLiveActivityLyricLineIndex = Int.min private var lastLiveActivityLyricText = "" private var lastLiveActivityCurrentWordSkipLogAtMs: Int64 = 0 private var requestedFullLyricsTrackIDs: Set = [] + private var lastSnapshotQueuedAtMs: Int64 = 0 private var completedFullLyricsTrackIDs: Set = [] private var fullLyricsUnavailableTrackIDs: Set = [] private var fullLyricsDelayedRetryTrackIDs: Set = [] @@ -315,6 +343,7 @@ final class BLETestManager: NSObject, ObservableObject { private var currentScanIsAutoReconnect = false private var currentConnectIsAutoReconnect = false private var currentConnectIsRetrievedPeripheral = false + private var coreBluetoothRestoreInProgress = false private var connectionAttemptId = UUID() private var lastStatusNotifyAt: Date? private var lastPlaybackStateAt: Date? @@ -388,6 +417,7 @@ final class BLETestManager: NSObject, ObservableObject { override init() { super.init() + installObservableStateModels() preferences.load() syncPreferencesStateFromStore() logAppExperienceModeLoaded() @@ -398,13 +428,88 @@ final class BLETestManager: NSObject, ObservableObject { refreshLiveActivityControlStatus() updateAppLifecycleState(UIApplication.shared.applicationState, emitLog: false) registerAppLifecycleDiagnostics() - startProgressTimer() + _ = centralManager startMainHeartbeatDiagnostics() loadCachedPlaybackHistory() + restoreLastNowPlayingSnapshot() syncAlbumArtReceiverState(albumArtReceiver) runDebugSmokeTestIfNeeded() } + private func installObservableStateModels() { + stateModelCancellable = objectWillChange.sink { [weak self] _ in + guard let self, !self.stateModelSyncScheduled else { return } + self.stateModelSyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.stateModelSyncScheduled = false + self.syncObservableStateModels() + } + } + syncObservableStateModels() + } + + private func syncObservableStateModels() { + connectionStateModel.update( + BLEConnectionViewState( + status: connectionStatus, + displayState: connectionDisplayState, + healthState: connectionHealthState, + deviceName: connectedDeviceName, + characteristicReady: connectionHealthCharacteristicReady, + autoReconnectState: autoReconnectState + ) + ) + playbackStateModel.update( + BLEPlaybackViewState( + title: title, + artist: artist, + album: album, + isPlaying: isPlaying, + positionMs: positionMs, + displayPositionMs: displayPositionMs, + durationMs: durationMs, + seekPositionMs: seekPositionMs, + isSeeking: isSeeking, + volumeCurrent: volumeCurrent, + volumeMax: volumeMax, + volumeSeekValue: volumeSeekValue, + isVolumeSeeking: isVolumeSeeking + ) + ) + lyricsStateModel.update( + BLELyricsViewState( + lyric: lyric, + fullLyrics: fullLyrics, + fullLyricsTrackId: fullLyricsTrackId, + isCurrent: isFullLyricsCurrent, + isReceiving: isFullLyricsReceiving, + currentWordLineIndex: currentWordLineIndex, + currentWordIndex: currentWordIndex, + loadingStage: mediaLoadingState.lyric + ) + ) + artworkStateModel.update( + BLEArtworkViewState( + image: albumArtImage, + displayQuality: artworkDisplayQuality, + enhancementStatus: artworkEnhancementStatus, + isRestoredSnapshot: isShowingLastNowPlayingSnapshot, + loadingStage: mediaLoadingState.artwork + ) + ) + diagnosticsStateModel.update( + BLEDiagnosticsViewState( + logCount: logs.count, + connectionAttemptId: connectionHealthAttemptId, + lastHardReconnectReason: connectionHealthLastHardReconnectReason, + remoteLogInProgress: isRemoteLogTransferInProgress, + mediaDumpInProgress: isMediaFieldDumpReceiving, + lyricDiagnosticLoading: lyricDiagnosticLoading + ) + ) + } + func toggleAppExperienceMode() { setAppExperienceMode(appExperienceMode.toggled) } @@ -428,11 +533,50 @@ final class BLETestManager: NSObject, ObservableObject { } 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 snapshot = receiver.snapshot() + let transfer = snapshot.transfer + let artworkStage: ArtworkLoadingStage + if transfer.state == "failed" || transfer.state == "timeout" { + artworkStage = .failed(reason: transfer.lastFailureReason) + } else if transfer.state == "receiving" || transfer.state == "decoding" { + if transfer.quality.lowercased() == "hq" { + artworkStage = .hq( + received: transfer.receivedChunks, + expected: transfer.totalChunks + ) + } else { + artworkStage = .preview( + received: transfer.receivedChunks, + expected: transfer.totalChunks + ) + } + } else if receiver.artworkDisplayQuality >= .hq { + artworkStage = .hqReady + } else if receiver.albumArtImage != nil { + artworkStage = .previewReady + } else if !receiver.currentAlbumArtID.isEmpty { + artworkStage = .preview(received: 0, expected: 0) + } else { + artworkStage = .idle + } + if mediaLoadingState.artwork != artworkStage { + mediaLoadingState.artwork = artworkStage + } } private func logAppExperienceModeLoaded() { @@ -449,7 +593,8 @@ 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-test-preferences") { setAppExperienceMode(.debug) @@ -495,6 +640,31 @@ final class BLETestManager: NSObject, ObservableObject { } #if DEBUG + private func consumeDebugSmokeLaunchArguments() -> [String] { + guard let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + return [] + } + let markerURL = documentsURL.appendingPathComponent( + DEBUG_SMOKE_LAUNCH_ARGUMENTS_FILE, + isDirectory: false + ) + guard let text = try? String(contentsOf: markerURL, encoding: .utf8) else { + return [] + } + try? FileManager.default.removeItem(at: markerURL) + let arguments = text + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { $0.hasPrefix("--smoke-") } + if !arguments.isEmpty { + log("[SmokeTest] restored launch arguments=\(arguments.joined(separator: ","))") + } + return arguments + } + private func runFullLyricsProtocolSelfTest() { let raw = Data( "{\"trackId\":\"fixed\",\"generation\":7,\"lines\":[{\"index\":0,\"timeMs\":0,\"durationMs\":1000,\"text\":\"hello\"}]}" @@ -1140,6 +1310,50 @@ final class BLETestManager: NSObject, ObservableObject { } } + func retryCurrentLyricsFromMain() { + let trackID = currentTrackID + guard !trackID.isEmpty else { return } + fullLyricsRequestStartTimeouts.removeValue(forKey: trackID)?.cancel() + fullLyricsRequestStartRetryCounts.removeValue(forKey: trackID) + requestedFullLyricsTrackIDs.remove(trackID) + completedFullLyricsTrackIDs.remove(trackID) + fullLyricsUnavailableTrackIDs.remove(trackID) + fullLyricsDelayedRetryTrackIDs.remove(trackID) + requestedLyricWindowTrackIDs.remove(trackID) + lyricDiagnostic = nil + lyricDiagnosticLoading = true + mediaLoadingState.lyric = .waitingQqQrc + log("[LyricRetry] main UI retry current trackId=\(trackID)") + sendCommand( + cmd: "GET_FULL_LYRICS", + extra: [ + "trackId": trackID, + "positionMs": displayPositionMs, + "includeWordsAroundCurrent": true, + "forceRefresh": true, + "format": serverSupportsFullLyricsZlib ? "zlib-json-v1" : "legacy" + ] + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { [weak self] in + guard let self, self.currentTrackID == trackID else { return } + if self.serverSupportsLyricWindow { + self.requestedLyricWindowTrackIDs.remove(trackID) + self.requestLyricWindow(trackID: trackID) + } + self.requestFullLyricsIfNeeded(force: true) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in + guard let self, self.currentTrackID == trackID else { return } + self.requestLyricDiagnostic(manual: true) + } + } + + func retryCurrentAlbumArtFromMain() { + mediaLoadingState.artwork = .preview(received: 0, expected: 0) + let requested = forceRefreshCurrentAlbumArt() + log("[AlbumArtRefresh] main UI retry requested=\(requested)") + } + @discardableResult func requestCurrentHqAlbumArt() -> Bool { albumArtReceiver.requestCurrentHqAlbumArt() @@ -1554,7 +1768,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)") @@ -1595,6 +1809,11 @@ final class BLETestManager: NSObject, ObservableObject { } private func flushCommandWriteQueue() { + let nowMs = currentTimeMs() + guard nowMs >= commandWriteRecoveryUntilMs else { + scheduleCommandWriteRecoveryFlush() + return + } guard commandWriteInflight.isEmpty, !pendingCommandWrites.isEmpty, let sonyPeripheral, @@ -1648,15 +1867,13 @@ final class BLETestManager: NSObject, ObservableObject { self.commandWriteInflight.first?.seq == seq else { return } + self.commandWriteTimeoutWorkItem = nil self.ctrlLog( "[CTRL-iOS] write timeout seq=\(seq) cmd=\(cmd) " + "timeoutMs=\(COMMAND_WRITE_CALLBACK_TIMEOUT_MS) " + "pending=\(self.pendingCommandWrites.count)" ) - self.performHardReconnect( - reason: "command write callback timeout cmd=\(cmd) seq=\(seq)", - manual: false - ) + self.handleCommandWriteCallbackTimeout(seq: seq, cmd: cmd) } commandWriteTimeoutWorkItem = item DispatchQueue.main.asyncAfter( @@ -1665,6 +1882,71 @@ 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 + guard recentNotifyAgeMs <= COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS, + sonyPeripheral?.state == .connected, + sonyCommandCharacteristic != nil else { + performHardReconnect( + reason: "command write callback timeout without live notify cmd=\(cmd) seq=\(seq)", + manual: false + ) + return + } + + consecutiveCommandWriteTimeouts += 1 + guard consecutiveCommandWriteTimeouts < COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT else { + performHardReconnect( + reason: "command write callback timeout x\(consecutiveCommandWriteTimeouts) cmd=\(cmd) seq=\(seq)", + manual: false + ) + return + } + + if commandWriteInflight.first?.seq == seq { + commandWriteInflight.removeFirst() + } + if volumeWriteInFlightSeq == seq { + volumeWriteInFlightSeq = nil + } + let pendingBefore = pendingCommandWrites.count + pendingCommandWrites.removeAll(where: { !$0.isControl }) + let droppedBackground = pendingBefore - pendingCommandWrites.count + commandWriteRecoveryUntilMs = currentTimeMs() + COMMAND_WRITE_LATE_CALLBACK_FENCE_MS + setConnectionHealth( + .suspect, + reason: "write callback soft recovery recentNotifyAgeMs=\(recentNotifyAgeMs)" + ) + ctrlLog( + "[CTRL-iOS] write soft recovery seq=\(seq) cmd=\(cmd) " + + "recentNotifyAgeMs=\(recentNotifyAgeMs) " + + "droppedBackground=\(droppedBackground)" + ) + scheduleCommandWriteRecoveryFlush() + } + + private func scheduleCommandWriteRecoveryFlush() { + guard commandWriteRecoveryUntilMs > 0 else { return } + commandWriteRecoveryWorkItem?.cancel() + let delayMs = max(commandWriteRecoveryUntilMs - currentTimeMs(), 0) + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + self.commandWriteRecoveryWorkItem = nil + self.commandWriteRecoveryUntilMs = 0 + self.ctrlLog("[CTRL-iOS] write soft recovery fence ended") + self.flushCommandWriteQueue() + self.flushPendingVolumeIfPossible() + } + commandWriteRecoveryWorkItem = item + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(delayMs) / 1_000.0, + execute: item + ) + } + func seek(to position: Int64) { sendUserCommand(cmd: "SEEK_TO", extra: ["position": max(position, 0)]) } @@ -1871,7 +2153,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 } @@ -2725,6 +3007,11 @@ final class BLETestManager: NSObject, ObservableObject { firstConnectionReadyAtMs = 0 commandWriteTimeoutWorkItem?.cancel() commandWriteTimeoutWorkItem = nil + commandWriteRecoveryWorkItem?.cancel() + commandWriteRecoveryWorkItem = nil + commandWriteRecoveryUntilMs = 0 + consecutiveCommandWriteTimeouts = 0 + coreBluetoothRestoreInProgress = false commandWriteInflight.removeAll() pendingCommandWrites.removeAll() liveActivityControlInFlightSeq = nil @@ -2842,6 +3129,7 @@ final class BLETestManager: NSObject, ObservableObject { value = "unknown" } appLifecycleState = value + updateProgressTimerState() if emitLog { ctrlLog("[APP-LIFECYCLE] \(value)") } @@ -2855,6 +3143,10 @@ final class BLETestManager: NSObject, ObservableObject { "ready=\(ready) health=\(connectionHealthState)" ) guard centralManager.state == .poweredOn else { return } + if connected, coreBluetoothRestoreInProgress { + log("[BLE-Reconnect] foreground restore skipped reason=CoreBluetooth restoration pending") + return + } if !connected { if AutoReconnectState(rawValue: autoReconnectState) == .connecting, currentConnectIsRetrievedPeripheral, @@ -3195,7 +3487,14 @@ extension BLETestManager: CBCentralManagerDelegate { if central.state == .poweredOn, shouldScanWhenPoweredOn { beginSonyScan(reason: "poweredOn pending scan", isAutoReconnect: false, force: false) } else if central.state == .poweredOn, autoReconnectEnabled, sonyPeripheral?.state != .connected { - scheduleReconnect(reason: "central poweredOn", immediate: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, + self.centralManager.state == .poweredOn, + self.sonyPeripheral?.state != .connected else { + return + } + self.scheduleReconnect(reason: "central poweredOn", immediate: true) + } } else if central.state != .poweredOn { stopHealthMonitoring(reason: "central not powered") setConnectionHealth(.disconnected, reason: "central state=\(central.state.rawValue)") @@ -3204,6 +3503,96 @@ extension BLETestManager: CBCentralManagerDelegate { } } + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + let restoredPeripherals = + dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + let lastIdentifier = UserDefaults.standard.string( + forKey: AUTO_RECONNECT_LAST_PERIPHERAL_KEY + ) + let candidate = restoredPeripherals.first { + $0.identifier.uuidString == lastIdentifier + } ?? restoredPeripherals.first { + $0.state == .connected + } + + guard let peripheral = candidate else { + log("[BLE-Restore] no restorable Sony peripheral") + return + } + + coreBluetoothRestoreInProgress = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + scanTimeoutWorkItem?.cancel() + scanTimeoutWorkItem = nil + connectTimeoutWorkItem?.cancel() + connectTimeoutWorkItem = nil + central.stopScan() + isConnectingToSony = false + connectionAttemptId = UUID() + connectionHealthAttemptId = connectionAttemptId.uuidString + sonyPeripheral = peripheral + peripheral.delegate = self + connectedDeviceName = peripheral.name ?? "Sony" + saveLastSonyPeripheral(peripheral) + log( + "[BLE-Restore] restored id=\(peripheral.identifier.uuidString) " + + "state=\(peripheralStateText(peripheral))" + ) + + guard peripheral.state == .connected else { + coreBluetoothRestoreInProgress = false + connectSonyPeripheral( + peripheral, + reason: "CoreBluetooth restored peripheral", + isRetrieved: true + ) + return + } + + setStatus("正在恢复服务") + setConnectionHealth(.suspect, reason: "CoreBluetooth restoration pending") + setAutoReconnectState(.serviceDiscovering) + 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, @@ -3442,12 +3831,23 @@ extension BLETestManager: CBPeripheralDelegate { } } else { let errorText = error?.localizedDescription ?? "nil" + if error == nil, didWriteMs <= commandWriteRecoveryUntilMs { + consecutiveCommandWriteTimeouts = 0 + ctrlLog( + "[CTRL-iOS] ignored late didWrite during recovery fence " + + "timeMs=\(didWriteMs)" + ) + return + } ctrlLog( "[CTRL-iOS] didWrite seq=unknown cmd=unknown " + "timeMs=\(didWriteMs) costMs=unknown error=\(errorText)" ) } + if error == nil, completed != nil { + consecutiveCommandWriteTimeouts = 0 + } if let error { if completed?.cmd == "SET_VOLUME" { log("[iOS][BLE] write SET_VOLUME failed: \(error.localizedDescription)") @@ -3480,6 +3880,7 @@ extension BLETestManager: CBPeripheralDelegate { return } guard characteristic.uuid == BLEUUIDs.status else { return } + coreBluetoothRestoreInProgress = false if let error { setStatus("未连接") @@ -3646,6 +4047,7 @@ extension BLETestManager: CBPeripheralDelegate { let reconnectSyncWindow = self.isInReconnectStateSyncWindow() self.isPlaying = object["playing"] as? Bool ?? false self.durationMs = Self.int64Value(object["duration"]) + self.updateProgressTimerState() self.updateLightweightLyricDiagnostic(from: object) if let lyric = object["lyric"] as? String { self.lyric = lyric @@ -3726,6 +4128,7 @@ extension BLETestManager: CBPeripheralDelegate { ) { self.updateLiveActivity(force: false, reason: "playbackState") } + self.scheduleLastNowPlayingSnapshotSave(reason: "playbackState") case "currentWord": self.handleCurrentWord(object) @@ -4232,6 +4635,8 @@ extension BLETestManager: CBPeripheralDelegate { 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 +4644,7 @@ extension BLETestManager: CBPeripheralDelegate { album = newAlbum if !trackID.isEmpty { currentTrackID = trackID + isShowingLastNowPlayingSnapshot = false let nowMs = currentTimeMs() lyricTraceTrackInfoAtMs[trackID] = nowMs log( @@ -4256,6 +4662,7 @@ extension BLETestManager: CBPeripheralDelegate { force: trackChanged, reason: trackChanged ? "trackInfo" : "trackInfoRefresh" ) + scheduleLastNowPlayingSnapshotSave(reason: "trackInfo", force: true) } private func clearNowPlayingForNoActiveTrack() { @@ -4302,6 +4709,7 @@ extension BLETestManager: CBPeripheralDelegate { lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = nil isPlaying = false + updateProgressTimerState() positionMs = 0 displayPositionMs = 0 seekPositionMs = 0 @@ -4309,12 +4717,35 @@ extension BLETestManager: CBPeripheralDelegate { basePlaybackPositionMs = 0 currentWordLineIndex = -1 currentWordIndex = -1 + 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 +4785,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 +4855,16 @@ extension BLETestManager: CBPeripheralDelegate { return true } - if secondsSinceLastRequest >= 15 { + let progressInterval: TimeInterval + switch preferences.playbackPerformanceMode { + case .smooth: + progressInterval = 10 + case .automatic: + progressInterval = 15 + case .powerSaving: + progressInterval = 30 + } + if secondsSinceLastRequest >= progressInterval { log("[LiveActivityPerf] progress request reason=interval") return true } @@ -4684,6 +5135,7 @@ extension BLETestManager: CBPeripheralDelegate { trackID: transfer.trackId, isFinal: false ) + mediaLoadingState.lyric = .windowReady(lineCount: transfer.chunks.count) log("[LyricWindow-iOS] published lines=\(transfer.chunks.count)") } @@ -4731,6 +5183,10 @@ extension BLETestManager: CBPeripheralDelegate { expectedCRC32: crc ) isFullLyricsReceiving = true + mediaLoadingState.lyric = .fullLyrics( + received: 0, + expected: fullLyricsExpectedCount + ) scheduleFullLyricsBinaryTimeout(trackID: trackID, transferID: transferID) log( "[FullLyricsV2-iOS] start transferId=\(transferID) " + @@ -4739,28 +5195,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 + ) } } @@ -4773,7 +5230,10 @@ extension BLETestManager: CBPeripheralDelegate { return } fullLyricsTimeoutWorkItem?.cancel() - let missing = (0.. 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 +5402,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 +5470,10 @@ extension BLETestManager: CBPeripheralDelegate { "received=\(fullLyricsChunks.count)/\(fullLyricsExpectedCount) " + "words=\(words.count)" ) + mediaLoadingState.lyric = .fullLyrics( + received: fullLyricsChunks.count, + expected: fullLyricsExpectedCount + ) } publishPartialFullLyricsIfNeeded(trackID: trackID) } @@ -5075,6 +5535,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 +5559,16 @@ extension BLETestManager: CBPeripheralDelegate { lyricDiagnostic = parseLyricDiagnostic(object) lyricDiagnosticLoading = false lyricDiagnosticLastUpdatedAt = Date() + if let diagnostic = lyricDiagnostic { + if diagnostic.status == "waiting_qqmusic_cache" || + diagnostic.status == "loading" || + diagnostic.status == "retry_pending" { + mediaLoadingState.lyric = .waitingQqQrc + } else if diagnostic.status != "loaded", + fullLyrics.isEmpty { + mediaLoadingState.lyric = .failed(reason: diagnostic.reason) + } + } log( "[LyricsDiag-iOS] received status=\(lyricDiagnostic?.status ?? "") " + "reason=\(lyricDiagnostic?.reason ?? "")" @@ -5175,6 +5650,9 @@ extension BLETestManager: CBPeripheralDelegate { ) lyricDiagnosticLastUpdatedAt = Date() requestLyricDiagnosticIfNeeded() + mediaLoadingState.lyric = .failed( + reason: reason.isEmpty ? status : reason + ) log("[FullLyrics] unavailable reason=\(reason)") } @@ -5408,6 +5886,7 @@ extension BLETestManager: CBPeripheralDelegate { let romaCount = sortedLines.filter { sanitizedSecondaryText($0.romanization) != nil }.count if isFinal { completedFullLyricsTrackIDs.insert(trackID) + mediaLoadingState.lyric = .ready(lineCount: sortedLines.count) log( "[FullLyrics] end count=\(fullLyrics.count) " + "transCount=\(transCount) romaCount=\(romaCount)" @@ -5417,12 +5896,146 @@ 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 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 func handleCurrentWord(_ object: [String: Any]) { @@ -5523,8 +6136,24 @@ extension BLETestManager: CBPeripheralDelegate { return false } - private func startProgressTimer() { - progressTimer?.invalidate() + 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: 0.2, repeats: true) { [weak self] _ in self?.updateInterpolatedProgress() } @@ -5535,11 +6164,16 @@ extension BLETestManager: CBPeripheralDelegate { 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 } @@ -5850,6 +6484,7 @@ extension BLETestManager: AlbumArtReceiverDelegate { func albumArtPublishLiveArtwork(image: UIImage, key: String, reason: String) { publishLiveArtworkIfCurrent(image: image, key: key, reason: reason) + scheduleLastNowPlayingSnapshotSave(reason: "artworkReady", force: true) } func albumArtClearLiveArtwork(reason: String, shouldUpdate: Bool) { diff --git a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift index 60905eb..952e627 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift @@ -1,7 +1,12 @@ import SwiftUI struct ContentView: View { - @StateObject private var bleManager = BLETestManager() + @State private var bleManager: BLETestManager + @ObservedObject private var connectionStateModel: ObservableStateSlice + @ObservedObject private var playbackStateModel: ObservableStateSlice + @ObservedObject private var lyricsStateModel: ObservableStateSlice + @ObservedObject private var artworkStateModel: ObservableStateSlice + @ObservedObject private var diagnosticsStateModel: ObservableStateSlice @ObservedObject private var preferences = PreferencesStore.shared @State private var showFullLyrics = false @State private var showDebugPage = false @@ -11,6 +16,16 @@ struct ContentView: View { @State private var showSystemHealthOverview = false @State private var showPreferences = false + init() { + let manager = BLETestManager() + _bleManager = State(initialValue: manager) + _connectionStateModel = ObservedObject(wrappedValue: manager.connectionStateModel) + _playbackStateModel = ObservedObject(wrappedValue: manager.playbackStateModel) + _lyricsStateModel = ObservedObject(wrappedValue: manager.lyricsStateModel) + _artworkStateModel = ObservedObject(wrappedValue: manager.artworkStateModel) + _diagnosticsStateModel = ObservedObject(wrappedValue: manager.diagnosticsStateModel) + } + var body: some View { NavigationStack { ZStack { @@ -131,6 +146,58 @@ struct ContentView: View { darkVolumeControl } .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + if bleManager.title != "-" { + mediaLoadingOverlay + .padding(.top, 48) + } + } + } + + private var mediaLoadingOverlay: some View { + HStack(spacing: 8) { + Text(bleManager.mediaLoadingState.lyric.title) + .foregroundStyle( + bleManager.mediaLoadingState.lyric.isFailure + ? Color.orange + : Color.white.opacity(0.68) + ) + .lineLimit(1) + + Button { + bleManager.retryCurrentLyricsFromMain() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("重试当前歌词") + + Rectangle() + .fill(.white.opacity(0.16)) + .frame(width: 1, height: 13) + + Text(bleManager.mediaLoadingState.artwork.title) + .foregroundStyle( + bleManager.mediaLoadingState.artwork.isFailure + ? Color.orange + : Color.white.opacity(0.68) + ) + .lineLimit(1) + + Button { + bleManager.retryCurrentAlbumArtFromMain() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("重试当前封面") + } + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .padding(.horizontal, 10) + .frame(height: 25) + .background(.black.opacity(0.42), in: Capsule()) + .overlay { + Capsule().stroke(.white.opacity(0.08), lineWidth: 1) + } + .buttonStyle(.plain) } private var darkSystemHeader: some View { @@ -267,6 +334,15 @@ struct ContentView: View { DarkPlaybackStatusBadge(state: uiState.playback) .padding(.top, 8) + .overlay(alignment: .bottomLeading) { + if bleManager.isShowingLastNowPlayingSnapshot { + Text("上次播放 · 等待同步") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.orange.opacity(0.88)) + .fixedSize() + .offset(y: 22) + } + } } .frame(maxWidth: .infinity, alignment: .leading) } @@ -1497,6 +1573,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,24 +1590,57 @@ 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) } + 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 1.0 / 20.0 + case .loading, .reconnecting: + return 1.0 / 10.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 { Canvas { context, size in guard !levels.isEmpty, size.width > 0, size.height > 0 else { return } 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/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/PlaybackHistoryView.swift b/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift index 4243a91..4f4c978 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift @@ -219,6 +219,7 @@ struct PlaybackStatsView: View { private struct HistoryArtworkView: View { let artworkId: String? + @State private var cachedImage: UIImage? var body: some View { Group { @@ -235,17 +236,24 @@ private struct HistoryArtworkView: View { } } .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) { + if let image = await ArtworkImageCache.shared.load( + artworkId: artworkId, + quality: "history-\(quality)", + fileURL: url, + maximumPixelSize: ArtworkImageCache.historyArtworkMaximumPixelSize + ) { return image } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift index dce70c7..ec50a4f 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift @@ -1,6 +1,47 @@ 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 "自动" + case .smooth: return "流畅" + case .powerSaving: return "省电" + } + } + + var detail: String { + switch self { + case .automatic: + return "根据低电量模式和播放状态自动平衡实时性与耗电。" + case .smooth: + return "优先频谱、封面和灵动岛的响应速度。" + case .powerSaving: + return "降低动画和后台刷新频率,HQ 封面仍会延后加载。" + } + } + + 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 @@ -61,6 +102,10 @@ final class PreferencesStore: ObservableObject { didSet { persistDynamicIslandStyle(oldValue: oldValue) } } + @Published var playbackPerformanceMode: PlaybackPerformanceMode { + didSet { persistPlaybackPerformanceMode(oldValue: oldValue) } + } + private let defaults: UserDefaults private init(defaults: UserDefaults = .standard) { @@ -84,6 +129,7 @@ final class PreferencesStore: ObservableObject { ) artworkDisplaySize = Self.loadArtworkDisplaySize(defaults: defaults) dynamicIslandStyle = Self.loadDynamicIslandStyle(defaults: defaults) + playbackPerformanceMode = Self.loadPlaybackPerformanceMode(defaults: defaults) logLoaded() } @@ -107,6 +153,7 @@ final class PreferencesStore: ObservableObject { ) artworkDisplaySize = Self.loadArtworkDisplaySize(defaults: defaults) dynamicIslandStyle = Self.loadDynamicIslandStyle(defaults: defaults) + playbackPerformanceMode = Self.loadPlaybackPerformanceMode(defaults: defaults) logLoaded() } @@ -118,6 +165,7 @@ final class PreferencesStore: ObservableObject { artworkEnhancementEnabled = true artworkDisplaySize = .defaultOption dynamicIslandStyle = .defaultStyle + playbackPerformanceMode = .defaultMode } private static func loadAppExperienceMode(defaults: UserDefaults) -> AppExperienceMode { @@ -140,6 +188,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 } @@ -172,6 +225,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) @@ -191,7 +256,8 @@ final class PreferencesStore: ObservableObject { "lyricDisplayMode=\(lyricDisplayMode.rawValue) " + "artworkEnhancement=\(artworkEnhancementEnabled) " + "artworkDisplaySize=\(artworkDisplaySize.rawValue) " + - "dynamicIslandStyle=\(dynamicIslandStyle.rawValue)" + "dynamicIslandStyle=\(dynamicIslandStyle.rawValue) " + + "performanceMode=\(playbackPerformanceMode.rawValue)" ) } diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift index 1084b8f..18d4a89 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift @@ -67,6 +67,21 @@ struct PreferencesView: View { .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)) + VStack(alignment: .leading, spacing: 8) { Text("灵动岛样式") .font(.subheadline.weight(.semibold)) @@ -226,6 +241,13 @@ struct PreferencesView: View { ) } + private var performanceModeBinding: Binding { + Binding( + get: { preferences.playbackPerformanceMode }, + set: { preferences.playbackPerformanceMode = $0 } + ) + } + private var karaokeOffsetBinding: Binding { Binding( get: { Double(preferences.lyricOffsetMs) }, diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift new file mode 100644 index 0000000..8b876d0 --- /dev/null +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -0,0 +1,187 @@ +import Combine +import UIKit +import XCTest +@testable import sonyMusic + +final class PerformanceStabilityTests: XCTestCase { + 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 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" + ) + ) + } + + 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.. + { + 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/java/com/example/playeragent/ble/BleGattServerManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt index b0e5dfd..29f5d15 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -30,6 +30,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.MediaExecutionContext import com.example.playeragent.media.PlaybackStateReader import com.example.playeragent.media.PlaybackStateDiffType import com.example.playeragent.media.ReactiveMediaController @@ -84,32 +85,15 @@ class BleGattServerManager( sendLine = { message -> sendStatusMessage(message) } ) private val mediaFieldDumpManager = MediaFieldDumpManager(appContext) - private val mediaFieldDumpExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "MediaFieldDumpThread") - } + private val mediaExecutionContext = MediaExecutionContext("BleMedia") + private val mediaFieldDumpExecutor = mediaExecutionContext.maintenance private val mediaFieldDumpPreparing = AtomicBoolean(false) - private val historyExecutor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "PlaybackHistoryBleThread") - } + private val historyExecutor = mediaExecutionContext.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 reconnectSyncExecutor = mediaExecutionContext.realtime + private val commandExecutor = mediaExecutionContext.realtime + private val lyricCommandExecutor = mediaExecutionContext.foregroundIO + private val albumArtFastPathExecutor = mediaExecutionContext.foregroundIO private val albumArtHandler = Handler(Looper.getMainLooper()) private val gattLifecycleHandler = Handler(Looper.getMainLooper()) private val qqMusicArtworkListener: (String) -> Unit = { event -> @@ -187,31 +171,14 @@ class BleGattServerManager( private var pendingFullLyricsRequest: PendingFullLyricsRequest? = null @Volatile private var pendingLyricWindowRequest: PendingLyricWindowRequest? = null - @Volatile - private var recentFullLyricsBinaryTransfer: FullLyricsBinaryTransfer? = null - private val recentAlbumArtBinaryTransfers = - ConcurrentHashMap() + private val lyricsTransferCoordinator = LyricsTransferCoordinator() + private val albumArtTransferCoordinator = AlbumArtTransferCoordinator() + private val connectionCommandCoordinator = ConnectionCommandCoordinator() private var lastAutoPushSongKey: String? = null private var lastAutoPushPlaying: Boolean? = null 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 @@ -264,6 +231,7 @@ class BleGattServerManager( connectedDeviceAddresses.add(address) connectedDevices[address] = device mtuByAddress[address] = DEFAULT_MTU + notifyQueue.resetLinkProfile(address, DEFAULT_MTU) logger("[ReconnectSync] central connected device=$address") scheduleGattRediscoveryIfUnsubscribed( device = device, @@ -308,6 +276,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 +284,7 @@ class BleGattServerManager( } override fun onNotificationSent(device: BluetoothDevice?, status: Int) { - notifyQueue.onNotificationSent(status) + notifyQueue.onNotificationSent(device?.address, status) } override fun onCharacteristicWriteRequest( @@ -472,16 +441,17 @@ class BleGattServerManager( subscribedDevices[device.address] = device lastSubscribedAtMs = SystemClock.elapsedRealtime() resetClientCapabilities() - val capabilityGeneration = ++capabilityNegotiationGeneration + val capabilityGeneration = + connectionCommandCoordinator.negotiationGeneration 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( + capabilityGeneration + ) ) { - clientCapabilitiesNegotiated = true logger("[BLE-A] client capability timeout, legacy fallback") sendPendingAlbumArtIfAny() } @@ -500,7 +470,6 @@ class BleGattServerManager( pendingAlbumArt = null albumArtScheduleGeneration += 1 resetClientCapabilities() - capabilityNegotiationGeneration += 1 lastAutoPushSongKey = null lastAutoPushPlaying = null logger("[BLE-A] status notify unsubscribed: device=${device.address}") @@ -660,12 +629,14 @@ class BleGattServerManager( } logger("[BLE-GATT] close reason=$reason") stopAutoPush() - notifyQueue.clearAllForDisconnect(reason) + notifyQueue.shutdown(reason) lastAlbumArtKey = null currentAlbumArtId = null albumArtRequestsInFlight.clear() albumArtRequestsCompleted.clear() pendingAlbumArt = null + lyricsTransferCoordinator.reset() + albumArtTransferCoordinator.reset() albumArtScheduleGeneration += 1 albumArtTaskGeneration += 1 albumArtFastPathTask?.cancel(true) @@ -686,12 +657,7 @@ class BleGattServerManager( recentConnectionCallbacks.clear() recentMtuCallbacks.clear() statusCharacteristic = null - mediaFieldDumpExecutor.shutdownNow() - historyExecutor.shutdownNow() - reconnectSyncExecutor.shutdownNow() - commandExecutor.shutdownNow() - lyricCommandExecutor.shutdownNow() - albumArtFastPathExecutor.shutdownNow() + mediaExecutionContext.close() playbackStateBuffer.shutdown() playbackStateReader.close() @@ -990,19 +956,22 @@ class BleGattServerManager( } 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 + val capabilities = ConnectionCommandCoordinator.Capabilities( + protocolVersion = request.optInt("protocolVersion", 1).coerceAtLeast(1), + binaryAlbumArt = request.optBoolean("albumArtBinary", false), + fullLyricsZlib = request.optBoolean("fullLyricsZlib", false), + lyricWindow = request.optBoolean("lyricWindow", false), + ping = request.optBoolean("ping", false), + transferRetry = request.optBoolean("transferRetry", false), + negotiated = true + ) + connectionCommandCoordinator.accept(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} " + + "transferRetry=${capabilities.transferRetry}" ) subscribedDevices.values.firstOrNull()?.let { device -> sendShortJsonIfFits( @@ -1022,15 +991,9 @@ class BleGattServerManager( } private fun resetClientCapabilities() { - clientProtocolVersion = 1 - clientSupportsBinaryAlbumArt = false - clientSupportsFullLyricsZlib = false - clientSupportsLyricWindow = false - clientSupportsPing = false - clientSupportsTransferRetry = false - clientCapabilitiesNegotiated = false - recentFullLyricsBinaryTransfer = null - recentAlbumArtBinaryTransfers.clear() + connectionCommandCoordinator.invalidate() + lyricsTransferCoordinator.clearRetryState() + albumArtTransferCoordinator.reset() } private fun sendPong(request: JSONObject) { @@ -1269,6 +1232,17 @@ 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, @@ -1906,7 +1880,7 @@ class BleGattServerManager( logger("[AlbumArt] offer skipped reason=empty protocol id") return } - if (!clientCapabilitiesNegotiated && + if (!connectionCommandCoordinator.capabilities.negotiated && SystemClock.elapsedRealtime() - lastSubscribedAtMs < CLIENT_CAPABILITY_WAIT_MS ) { pendingAlbumArt = pending @@ -2348,7 +2322,7 @@ class BleGattServerManager( return } if (quality == AlbumArtQuality.HQ) { - if (!clientSupportsBinaryAlbumArt) { + if (!connectionCommandCoordinator.capabilities.binaryAlbumArt) { logger("[AlbumArtHQ] request skipped reason=binary unsupported") return } @@ -2427,7 +2401,7 @@ class BleGattServerManager( ) val useBinaryAlbumArt = (quality == AlbumArtQuality.PREVIEW || quality == AlbumArtQuality.HQ) && - clientSupportsBinaryAlbumArt + connectionCommandCoordinator.capabilities.binaryAlbumArt val maximumPayload = if (useBinaryAlbumArt) { albumArtMaximumPayloadFor(device) } else { @@ -3913,6 +3887,10 @@ class BleGattServerManager( } private fun sendFullLyrics(request: JSONObject) { + if (request.optBoolean("forceRefresh", false)) { + val started = playbackStateReader.manualRefreshCurrentLyric() + logger("[LyricRetry] BLE force refresh requested started=$started") + } val device = subscribedDevices.values.firstOrNull() ?: run { val requestedTrackId = request.optString("trackId") if (requestedTrackId.isNotBlank()) { @@ -4096,10 +4074,11 @@ class BleGattServerManager( val maximumPayload = maximumPayloadFor(device) val requestedFormat = request.optString("format") + val negotiatedCapabilities = connectionCommandCoordinator.capabilities 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, @@ -4255,53 +4234,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 +4342,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 +4349,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 +4372,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,7 +4409,7 @@ class BleGattServerManager( value = end, delayAfterMs = FULL_LYRICS_JSON_NOTIFICATION_DELAY_MS ) - recentFullLyricsBinaryTransfer = FullLyricsBinaryTransfer( + lyricsTransferCoordinator.retain(FullLyricsBinaryTransfer( trackId = trackId, transferId = transferId, generation = generation, @@ -4393,7 +4417,7 @@ class BleGattServerManager( chunks = chunkPackets, end = endPacket, expiresAtMs = SystemClock.elapsedRealtime() + FULL_LYRICS_RETRY_TTL_MS - ) + )) val packets = buildList { add(startPacket) addAll(chunkPackets) @@ -4402,7 +4426,7 @@ class BleGattServerManager( val sendStartedAtMs = SystemClock.elapsedRealtime() 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( @@ -4453,8 +4477,35 @@ class BleGattServerManager( 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.words.size.toLong()) + line.words.forEach { word -> + mix(word.startMs) + mix(word.durationMs) + mix(word.text.hashCode().toLong()) + } + } + return hash + } + private fun sendLyricWindow(request: JSONObject) { - if (!clientCapabilitiesNegotiated || !clientSupportsLyricWindow) return + val capabilities = connectionCommandCoordinator.capabilities + if (!capabilities.negotiated || !capabilities.lyricWindow) return val device = subscribedDevices.values.firstOrNull() ?: return val source = playbackSourceForRequestedTrack(request.optString("trackId")) val trackId = buildAlbumArtProtocolId(source) @@ -4604,19 +4655,19 @@ class BleGattServerManager( } private fun retryTransfer(request: JSONObject) { - if (!clientCapabilitiesNegotiated || !clientSupportsTransferRetry) return + val capabilities = connectionCommandCoordinator.capabilities + if (!capabilities.negotiated || !capabilities.transferRetry) return val device = subscribedDevices.values.firstOrNull() ?: return val transferId = request.optString("transferId") - recentAlbumArtBinaryTransfers[transferId]?.let { albumTransfer -> + albumArtTransferCoordinator.retained(transferId)?.let { albumTransfer -> retryAlbumArtTransfer(device, request, albumTransfer) return } - val transfer = recentFullLyricsBinaryTransfer + val transfer = lyricsTransferCoordinator.retained(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 @@ -4671,17 +4722,15 @@ 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 + )) } private fun retryAlbumArtTransfer( @@ -5301,11 +5350,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( @@ -5692,26 +5743,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 +5754,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..f3c2811 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt @@ -0,0 +1,132 @@ +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, + 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 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 + + fun reset(newMtu: Int = mtu) { + mtu = newMtu + jsonDelayMs = JSON_INITIAL_DELAY_MS + binaryDelayMs = BINARY_INITIAL_DELAY_MS + ewmaCallbackRttMs = 0.0 + successCount = 0 + failureCount = 0 + jsonSuccessWindow = 0 + binarySuccessWindow = 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.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.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.OTHER -> Unit + } + } + + private fun resetSuccessWindow(kind: PayloadKind) { + when (kind) { + PayloadKind.JSON_LYRIC -> jsonSuccessWindow = 0 + PayloadKind.BINARY_LYRIC -> binarySuccessWindow = 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 JSON_MIN_DELAY_MS = 2L + private const val BINARY_MIN_DELAY_MS = 1L + private const val FAILURE_STEP_MS = 5L + private const val MAX_DELAY_MS = 30L + private const val SUCCESS_WINDOW = 20 + private const val CONGESTED_CALLBACK_RTT_MS = 120L + private const val FAST_CALLBACK_RTT_MS = 60.0 + private const val EWMA_ALPHA = 0.2 + } +} diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt index aa8766b..5fbb31b 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,15 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattServer import android.os.Build import android.os.Handler +import android.os.HandlerThread import android.os.Looper import android.os.SystemClock import com.example.playeragent.logging.LogConfig import org.json.JSONObject import java.util.ArrayDeque import java.util.LinkedHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class BleNotifyQueue( private val serverProvider: () -> BluetoothGattServer?, @@ -24,8 +27,10 @@ class BleNotifyQueue( private val onNotifyFailure: (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 var activeJob: SendJob? = null private var activePacketIndex = 0 private var activeJobStartedAtMs = 0L @@ -42,6 +47,31 @@ class BleNotifyQueue( private var drainingCancelledCallback = false private var cancelledCallbackDrainRunnable: Runnable? = null private var commandResponseQuietUntilMs = 0L + private var activeNotifyStartedAtMs = 0L + private var activeNotifyDeviceAddress: String? = null + private var activeNotifyPacketType: String? = null + + fun resetLinkProfile(address: String, mtu: Int) { + runOnQueueThread { resetLinkProfileOnQueue(address, mtu) } + } + + @Synchronized + private fun resetLinkProfileOnQueue(address: String, mtu: Int) { + if (address.isBlank()) return + linkProfiles[address] = BleLinkProfile(mtu) + localOnlyLogger("[BleLink] reset device=$address mtu=$mtu") + } + + fun updateLinkMtu(address: String, mtu: Int) { + runOnQueueThread { updateLinkMtuOnQueue(address, mtu) } + } + + @Synchronized + private fun updateLinkMtuOnQueue(address: String, mtu: Int) { + if (address.isBlank() || mtu <= 0) return + linkProfiles.getOrPut(address) { BleLinkProfile(mtu) }.updateMtu(mtu) + localOnlyLogger("[BleLink] mtu device=$address mtu=$mtu pacing reset") + } fun enqueueShort( device: BluetoothDevice, @@ -70,8 +100,12 @@ class BleNotifyQueue( * Older Sony Bluetooth stacks can otherwise acknowledge sendResponse() locally while the * iOS client never receives its write callback during a dense album-art transfer. */ - @Synchronized fun onCommandResponseSent() { + runOnQueueThread { onCommandResponseSentOnQueue() } + } + + @Synchronized + private fun onCommandResponseSentOnQueue() { commandResponseQuietUntilMs = maxOf( commandResponseQuietUntilMs, SystemClock.elapsedRealtime() + COMMAND_RESPONSE_QUIET_MS @@ -109,12 +143,23 @@ class BleNotifyQueue( ) } - @Synchronized fun setLatestInterleavedShort( device: BluetoothDevice, type: String, value: ByteArray, delayAfterMs: Long = SHORT_MESSAGE_DELAY_MS + ) { + runOnQueueThread { + setLatestInterleavedShortOnQueue(device, type, value, delayAfterMs) + } + } + + @Synchronized + private fun setLatestInterleavedShortOnQueue( + device: BluetoothDevice, + type: String, + value: ByteArray, + delayAfterMs: Long ) { latestInterleavedPackets[type] = InterleavedPacket( device = device, @@ -140,8 +185,12 @@ class BleNotifyQueue( return activeJob?.type == type || jobs.any { it.type == type } } - @Synchronized fun cancelJobTypes(types: Set, reason: String) { + runOnQueueThread { cancelJobTypesOnQueue(types, reason) } + } + + @Synchronized + private fun cancelJobTypesOnQueue(types: Set, reason: String) { val removedJobs = jobs.filter { it.type in types } jobs.removeAll { it.type in types } removedJobs.forEach { failJob(it, reason) } @@ -165,8 +214,12 @@ class BleNotifyQueue( } } + 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() @@ -179,13 +232,26 @@ class BleNotifyQueue( cancelNotifyTimeout() notificationInFlight = false + val callbackAddress = deviceAddress + ?.takeIf { it.isNotBlank() } + ?: activeNotifyDeviceAddress + ?: job.device.address + val callbackType = activeNotifyPacketType + val callbackRttMs = if (activeNotifyStartedAtMs > 0L) { + SystemClock.elapsedRealtime() - activeNotifyStartedAtMs + } else { + 0L + } + clearActiveNotifyMetrics() if (interleavedPacketInFlight) { - val type = interleavedPacketType ?: job.type + val type = interleavedPacketType ?: callbackType ?: job.type interleavedPacketInFlight = false interleavedPacketType = null if (status == BluetoothGatt.GATT_SUCCESS) { onNotifySuccess(type) + recordAdaptiveSuccess(type, callbackAddress, callbackRttMs) } else { + recordAdaptiveFailure(type, callbackAddress) logger("[BleNotifyQueue] interleaved notify failed type=$type status=$status") onNotifyFailure(type, status, "callback_failed") } @@ -195,8 +261,8 @@ 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" @@ -206,19 +272,28 @@ class BleNotifyQueue( return } else { onNotifySuccess(packet?.type ?: job.type) - packet?.let { recordAdaptiveSuccess(it.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 } + linkProfiles.remove(address) val activeDeviceRemoved = activeJob?.device?.address == address if (interleavedPacketInFlight) { interleavedPacketInFlight = false @@ -239,14 +314,19 @@ class BleNotifyQueue( } } - @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() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -257,18 +337,24 @@ class BleNotifyQueue( commandResponseQuietUntilMs = 0L activeRequestType = null activeRequestId += 1 + clearActiveNotifyMetrics() cancelNotifyTimeout() drainingCancelledCallback = false cancelledCallbackDrainRunnable = null } - @Synchronized fun clear() { + runOnQueueThread { clearOnQueue() } + } + + @Synchronized + private fun clearOnQueue() { handler.removeCallbacksAndMessages(null) activeJob?.let { failJob(it, "queue cleared") } jobs.forEach { failJob(it, "queue cleared") } jobs.clear() latestInterleavedPackets.clear() + linkProfiles.clear() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -278,6 +364,7 @@ class BleNotifyQueue( commandResponseQuietUntilMs = 0L activeRequestType = null activeRequestId += 1 + clearActiveNotifyMetrics() cancelNotifyTimeout() drainingCancelledCallback = false cancelledCallbackDrainRunnable = null @@ -294,8 +381,12 @@ class BleNotifyQueue( ) } - @Synchronized private fun enqueueJob(job: SendJob) { + runOnQueueThread { enqueueJobOnQueue(job) } + } + + @Synchronized + private fun enqueueJobOnQueue(job: SendJob) { jobs.addLast(job) sendNextPacket() } @@ -389,6 +480,10 @@ class BleNotifyQueue( interleavedPacketInFlight = true interleavedPacketType = interleaved.packet.type notificationInFlight = true + markNotifyStarted( + interleaved.device.address, + interleaved.packet.type + ) logInterleavedEventThrottled( isSavedEvent = false, message = "[BleNotifyQueue] latest ${interleaved.packet.type} flushed during long job" @@ -408,6 +503,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null notificationInFlight = false + clearActiveNotifyMetrics() interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS handler.postDelayed( { sendNextPacket() }, @@ -467,6 +563,7 @@ class BleNotifyQueue( notificationInFlight = true activeRequestType = packet.type + markNotifyStarted(job.device.address, packet.type) val requested = notify(server, characteristic, job.device, packet.value) if (job.type == "albumArt") { logAlbumArtPacketProgress(job, packet) @@ -507,7 +604,8 @@ 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( packet.type, @@ -527,6 +625,7 @@ class BleNotifyQueue( activePacketIndex = 0 activeJobStartedAtMs = 0L activeRequestType = null + clearActiveNotifyMetrics() handler.postDelayed({ sendNextPacket() }, SHORT_MESSAGE_DELAY_MS) } @@ -540,6 +639,8 @@ class BleNotifyQueue( } val job = activeJob logger("[BleNotifyQueue] notify callback timeout type=$type job=${job?.type}") + val address = activeNotifyDeviceAddress ?: job?.device?.address.orEmpty() + recordAdaptiveFailure(type, address) onNotifyFailure(type, NOTIFY_CALLBACK_TIMEOUT_STATUS, "callback_timeout") if (job != null) { markJobFailed(job, "notify callback timeout type=$type") @@ -552,6 +653,7 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null activeRequestType = null + clearActiveNotifyMetrics() beginCancelledCallbackDrain("notify callback timeout") } } @@ -570,6 +672,7 @@ class BleNotifyQueue( drainingCancelledCallback = true notificationInFlight = false activeRequestType = null + clearActiveNotifyMetrics() cancelledCallbackDrainRunnable?.let(handler::removeCallbacks) val drain = Runnable { synchronized(this) { @@ -695,64 +798,78 @@ 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 + profile.recordSuccess(payloadKind(type), callbackRttMs) + if (beforeJson != profile.jsonDelayMs || + beforeBinary != profile.binaryDelayMs || + 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}" + ) } } - 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} " + + "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 in JSON_LYRIC_PACKET_TYPES -> + BleLinkProfile.PayloadKind.JSON_LYRIC + else -> BleLinkProfile.PayloadKind.OTHER } } + private fun markNotifyStarted(address: String, type: String) { + activeNotifyStartedAtMs = SystemClock.elapsedRealtime() + activeNotifyDeviceAddress = address + activeNotifyPacketType = type + } + + private fun clearActiveNotifyMetrics() { + activeNotifyStartedAtMs = 0L + activeNotifyDeviceAddress = null + activeNotifyPacketType = null + } + + private fun interleaveIntervalFor(jobType: String): Int { + return realtimeInterleaveIntervalFor(jobType) + } + private fun logInterleavedEventThrottled( isSavedEvent: Boolean, message: String @@ -805,6 +922,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, @@ -847,6 +987,7 @@ class BleNotifyQueue( || it.type == "mediaFieldDumpChunk" || it.type == "trackInfoChunk" || it.type == "fullLyricsChunk" + || it.type == "fullLyricsBinaryChunk" || it.type == "historyPayloadChunk" } @@ -870,16 +1011,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 +1023,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", @@ -936,6 +1067,15 @@ class BleNotifyQueue( internal fun remainingQuietDelayMs(quietUntilMs: Long, nowMs: Long): Long { return (quietUntilMs - nowMs).coerceAtLeast(0L) } + + internal fun realtimeInterleaveIntervalFor(jobType: String): Int { + return when (jobType) { + "albumArt", "fullLyrics", "lyricSecondary", + "remoteLog", "mediaFieldDump", "qrcDump", "playHistory", "playStats" -> + REALTIME_INTERLEAVE_INTERVAL + else -> 0 + } + } } enum class Priority(val rank: Int) { 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/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/MediaTransferCoordinators.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt new file mode 100644 index 0000000..47088da --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt @@ -0,0 +1,132 @@ +package com.example.playeragent.ble + +import android.os.SystemClock +import java.util.concurrent.ConcurrentHashMap + +/** + * 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() +) { + @Volatile + private var retainedTransfer: FullLyricsBinaryTransfer? = null + + fun retain(transfer: FullLyricsBinaryTransfer) { + retainedTransfer = transfer + } + + fun retained(transferId: String): FullLyricsBinaryTransfer? { + return retainedTransfer?.takeIf { it.transferId == transferId } + } + + fun reset() { + retainedTransfer = null + compressedCache.clear() + } + + fun clearRetryState() { + retainedTransfer = null + } +} + +/** + * 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[transfer.transferId] = transfer + } + + fun retained(transferId: String): AlbumArtBinaryTransfer? = retainedTransfers[transferId] + + fun reset() { + retainedTransfers.clear() + } +} + +/** + * 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 transferRetry: Boolean = false, + val negotiated: Boolean = false + ) + + @Volatile + var capabilities = Capabilities() + private set + + @Volatile + var negotiationGeneration: Long = 0L + private set + + fun beginNegotiation(): Long { + capabilities = Capabilities() + negotiationGeneration += 1 + return negotiationGeneration + } + + fun accept(requested: Capabilities) { + capabilities = requested.copy(negotiated = true) + } + + fun useLegacyIfCurrent(generation: Long): Boolean { + if (generation != negotiationGeneration || capabilities.negotiated) return false + capabilities = capabilities.copy(negotiated = true) + return true + } + + fun invalidate() { + capabilities = Capabilities() + negotiationGeneration += 1 + } +} + +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 +) + +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 +) + +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/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/service/PlayerAgentForegroundService.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt index 3c12cb9..a4d84d8 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt @@ -607,7 +607,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 { 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..befacd4 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt @@ -0,0 +1,42 @@ +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) + + profile.recordSuccess( + BleLinkProfile.PayloadKind.JSON_LYRIC, + callbackRttMs = 180 + ) + assertEquals(10L, profile.jsonDelayMs) + assertTrue(profile.ewmaCallbackRttMs >= 180.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(0L, profile.successCount) + assertEquals(0L, profile.failureCount) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt index c139741..108dcbb 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt @@ -55,4 +55,21 @@ class BleNotifyQueuePolicyTest { assertEquals(0L, BleNotifyQueue.remainingQuietDelayMs(1_000L, 1_000L)) assertEquals(0L, BleNotifyQueue.remainingQuietDelayMs(999L, 1_000L)) } + + @Test + fun everyLongTransferChecksRealtimePacketsAfterEachChunk() { + listOf( + "albumArt", + "fullLyrics", + "lyricSecondary", + "remoteLog", + "mediaFieldDump", + "qrcDump", + "playHistory", + "playStats" + ).forEach { type -> + assertEquals(1, BleNotifyQueue.realtimeInterleaveIntervalFor(type)) + } + assertEquals(0, BleNotifyQueue.realtimeInterleaveIntervalFor("playbackState")) + } } 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/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/MediaTransferCoordinatorsTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt new file mode 100644 index 0000000..455cf2c --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt @@ -0,0 +1,88 @@ +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 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 + ) + lyrics.retain(lyricTransfer) + artwork.reset() + + assertEquals(lyricTransfer, lyrics.retained("lyrics-a")) + assertNull(lyrics.retained("art-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 + ), + nowMs = 10 + ) + coordinator.retain( + AlbumArtBinaryTransfer( + trackId = "current", + quality = AlbumArtQuality.HQ, + transferId = "current-transfer", + start = packet, + chunks = listOf(packet), + end = packet, + expiresAtMs = 500 + ), + nowMs = 100 + ) + + assertNull(coordinator.retained("old-transfer")) + assertEquals("current", coordinator.retained("current-transfer")?.trackId) + } + + @Test + fun capabilityFallbackOnlyAppliesToCurrentSubscriptionGeneration() { + val coordinator = ConnectionCommandCoordinator() + val first = coordinator.beginNegotiation() + val second = coordinator.beginNegotiation() + + assertFalse(coordinator.useLegacyIfCurrent(first)) + assertTrue(coordinator.useLegacyIfCurrent(second)) + assertTrue(coordinator.capabilities.negotiated) + + coordinator.accept( + ConnectionCommandCoordinator.Capabilities( + protocolVersion = 2, + binaryAlbumArt = true, + fullLyricsZlib = true, + lyricWindow = true, + ping = true, + transferRetry = true, + negotiated = true + ) + ) + assertEquals(2, coordinator.capabilities.protocolVersion) + assertTrue(coordinator.capabilities.transferRetry) + } +} 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/docs/ALBUM_ART_ARCHITECTURE.md b/docs/ALBUM_ART_ARCHITECTURE.md index 6f30de0..2742e40 100644 --- a/docs/ALBUM_ART_ARCHITECTURE.md +++ b/docs/ALBUM_ART_ARCHITECTURE.md @@ -5,19 +5,22 @@ ## 模块职责 - Sony `AlbumArtTestManager`:从 MediaMetadata 和通知 largeIcon 中探测当前可用封面。当前真实链路常见来源是 QQMusic notification largeIcon。 -- Sony `BleGattServerManager`:处理 `ALBUM_ART_REQUEST`,压缩 preview/HQ/fallback,发送 `albumArtOffer` 和 binary chunk。 +- Sony `BleGattServerManager`:处理 `ALBUM_ART_REQUEST`,压缩 preview/HQ/fallback,发送 `albumArtOffer` 和 binary chunk;短期重传记录由独立 `AlbumArtTransferCoordinator` 持有。 - Sony `BleNotifyQueue`:发送 `albumArt` 长任务,控制 chunk 进度、超时和短状态让路。 - iOS `AlbumArtReceiver`:接收 offer、请求 preview/HQ、处理 binary start/chunk/end、超时恢复、缓存、displayQuality、enhanced、诊断 snapshot。 +- iOS `ArtworkImageCache`:缓存已经解码/降采样的 `UIImage`,40 项、32MB;内存警告只清理解码层,不删除磁盘原图。 - iOS `ArtworkEnhancementManager`:本地离线视觉增强缓存,不覆盖 HQ 原图。 - Live Activity:通过 `LiveActivityArtworkStore` 写 App Group 小缩略图,`ContentState` 只带 key/revision。 ## 核心文件 - iOS 接收:[AlbumArtReceiver.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift) +- iOS 解码缓存:[ArtworkImageCache.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ArtworkImageCache.swift) - iOS 增强:[ArtworkEnhancementManager.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/ArtworkEnhancementManager.swift) - iOS 诊断模型:[NowPlayingDiagnosticSnapshot.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift) - iOS Live Activity 缩略图:[LiveActivityArtworkStore.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift) - Sony GATT 封面发送:[BleGattServerManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt) +- Sony 封面传输协调器:[MediaTransferCoordinators.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt) - Sony 封面探测:[AlbumArtTestManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt) - iOS smoke AlbumArt 验收:[ios_album_art_flow_test.sh](/Volumes/雷电/project/MusicBleController/tools/ios-smoke-tests/ios_album_art_flow_test.sh) @@ -31,8 +34,10 @@ 6. 二进制传输: - JSON:`albumArtBinaryStart` / `albumArtBinaryEnd` - 二进制 chunk:6 字节 header + payload -7. iOS 在 utility 串行队列原子保存 `Documents/AlbumArtCache/`,不阻塞 CoreBluetooth/UI 主线程;增强图保存到 Enhanced 子目录。 -8. 主 UI 使用最终 `albumArtImage`;Live Activity 使用 App Group 小图。 +7. 传输结束先复制不可变 `Data` 快照到 utility 串行队列,再做 JPEG 校验、磁盘读写和 ImageIO 降采样;主图约 780px、历史缩略图约 128px。 +8. 解码完成回主线程前再次校验 `transferId + artworkId`;切歌后的旧任务不能覆盖新封面。 +9. iOS 在 utility 串行队列原子保存 `Documents/AlbumArtCache/`;增强图保存到 Enhanced 子目录。 +10. 主 UI 使用最终 `albumArtImage`;Live Activity 使用 App Group 小图。加载层明确区分 preview、HQ 和失败,可只重试当前歌曲。 ## 关键状态 @@ -46,6 +51,7 @@ - 缓存最长可展示 30 天;通知来源 30 分钟后复验,稳定 metadata 来源 24 小时后复验。小于 300px 只触发后台刷新,不清空可用图。 - Sony preview 目标约 112px、Q40~50、最多 1.8KB/12 包;HQ Q70~80、最多 8KB且保持最低优先级。 - Sony 已编码 JPEG 使用 40 项、16MB、30 分钟内存 LRU,避免同歌曲重复压缩。 +- iOS 解码缓存 key 包含 `artworkId + quality + targetPixelSize`,避免同一 JPEG 在主界面和历史列表重复全尺寸解码。 - A1 header 和 quality code 不变;start/end 可带 `transferId`、`generation`、`crc32`,新端支持局部重传,旧端直接忽略新字段。 ## 不允许随便修改的点 @@ -55,6 +61,7 @@ - 不要让 HQ 请求在 fullLyrics、lyricSecondary、remoteLog、mediaFieldDump 长任务期间抢占。 - 不要覆盖 HQ 原图;enhanced 必须是独立缓存。 - 不要把封面接收逻辑塞回 `BLETestManager`。 +- 不要在 SwiftUI `body` 或主线程同步 `Data(contentsOf:)`、校验 JPEG 或创建全尺寸图片。 ## 常见问题排查入口 @@ -64,6 +71,7 @@ - `[AlbumArt-iOS] transfer start/first chunk timeout/idle chunk timeout/total timeout/transfer cancelled` - `[AlbumArtCache] saved/display quality` - `[ArtworkDisplay] upgrade ...` + - `[ArtworkImageCache]` / `[ArtworkDecode]` - `[ArtworkEnhance] ...` - Sony: - `[AlbumArt-Sony]` diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index c14c061..f1d2d44 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) @@ -65,6 +69,7 @@ ## 关键状态 - Sony `BleNotifyQueue` 使用四级优先级:P0 控制/状态/逐字,P1 lyricWindow/preview,P2 完整歌词/secondary,P3 HQ/历史/日志。 +- 队列的 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`。 @@ -76,13 +81,23 @@ 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 协议。 ## 自适应发送 -- JSON 歌词初始间隔 5ms,A2 binary 初始 2ms。 -- notify 失败后增加 5ms,连续 20 包成功后逐步降低,最大 30ms。 +- 每次连接创建独立 `BleLinkProfile`,记录设备、连接代次、MTU、EWMA notify RTT、失败率和 JSON/二进制间隔;重连或 MTU 改变时重置。 +- JSON 初始 5ms、范围 2~30ms;binary 初始 2ms、范围 1~30ms。 +- notify 失败或 callback RTT 超过 120ms 时增加 5ms;连续 20 次成功且 EWMA RTT 小于 60ms 时减少 1ms。 - `GET_LYRIC_WINDOW` 与完整歌词走独立歌词命令执行器,不被较慢的播放状态读取 FIFO 阻塞。 +- 压缩歌词缓存最多 16 项/512KB,保存 zlib 正文与 CRC,按当前 MTU 重新分包;fingerprint、generation、格式或逐字行集合变化时失效。 +- Sony 执行器只分实时、前台媒体 I/O、后台维护和 BLE 发送四类;历史、索引、日志与诊断不得占用实时线程。 + +## iOS 写回调容错 + +- CoreBluetooth 偶发漏掉单次 `didWrite`,但 Sony 已处理命令且 status notify 仍持续时,iOS 先进入 1 秒 late-callback fence,丢弃待处理的后台写并保留控制写。 +- 后续真实写成功会清零计数;连续两次写回调超时才 hard reconnect。没有活动 notify 的超时仍按断链处理。 +- 恢复连接的 smoke 参数通过一次性 App 容器标记传递,避免 CoreBluetooth 后台恢复抢先启动导致测试序列丢失。 ## 不允许随便修改的点 diff --git a/docs/IOS_ARCHITECTURE.md b/docs/IOS_ARCHITECTURE.md index 6bc220d..3e67af0 100644 --- a/docs/IOS_ARCHITECTURE.md +++ b/docs/IOS_ARCHITECTURE.md @@ -5,8 +5,12 @@ ## 模块职责 - `BLETestManager`:CoreBluetooth Central、扫描/连接/重连、命令写入、status notify 分发、歌词/历史/诊断/Live Activity 更新协调。 -- `PreferencesStore`:本地设置和 UserDefaults。包括日常/调试模式、自动重连、歌词偏移、歌词显示模式、封面增强开关、封面显示尺寸。 +- `PreferencesStore`:本地设置和 UserDefaults。包括日常/调试模式、自动重连、歌词偏移、歌词显示模式、封面增强、封面尺寸和自动/流畅/省电性能模式。 - `AlbumArtReceiver`:封面 offer、preview/HQ 请求、legacy/base64 和 binary 接收、超时、缓存、displayQuality、enhanced、诊断 snapshot。 +- `ArtworkImageCache`:40 项/32MB 解码图片缓存,以及 utility 串行队列上的 ImageIO 降采样。 +- `BLEObservableStateModels`:连接、播放、歌词、封面、诊断五个可观察切片;相同值不重复发布。 +- `LastNowPlayingSnapshotStore`:24 小时版本化启动占位,只显示为“上次播放 · 等待同步”。 +- `MediaLoadingState`:歌词窗口/完整歌词和 preview/HQ 的加载阶段及明确失败原因。 - `ContentView`:主播放器 UI,按 `AppExperienceMode` 控制日常/调试入口。 - `FullLyricsView`:全屏歌词、翻译/罗马音显示模式、逐字高亮和点击 seek。 - `NowPlayingDiagnosticView` / `SystemHealthOverviewView`:当前歌曲诊断、快捷修复、系统健康总览。 @@ -18,6 +22,10 @@ - [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) +- [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,9 +42,11 @@ - `lyricSecondary*` 组装翻译/罗马音。 - `albumArt*` 转交 `AlbumArtReceiver`。 - `playHistory*` 合并到 `PlaybackHistoryStore`。 -4. UI 通过 `@Published` 状态刷新。 +4. `BLETestManager` 保留兼容 `@Published` API,但主界面分别观察五个 `ObservableStateSlice`;同值与无关状态不再让整个播放器重绘。 5. 诊断页通过 `makeNowPlayingDiagnosticSnapshot()` 从当前状态派生。 +CoreBluetooth 使用 `com.musicblecontroller.sony.central.v1` 做状态恢复。恢复到已连接且特征有效的 Sony 时直接复用 characteristic/notify;恢复确认前,前台生命周期检查不得把 `disconnected` 初值误判为真实断链。只有恢复状态无效时才回到 scan/reconnect。 + ## 关键状态 - 连接:`connectionStatus`、`connectionDisplayState`、`connectionHealthState`、`autoReconnectState`。 @@ -44,6 +54,8 @@ - 歌词:`lyric`、`fullLyrics`、`fullLyricsTrackId`、`lyricDiagnostic`、`lyricSecondaryTransfer`。 - 封面:`albumArtImage`、`artworkDisplayQuality`、`artworkEnhancementStatus`,由 `AlbumArtReceiver` 镜像到 `BLETestManager`。 - 设置:`PreferencesStore.shared` 是真实 owner;`BLETestManager` 只做兼容读取/同步。 +- 播放时钟只在前台且播放中运行;暂停、断开、后台保持静态位置。频谱为播放 20fps、加载 10fps,Reduce Motion/低电量/省电模式静态化。 +- 稳定歌曲会异步写入 `LastNowPlayingSnapshot-v1.json`;首个真实 playbackState 不匹配或没有活动歌曲时立即清除占位。 ## 不允许随便修改的点 @@ -51,12 +63,15 @@ - 不要把设置重新塞回 `BLETestManager`。 - 不要把封面接收链路从 `AlbumArtReceiver` 拆回 `BLETestManager`。 - 不要让控制命令在非 healthy 状态下排队补发。 +- 不要绕过五个状态切片让主界面重新直接观察所有 BLE 诊断字段。 +- 不要把“上次播放”快照当作真实在线播放状态。 - 不要让 UI 模式影响 BLE、歌词、封面、Live Activity 的业务逻辑。 ## 常见问题排查入口 - 自动连接慢:查 `[BLE-Reconnect] foreground strategy=scanFirst`、retrieve fast timeout、didDiscover、didConnect。 - 假连接:查 `[BLE-Health] suspect/probe/hard reconnect` 和 `connectionHealthState`。 +- 恢复后立即重连:查 `[BLE-Restore]`、`foreground restore skipped`,恢复完成前不应出现 `foreground unhealthy ... hard reconnect`。 - 设置未持久化:查 `[Preferences] loaded`、`[Preferences] changed`、smoke `[SmokeTest] preferences persisted`。 - 诊断页数据不对:查 `makeNowPlayingDiagnosticSnapshot()` 和 `SystemHealthSnapshot(nowPlaying:)`。 @@ -66,3 +81,4 @@ - 改 `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` diff --git a/docs/LYRICS_ARCHITECTURE.md b/docs/LYRICS_ARCHITECTURE.md index 8f92018..0795827 100644 --- a/docs/LYRICS_ARCHITECTURE.md +++ b/docs/LYRICS_ARCHITECTURE.md @@ -11,6 +11,7 @@ - Sony `QrcDirectoryWatcher` / `QrcIncrementalPrebuildManager`:监听 QQMusic 文件后到并增量解析。 - Sony `LyricRecoveryEngine`:当前歌曲无歌词后短期恢复窗口,处理 QQ音乐懒加载歌词缓存。 - iOS `BLETestManager`:接收 `lyricWindow*`、legacy `fullLyrics*`、A2 zlib full lyrics、`lyricSecondary*` 和歌词诊断。 +- Sony `LyricsTransferCoordinator` / `CompressedLyricsCache`:独立持有歌词重传与最多 16 项/512KB 的 zlib 正文和 CRC,不受封面生命周期影响。 - iOS `FullLyricsView`:显示原文、翻译、罗马音,逐字高亮只作用于原文。 ## 核心文件 @@ -25,6 +26,8 @@ - Sony recovery:[LyricRecoveryEngine.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricRecoveryEngine.kt) - iOS full lyrics:[FullLyricsView.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift) - iOS 诊断:[LyricDiagnostic.swift](/Volumes/雷电/project/MusicBleController/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnostic.swift) +- Sony 压缩热缓存:[CompressedLyricsCache.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CompressedLyricsCache.kt) +- Sony 歌词传输协调器:[MediaTransferCoordinators.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt) ## 数据流 @@ -35,8 +38,11 @@ 5. QRC 前台顺序为 L1、alias、exact L2、title+artist 唯一安全直达、内存索引 fuzzy;不再为当前歌曲 `listFiles` 或解析所有缓存 JSON。 6. TrackInfo 后 iOS 优先请求最多 5 行的 `GET_LYRIC_WINDOW`,先构建可见 UI;完整歌词后台请求。 7. V2 完整歌词为 zlib JSON + A2 binary + CRC32;协商/大小失败自动回退 legacy 逐行协议。 + - `RETRY_TRANSFER` 使用歌词传输自己的 `trackId + generation + transferId`,与封面是否存在、是否完成无关。 + - 重传结果仍受当前 generation 栅栏保护,旧歌曲不得覆盖新歌。 8. 翻译/罗马音继续通过 `GET_LYRIC_SECONDARY mode=translation|romanization` 独立分片发送。 9. QRC 就绪回调会立即恢复 currentWord 边界调度;暂停时无周期任务,seek/恢复/切歌重新计算,最长 500ms 漂移校正。 +10. iOS 主界面显示等待 QQ QRC、歌词窗口、完整歌词、完成或明确失败;“重试歌词”只清理当前歌曲 cooldown/失败状态,并用 `GET_FULL_LYRICS forceRefresh=true` 触发兼容刷新。 ## 关键状态 @@ -46,6 +52,7 @@ - `QrcParsedCacheIndex.json`:只保存 songKey、标准化 metadata、groupId、文件名、行数、时间和 fingerprint;500ms 防抖、临时文件原子替换。 - `LyricRecoveryState`:`WAITING_QQMUSIC_CACHE`、`WATCHING_RECENT_QRC`、`RETRY_SCHEDULED`、`RETRYING`、`RESOLVED`、`EXPIRED` 等。 - iOS `LyricLine`:解析 fullLyrics 和 secondary 后合并,secondary 会清洗 `//`、`/`、`暂无翻译` 等占位。 +- 压缩缓存 key 包含 songKey、fingerprint、generation、格式和逐字行集合;翻译/罗马音、fingerprint、generation 改变时不复用旧正文。 ## 不允许随便修改的点 @@ -56,6 +63,7 @@ - 不要把 translation/romanization 塞回基础 `fullLyricsChunk` 导致 MTU 裁剪。 - 不要让 Live Activity 显示翻译/罗马音或携带完整歌词。 - 不要删除 negative/cooldown/alias 策略,除非有日志证明它们错误。 +- 不要让歌词重传读取或比较 `currentAlbumArtId`。 ## 常见问题排查入口 diff --git a/docs/RECONNECT_HEALTH_ARCHITECTURE.md b/docs/RECONNECT_HEALTH_ARCHITECTURE.md index 494b337..8db550d 100644 --- a/docs/RECONNECT_HEALTH_ARCHITECTURE.md +++ b/docs/RECONNECT_HEALTH_ARCHITECTURE.md @@ -25,6 +25,8 @@ 5. Health timer 检查静默时间:播放中 15 秒、暂停中 30 秒才探测。 6. V2 发送轻量 `PING/PONG`;旧 Sony 回退 `GET_PLAYBACK_STATE`。连续两次探测失败才 hard reconnect,明确断开回调仍立即处理。 7. `connectionDisplayState` 给 UI,`connectionHealthState` 给诊断和控制保护。 +8. CoreBluetooth 状态恢复优先复用已连接 Sony 和已恢复 characteristic。恢复未完成时标为 `suspect`,前台生命周期检查必须等待 notify 确认,不能以初始 `disconnected` 强制断开有效连接。 +9. 单次 `didWrite` 回调超时但 3 秒内仍收到 status notify 时只做软恢复;连续两次写回调超时才 hard reconnect。 ## 关键状态 @@ -40,6 +42,7 @@ - `connectionHealthState`:内部健康,如 `healthy`、`suspect`、`stale`、`disconnected`。 - `autoReconnectState`:`idle`、`reconnectScheduled`、`scanning`、`connecting` 等。 - `connectionAttemptId`:防止旧 scan/connect 回调污染当前状态。 +- `coreBluetoothRestoreInProgress`:隔离状态恢复和前台检查的竞态。 ## 不允许随便修改的点 @@ -48,16 +51,19 @@ - 不要让 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 soft recovery`;仍有 notify 时不应紧跟 hard reconnect。 - 胶囊闪烁:区分 `connectionDisplayState` 和 `connectionHealthState`,日常模式不应展示技术细节。 ## 修改后必须跑哪些 smoke test - 改连接、重连、Health、UI 胶囊:quick smoke。 - 改 `autoReconnectEnabled` 默认值/UserDefaults/Preferences:full smoke。 -- 真机建议测试:Sony 服务停止、恢复、App 前后台、Force Reconnect。 +- 真机建议测试:`reconnect_sync_v28_test.sh`、Sony 服务停止/恢复、App 前后台、Force Reconnect。 diff --git a/docs/SMOKE_TEST_GUIDE.md b/docs/SMOKE_TEST_GUIDE.md index 9ac0eb5..966aa57 100644 --- a/docs/SMOKE_TEST_GUIDE.md +++ b/docs/SMOKE_TEST_GUIDE.md @@ -5,6 +5,7 @@ - iOS-only:只验证 iPhone 端,不控制 Sony,也不使用 adb。 - Android/Sony-only:只验证 Sony `PlayerAgentApp`,使用 adb,不操作 iPhone。 - Cross-device entry:编排 iOS-only 和 Android/Sony-only,并生成总报告。 +- CI:`.github/workflows/build.yml` 固定 JDK 17 跑 Android 单测/assembleDebug,并在无签名 iOS Simulator 上跑 XCTest。 ## iOS-only 模块职责 @@ -75,6 +76,7 @@ 1. 选择 iPhone:`--device` > `IOS_DEVICE_ID` > 自动发现 available iPhone。 2. 可选 build/install。 3. `devicectl launch` 启动 App。 + - 有 `--smoke-*` 参数时,`ios_ble_precheck.sh` 会先写入一次性 `Documents/SmokeLaunchArguments.txt`。即使 CoreBluetooth 状态恢复先在后台唤醒 App,测试参数也不会丢失;App 读取后立即删除。 4. 从 App container 复制 `Documents/Logs/ios_ble.log`。 5. DEBUG smoke preferences 写入并重启验证。 6. 文件检查 App container 中日志、AlbumArtCache、Preferences。 @@ -179,6 +181,7 @@ - `fullLyricsSendStart/End` 同时覆盖 A2 与 legacy,不能因协议升级造成 trace 缺失。 - 冷测使用调试流程自然换歌,不删除用户真实 QRC/封面缓存。 - 自动切歌必须通过 iOS BLE 控制或 Android `cmd media_session dispatch next`;不要向无焦点 PlayerAgent Activity 注入 `input keyevent`,否则系统可能产生与业务线程无关的 input-dispatch ANR。 +- Sony-only 模式的当前歌词刷新必须通过 Debug Receiver 的 `com.example.playeragent.debug.REFRESH_CURRENT_LYRIC` 转发;正式前台服务保持 `exported=false`,shell 不能直接启动。 - iPhone 锁屏导致 `ios_app_launch_failed` 时测试不得执行动作,解锁后整项重跑。 ## iOS BLE 硬前置校验 @@ -188,12 +191,25 @@ 硬性条件: - iOS App launched。 -- BLE connected。 +- BLE connected。新的 `didConnect` 或 `[BLE-Restore] restored ... state=connected` 都是有效连接证据。 - notify subscribed。 - 5 秒内收到至少 1 条 `playbackState`。 如果前置校验失败,脚本必须立即 FAIL,`precheckFailReason=ios_ble_not_connected`,并且不得继续执行 `GET_FULL_LYRICS`、AlbumArt 请求、`NEXT`、`VOLUME`、`SEEK` 等动作。报告必须输出 `iosAppLaunched`、`iosBleConnected`、`notifySubscribed`、`firstPlaybackStateReceived`、`firstPlaybackStateLatencyMs`、`precheckResult`、`precheckFailReason`。 +## iOS XCTest + +```bash +xcodebuild \ + -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ + -scheme sonyMusic \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +`PerformanceStabilityTests` 覆盖 A1/A2 分发、乱序/重复/缺包、CRC/zlib 固定向量、封面降采样和损坏数据、快照过期/损坏、状态切片去重及播放时钟暂停。新增协议或缓存行为时,应先增加确定性 XCTest,再跑真机 smoke。 + ## Source Capability V3.0 源可用性诊断 命令: diff --git a/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/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/smoke/README.md b/tools/smoke/README.md index a5eeddb..a93d32e 100644 --- a/tools/smoke/README.md +++ b/tools/smoke/README.md @@ -87,12 +87,14 @@ Use this when lyrics or artwork are slow/missing and you need to know whether th All tests that perform real iOS BLE operations must pass `ios_ble_precheck.sh` before they send commands or request large payloads. The hard precheck requires: - iOS app launched. -- BLE connected. +- BLE connected through a fresh `didConnect` or CoreBluetooth restored-connected event. - status notify subscribed. - at least one `playbackState` received within 5 seconds. If any of these conditions fail, the test exits immediately with `reason=ios_ble_not_connected` and does not send `GET_FULL_LYRICS`, AlbumArt requests, `NEXT`, volume, or seek commands. Reports include `iosAppLaunched`, `iosBleConnected`, `notifySubscribed`, `firstPlaybackStateReceived`, `firstPlaybackStateLatencyMs`, `precheckResult`, and `precheckFailReason`. +When a test supplies `--smoke-*` launch arguments, the precheck also stages a one-shot app-container marker. This keeps the test trigger deterministic when CoreBluetooth restoration wakes the app before the foreground launch process receives its arguments. + ## Device Behavior When neither `--ios-only` nor `--android-only` is used: diff --git a/tools/smoke/ios_ble_precheck.sh b/tools/smoke/ios_ble_precheck.sh index 0cb270a..69e776b 100755 --- a/tools/smoke/ios_ble_precheck.sh +++ b/tools/smoke/ios_ble_precheck.sh @@ -121,12 +121,21 @@ def first(pattern: str): return line, parse_ios_ms(line) return "", None -_, connected_at = first(r"\[BLE-iOS\]\s+didConnect|\[BLE\]\s+connected|\[Reconnect\]\s+connected") +_, connected_at = first( + r"\[BLE-iOS\]\s+didConnect|" + r"\[BLE\]\s+connected|" + r"\[Reconnect\]\s+connected|" + r"\[BLE-Restore\]\s+restored\b.*\bstate=connected" +) _, notify_at = first(r"status notify subscribed|notify subscribed|\[Reconnect\]\s+subscribed") _, playback_at = first(r'\{"type":"playbackState"|\[iOS\]\[Status\]\s+playbackState|\[Reconnect\]\s+playbackState accepted|notify received type=playbackState') display_state = "unknown" -if re.search(r"didConnect|\[BLE\]\s+connected", window_text, re.I): +if re.search( + r"didConnect|\[BLE\]\s+connected|\[BLE-Restore\]\s+restored\b.*\bstate=connected", + window_text, + re.I, +): display_state = "connected_no_status" if re.search(r"notify subscribed", window_text, re.I): display_state = "subscribed_waiting_status" @@ -194,6 +203,22 @@ if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then launch_args_display="${LAUNCH_ARGS[*]}" fi log "launch app for BLE precheck timeout=${TIMEOUT_SEC}s args=${launch_args_display}" +if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then + LAUNCH_MARKER="$OUT_DIR/SmokeLaunchArguments.txt" + : > "$LAUNCH_MARKER" + for launch_arg in "${LAUNCH_ARGS[@]}"; do + printf '%s\n' "$launch_arg" >> "$LAUNCH_MARKER" + done + if ! xcrun devicectl device copy to \ + --device "$IOS_DEVICE_ID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source "$LAUNCH_MARKER" \ + --destination "Documents/SmokeLaunchArguments.txt" \ + >>"$PRECHECK_LOG" 2>&1; then + log "WARN unable to stage restoration-safe smoke launch marker; using process argument only" + fi +fi LAUNCH_OK=true launch_cmd=(xcrun devicectl device process launch --device "$IOS_DEVICE_ID" --terminate-existing "$BUNDLE_ID") if [[ "${#LAUNCH_ARGS[@]}" -gt 0 ]]; then diff --git a/tools/smoke/lyrics_timeline_v34_test.sh b/tools/smoke/lyrics_timeline_v34_test.sh index 192aee8..0bd6df6 100755 --- a/tools/smoke/lyrics_timeline_v34_test.sh +++ b/tools/smoke/lyrics_timeline_v34_test.sh @@ -397,9 +397,9 @@ if [[ "$SONY_ONLY" == true ]]; then for _ in $(seq 1 "$EXPECTED_TRACK_COUNT"); do "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell cmd media_session dispatch next || true sleep 1 - "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell am start-foreground-service \ - -n com.example.playeragent/.service.PlayerAgentForegroundService \ - -a com.example.playeragent.ACTION_REFRESH_CURRENT_LYRIC || true + "$ADB_BIN" -s "$ANDROID_DEVICE_ID" shell am broadcast \ + -p com.example.playeragent \ + -a com.example.playeragent.debug.REFRESH_CURRENT_LYRIC || true sleep "$interval" done ) >"$OUT_DIR/sony_next_driver.out" 2>"$OUT_DIR/sony_next_driver.err" & diff --git a/tools/smoke/reconnect_sync_v28_test.sh b/tools/smoke/reconnect_sync_v28_test.sh index 9706ee3..3868062 100755 --- a/tools/smoke/reconnect_sync_v28_test.sh +++ b/tools/smoke/reconnect_sync_v28_test.sh @@ -388,12 +388,26 @@ sync_current_word_sent = count(r"\[ReconnectSync\] send currentWord", sony_text) sync_current_word_skip = count(r"\[ReconnectSync\] skip currentWord", sony_text) sync_album_offer_sent = count(r"\[ReconnectSync\] send albumArtOffer", sony_text) cooldown_skip = count(r"\[ReconnectSync\] skip reason=cooldown", sony_text) +restored_connection = count( + r"\[BLE-Restore\]\s+restored\b.*\bstate=connected", + ios_window, +) +ios_restore_sync_start = count(r"\[BLE-Reconnect\] sync playback state", ios_window) ios_state_sync = count(r"\[Reconnect\] state sync received", ios_window) ios_reconnect_playback_accepted = count(r"\[Reconnect\] playbackState accepted", ios_window) ios_raw_playback_state = count(r'\{"type":"playbackState"', ios_window) ios_playback_accepted = max(ios_reconnect_playback_accepted, ios_raw_playback_state) -ios_current_word_after = count(r"\[Reconnect\] currentWord accepted after reconnect", ios_window) +ios_current_word_reconnect_log = count( + r"\[Reconnect\] currentWord accepted after reconnect", + ios_window, +) +ios_raw_current_word = count(r"\[Lyrics-iOS\]\s+currentWord", ios_window) +ios_current_word_after = max(ios_current_word_reconnect_log, ios_raw_current_word) ios_album_offer = count(r"albumArtOffer", ios_window) +ios_artwork_cache_reused = count( + r"\[AlbumArtCache\]\s+hit|\[ArtworkDisplay\].*\bcacheHit\b", + ios_window, +) stale_after = count(r"\[Reconnect\] stale discard after reconnect", ios_window) main_stall = count(r"main stall detected", ios_window) payload_too_large = count(r"payload=\d+ max=\d+|Payload maximum size exceeded", sony_text + "\n" + ios_window) @@ -422,13 +436,13 @@ if ios_playback_accepted == 0: issues.append("iOS did not receive reconnect playbackState") elif playback_latency and playback_latency > 5_000: issues.append(f"reconnect playbackState latency exceeded 5s ({playback_latency}ms)") -if sync_start == 0: +if sync_start == 0 and not (restored_connection and ios_restore_sync_start): warnings.append("Sony reconnect sync start was not observed") -if sync_playback_sent == 0: +if sync_playback_sent == 0 and ios_playback_accepted == 0: warnings.append("Sony reconnect playbackState send log was not observed") -if sync_current_word_sent == 0: +if sync_current_word_sent == 0 and ios_current_word_after == 0: warnings.append("currentWord was not sent after reconnect or was unavailable") -if sync_album_offer_sent == 0: +if sync_album_offer_sent == 0 and ios_album_offer == 0 and ios_artwork_cache_reused == 0: warnings.append("albumArtOffer was not resent after reconnect") if stale_after > 0: issues.append(f"stale discard after reconnect observed ({stale_after})") @@ -466,13 +480,18 @@ payload = { }, "ios": { "reconnectCount": reconnect_count, + "coreBluetoothRestoredConnection": restored_connection, + "restoreSyncStartCount": ios_restore_sync_start, "notifySubscribedCount": notify_subscribed, "reconnectPlaybackStateReceived": ios_playback_accepted, "reconnectPlaybackStateAcceptedLogCount": ios_reconnect_playback_accepted, "rawPlaybackStateReceived": ios_raw_playback_state, "reconnectStateSyncReceived": ios_state_sync, "reconnectCurrentWordReceived": ios_current_word_after, + "reconnectCurrentWordAcceptedLogCount": ios_current_word_reconnect_log, + "rawCurrentWordReceived": ios_raw_current_word, "albumArtOfferReceived": ios_album_offer, + "artworkCacheReused": ios_artwork_cache_reused, "staleDiscardAfterReconnect": stale_after, "mainStallCount": main_stall, "payloadTooLargeCount": payload_too_large, @@ -506,6 +525,8 @@ md = [ "## Metrics", "", f"- reconnect count: {reconnect_count}", + f"- CoreBluetooth restored connection: {restored_connection}", + f"- iOS restore sync start: {ios_restore_sync_start}", f"- notify subscribed: {notify_subscribed}", f"- Sony sync start: {sync_start}", f"- Sony playbackState sent: {sync_playback_sent}", @@ -515,6 +536,7 @@ md = [ f"- iOS playbackState received: {ios_playback_accepted}", f"- iOS currentWord received: {ios_current_word_after}", f"- iOS albumArtOffer received: {ios_album_offer}", + f"- iOS artwork cache reused: {ios_artwork_cache_reused}", f"- playback latency: {playback_latency}ms", f"- currentWord latency: {current_word_latency}ms", f"- albumArtOffer latency: {album_offer_latency}ms", From a8e95d7b93623a495f2e469a96311840424aef46 Mon Sep 17 00:00:00 2001 From: sqz Date: Sat, 25 Jul 2026 23:30:52 +0800 Subject: [PATCH 02/13] fix: select available iOS simulator in CI --- .github/workflows/build.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1844eb..1632f8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,17 +36,37 @@ jobs: timeout-minutes: 35 steps: - uses: actions/checkout@v4 - - name: Select Xcode 16.2 when available + - name: Inspect Xcode and available simulators run: | - if [ -d /Applications/Xcode_16.2.app ]; then - sudo xcode-select -s /Applications/Xcode_16.2.app - fi 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}" xcodebuild \ -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ -scheme sonyMusic \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \ + -destination "platform=iOS Simulator,id=${simulator_udid}" \ + -derivedDataPath "${RUNNER_TEMP}/IOSBleFeasibilityDerivedData" \ CODE_SIGNING_ALLOWED=NO \ test From 3174e6160decf9bd3ad0ad8069468b453fa65e1e Mon Sep 17 00:00:00 2001 From: sqz Date: Sun, 26 Jul 2026 23:32:42 +0800 Subject: [PATCH 03/13] perf: reduce BLE media latency and reconnect stalls --- .../IOSBleFeasibility/BLETestManager.swift | 209 ++++++++++++------ .../playeragent/ble/BleGattServerManager.kt | 112 +++++++--- .../example/playeragent/ble/BleLinkProfile.kt | 23 ++ .../example/playeragent/ble/BleNotifyQueue.kt | 25 ++- .../playeragent/media/AlbumArtTestManager.kt | 145 ++++++++---- .../ble/BleGattServerManagerPolicyTest.kt | 13 ++ .../playeragent/ble/BleLinkProfileTest.kt | 14 +- .../ble/BleNotifyQueuePolicyTest.kt | 21 ++ 8 files changed, 413 insertions(+), 149 deletions(-) create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index 8ee2088..0b48052 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -28,6 +28,7 @@ private let CONNECTION_HEALTH_PAUSED_SUSPECT_MS: Int64 = 30_000 private let CONNECTION_HEALTH_PROBE_TIMEOUT_MS: Int64 = 3_000 private let CONNECTION_HEALTH_HARD_RECONNECT_MIN_INTERVAL_MS: Int64 = 5_000 private let CONNECTION_SUBSCRIBE_NOTIFY_TIMEOUT_MS: Int64 = 5_000 +private let CORE_BLUETOOTH_RESTORE_TIMEOUT_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_CONNECTED_MIN_HOLD_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_DISCONNECTED_CONFIRM_MS: Int64 = 1_000 private let COMMAND_WRITE_CALLBACK_TIMEOUT_MS: Int64 = 2_500 @@ -208,6 +209,16 @@ final class BLETestManager: NSObject, ObservableObject { let diagnosticsStateModel = ObservableStateSlice(BLEDiagnosticsViewState()) private let preferences = PreferencesStore.shared + 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 stateModelCancellable: AnyCancellable? private var stateModelSyncScheduled = false private lazy var centralManager = CBCentralManager( @@ -274,6 +285,7 @@ final class BLETestManager: NSObject, ObservableObject { 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? @@ -329,6 +341,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? @@ -407,6 +420,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 @@ -2421,10 +2439,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 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) + } + } } } } @@ -3002,6 +3037,8 @@ final class BLETestManager: NSObject, ObservableObject { private func clearConnectionTransports(reason: String) { log("[BLE-Reconnect] clear characteristics reason=\(reason)") + coreBluetoothRestoreTimeoutWorkItem?.cancel() + coreBluetoothRestoreTimeoutWorkItem = nil sonyCommandCharacteristic = nil sonyStatusCharacteristic = nil firstConnectionReadyAtMs = 0 @@ -3030,6 +3067,36 @@ final class BLETestManager: NSObject, ObservableObject { 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) { log("[BLE-Reconnect] sync playback state reason=\(reason)") sendGetPlaybackState() @@ -3553,6 +3620,7 @@ extension BLETestManager: CBCentralManagerDelegate { 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 { @@ -3881,6 +3949,8 @@ extension BLETestManager: CBPeripheralDelegate { } guard characteristic.uuid == BLEUUIDs.status else { return } coreBluetoothRestoreInProgress = false + coreBluetoothRestoreTimeoutWorkItem?.cancel() + coreBluetoothRestoreTimeoutWorkItem = nil if let error { setStatus("未连接") @@ -3947,7 +4017,9 @@ extension BLETestManager: CBPeripheralDelegate { self?.log("[BLE-Reconnect] sync playback state") self?.sendGetPlaybackState() } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + // 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 self?.log("[BLE-Reconnect] sync volume") self?.sendGetVolume() } @@ -3992,31 +4064,25 @@ extension BLETestManager: CBPeripheralDelegate { return } - guard let text = String(data: data, encoding: .utf8) else { - log("[Status] invalid UTF-8") - return - } - - if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = object["type"] as? String { - if type == "albumArtChunk" || type == "trackInfoChunk" { - log("[BLE] status notify received type=\(type)") - } else if !isHighVolumeNotifyType(type) { - log("[BLE] status notify received: \(text)") - } - } 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 { + if let text = String(data: data, encoding: .utf8) { + log("[BLE] status notify received: \(text)") + } else { + log("[Status] invalid UTF-8") + } log("[Status] JSON parse failed") return } + if type == "albumArtChunk" || type == "trackInfoChunk" { + log("[BLE] status notify received type=\(type)") + } else if !isHighVolumeNotifyType(type) { + log("[BLE] status notify received: \(String(data: data, encoding: .utf8) ?? type)") + } + parseStatus(object, type: type) + } + private func parseStatus(_ object: [String: Any], type: String) { let apply: () -> Void = { self.markStatusNotifyReceived(type: type) switch type { @@ -5226,7 +5292,8 @@ 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() @@ -5243,64 +5310,73 @@ extension BLETestManager: CBPeripheralDelegate { ) return } + fullLyricsBinaryDecodingTransferID = transfer.transferId + protocolDecodeQueue.async { + let result = Self.decodeFullLyricsBinaryTransfer(transfer) + DispatchQueue.main.async { [weak self] in + guard let self, + self.currentTrackID == transfer.trackId, + self.fullLyricsBinaryTransfer?.transferId == transfer.transferId, + self.fullLyricsBinaryDecodingTransferID == transfer.transferId else { + return + } + self.fullLyricsBinaryDecodingTransferID = nil + switch result { + case let .success(lines, compressedSize): + self.fullLyricsBinaryRetryCounts.removeValue(forKey: transfer.transferId) + self.publishFullLyrics( + lines: lines, + trackID: transfer.trackId, + isFinal: true + ) + self.fullLyricsUnavailableTrackIDs.remove(transfer.trackId) + self.log( + "[FullLyricsV2-iOS] complete transferId=\(transfer.transferId) " + + "compressed=\(compressedSize) lines=\(lines.count)" + ) + self.resetFullLyricsTransfer() + case let .failure(reason): + self.requestFullLyricsBinaryRetry( + transfer: transfer, + missing: [], + retryAll: true, + reason: reason + ) + } + } + } + } + + private static func decodeFullLyricsBinaryTransfer( + _ transfer: FullLyricsBinaryTransfer + ) -> FullLyricsBinaryDecodeResult { guard let compressed = BLEBinaryChunkCodec.reassemble( chunks: transfer.chunks, expectedCount: transfer.expectedChunks ) else { - return + 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( @@ -5840,6 +5916,7 @@ extension BLETestManager: CBPeripheralDelegate { fullLyricsExpectedCount = 0 fullLyricsChunks.removeAll() fullLyricsBinaryTransfer = nil + fullLyricsBinaryDecodingTransferID = nil isFullLyricsReceiving = false lastFullLyricsPartialPublishAtMs = 0 } 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 29f5d15..74b79c0 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -41,6 +41,8 @@ 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 @@ -97,7 +99,10 @@ class BleGattServerManager( private val albumArtHandler = Handler(Looper.getMainLooper()) private val gattLifecycleHandler = Handler(Looper.getMainLooper()) private val qqMusicArtworkListener: (String) -> Unit = { event -> - albumArtHandler.post { retryCurrentAlbumArtAfterNotification(event) } + albumArtHandler.post { + retryCurrentAlbumArtAfterNotification(event) + wakeAutoPushFromQqNotification() + } } private var gattServer: BluetoothGattServer? = null @@ -138,8 +143,6 @@ class BleGattServerManager( @Volatile private var pendingAlbumArt: PendingAlbumArt? = null @Volatile - private var albumArtScheduleGeneration = 0L - @Volatile private var albumArtTaskGeneration = 0L @Volatile private var albumArtFastPathTask: Future<*>? = null @@ -150,18 +153,8 @@ 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 albumArtCache = + LinkedHashMap(ALBUM_ART_CACHE_CAPACITY * 2, 0.75f, true) private val encodedAlbumArtCache = LinkedHashMap(ENCODED_ART_CACHE_CAPACITY, 0.75f, true) private var encodedAlbumArtCacheBytes = 0 @@ -176,6 +169,8 @@ class BleGattServerManager( private val connectionCommandCoordinator = ConnectionCommandCoordinator() 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 @@ -261,7 +256,6 @@ class BleGattServerManager( albumArtRequestsCompleted.clear() albumArtPendingRequests.clear() pendingAlbumArt = null - albumArtScheduleGeneration += 1 resetClientCapabilities() lastAutoPushSongKey = null lastAutoPushPlaying = null @@ -468,7 +462,6 @@ class BleGattServerManager( albumArtRequestsInFlight.clear() albumArtRequestsCompleted.clear() pendingAlbumArt = null - albumArtScheduleGeneration += 1 resetClientCapabilities() lastAutoPushSongKey = null lastAutoPushPlaying = null @@ -637,7 +630,6 @@ class BleGattServerManager( pendingAlbumArt = null lyricsTransferCoordinator.reset() albumArtTransferCoordinator.reset() - albumArtScheduleGeneration += 1 albumArtTaskGeneration += 1 albumArtFastPathTask?.cancel(true) albumArtFastPathTask = null @@ -1326,7 +1318,11 @@ class BleGattServerManager( }.also { executor -> executor.scheduleAtFixedRate( { - pushPlaybackStateAutomatically() + val now = SystemClock.elapsedRealtime() + val interval = autoPushPollIntervalMs(lastAutoPushPlaying) + if (now - lastAutoPushReadAtMs >= interval) { + pushPlaybackStateAutomatically() + } }, 0L, AUTO_PUSH_INTERVAL_MS, @@ -1351,9 +1347,22 @@ class BleGattServerManager( 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() @@ -1361,6 +1370,7 @@ class BleGattServerManager( } try { + lastAutoPushReadAtMs = SystemClock.elapsedRealtime() val source = playbackStateReader.readPlaybackState() onPlaybackUiState(source) val songChanged = logAutoPushStateChanges(source) @@ -1371,7 +1381,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} " + @@ -1822,18 +1832,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) } /** @@ -1862,7 +1865,6 @@ class BleGattServerManager( albumArtRequestsCompleted.clear() albumArtPendingRequests.clear() albumArtSourceRetryCounts.clear() - albumArtScheduleGeneration += 1 notifyQueue.cancelJobTypes( setOf(ALBUM_ART_JOB_TYPE), "no active QQ track" @@ -2215,7 +2217,7 @@ class BleGattServerManager( logger("[AlbumArtCache] hit key=$key id=${entry.protocolId}") return entry } - albumArtCache.remove(key) + removeAlbumArtCacheEntryLocked(entry) logger("[AlbumArtCache] evict key=$key reason=ttl") } } @@ -2242,6 +2244,7 @@ class BleGattServerManager( synchronized(albumArtCache) { albumArtCache[albumArtPrimaryCacheKey(protocolId)] = entry albumArtCache[albumArtFallbackCacheKey(cacheKey)] = entry + trimAlbumArtCacheLocked() } logger( "[AlbumArtCache] put id=$protocolId source=$source " + @@ -2249,6 +2252,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 @@ -5604,6 +5637,7 @@ class BleGattServerManager( private const val DEFAULT_MTU = 23 private const val ATT_HEADER_SIZE = 3 private const val AUTO_PUSH_INTERVAL_MS = 1000L + private const val AUTO_PUSH_PAUSED_INTERVAL_MS = 5000L private const val CURRENT_WORD_INITIAL_DELAY_MS = 100L private const val CURRENT_WORD_TRACK_SWITCH_DELAY_MS = 450L private const val CURRENT_WORD_DRIFT_CORRECTION_MS = 500L @@ -5621,9 +5655,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 @@ -5648,6 +5682,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 diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt index f3c2811..3211fb4 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleLinkProfile.kt @@ -8,6 +8,7 @@ internal class BleLinkProfile(initialMtu: Int) { enum class PayloadKind { JSON_LYRIC, BINARY_LYRIC, + BINARY_ARTWORK, OTHER } @@ -17,6 +18,8 @@ internal class BleLinkProfile(initialMtu: Int) { 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 @@ -26,16 +29,19 @@ internal class BleLinkProfile(initialMtu: Int) { 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) { @@ -48,6 +54,7 @@ internal class BleLinkProfile(initialMtu: Int) { return when (kind) { PayloadKind.JSON_LYRIC -> jsonDelayMs PayloadKind.BINARY_LYRIC -> binaryDelayMs + PayloadKind.BINARY_ARTWORK -> artworkDelayMs PayloadKind.OTHER -> fallbackMs } } @@ -87,6 +94,16 @@ internal class BleLinkProfile(initialMtu: Int) { 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 } } @@ -105,6 +122,9 @@ internal class BleLinkProfile(initialMtu: Int) { 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 } } @@ -113,6 +133,7 @@ internal class BleLinkProfile(initialMtu: Int) { when (kind) { PayloadKind.JSON_LYRIC -> jsonSuccessWindow = 0 PayloadKind.BINARY_LYRIC -> binarySuccessWindow = 0 + PayloadKind.BINARY_ARTWORK -> artworkSuccessWindow = 0 PayloadKind.OTHER -> Unit } } @@ -120,8 +141,10 @@ internal class BleLinkProfile(initialMtu: Int) { companion object { private const val JSON_INITIAL_DELAY_MS = 5L private const val BINARY_INITIAL_DELAY_MS = 2L + private const val ARTWORK_INITIAL_DELAY_MS = 3L private const val JSON_MIN_DELAY_MS = 2L private const val BINARY_MIN_DELAY_MS = 1L + private const val ARTWORK_MIN_DELAY_MS = 1L private const val FAILURE_STEP_MS = 5L private const val MAX_DELAY_MS = 30L private const val SUCCESS_WINDOW = 20 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 5fbb31b..03c30b0 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt @@ -387,6 +387,13 @@ class BleNotifyQueue( @Synchronized private fun enqueueJobOnQueue(job: SendJob) { + if (!job.isLongJob && shouldCoalesceShortType(job.type)) { + jobs.removeAll { + !it.isLongJob && + it.type == job.type && + it.device.address == job.device.address + } + } jobs.addLast(job) sendNextPacket() } @@ -816,16 +823,19 @@ class BleNotifyQueue( } 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}" + "binaryDelayMs=${profile.binaryDelayMs} " + + "artworkDelayMs=${profile.artworkDelayMs}" ) } } @@ -840,6 +850,7 @@ class BleNotifyQueue( "[BleLink] backoff device=$address type=$type " + "jsonDelayMs=${profile.jsonDelayMs} " + "binaryDelayMs=${profile.binaryDelayMs} " + + "artworkDelayMs=${profile.artworkDelayMs} " + "failures=${profile.failureCount}" ) } @@ -848,6 +859,8 @@ class BleNotifyQueue( 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 @@ -1034,6 +1047,12 @@ class BleNotifyQueue( "lyricWindowChunk", "lyricWindowEnd" ) + private val COALESCIBLE_SHORT_TYPES = setOf( + "playbackState", + "currentWord", + "volumeState", + "albumArtOffer" + ) fun priorityFor(type: String): Priority { return when (type) { @@ -1076,6 +1095,10 @@ class BleNotifyQueue( else -> 0 } } + + internal fun shouldCoalesceShortType(type: String): Boolean { + return type in COALESCIBLE_SHORT_TYPES + } } enum class Priority(val rank: Int) { 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..ea57e8d 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt @@ -37,53 +37,79 @@ class AlbumArtTestManager( expectedTitle: String = "", expectedArtist: String = "" ): NotificationAlbumArt? { - val candidates = mutableListOf() - candidates += readMediaMetadataCandidates() - candidates += readNotificationCandidates(expectedTitle, expectedArtist) - logger("[AlbumArtSource] candidates count=${candidates.size}") - if (candidates.isEmpty()) { - logger("[AlbumArtDebug] unavailable reason=no album art candidate") - logger("[AlbumArt][BLE] unavailable") - return null + val selectedNotification = currentNotification(expectedTitle, expectedArtist) + val notificationFingerprint = selectedNotification?.let { + notificationFingerprint(it, expectedTitle) } + synchronized(sharedArtworkReadLock) { + val now = android.os.SystemClock.elapsedRealtime() + val cached = sharedArtworkReadCache + if (notificationFingerprint != null && + cached != null && + cached.notificationFingerprint == notificationFingerprint && + now - cached.createdAtElapsedMs <= SHARED_READ_CACHE_TTL_MS + ) { + logger("[AlbumArtSource] shared cache hit source=${cached.result.source}") + return cached.result + } - val valid = candidates.filter { candidate -> - if (isLikelyPlaceholder(candidate.bitmap)) { - logger("[AlbumArtSource] rejected placeholder source=${candidate.source}") - false - } else { - true + val candidates = mutableListOf() + candidates += readMediaMetadataCandidates() + candidates += readNotificationCandidates(selectedNotification) + logger("[AlbumArtSource] candidates count=${candidates.size}") + if (candidates.isEmpty()) { + logger("[AlbumArtDebug] unavailable reason=no album art candidate") + logger("[AlbumArt][BLE] unavailable") + return null } - } - 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 valid = candidates.filter { candidate -> + if (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 + } + + val selected = valid.maxWithOrNull( + compareBy { + it.bitmap.width.toLong() * it.bitmap.height.toLong() + }.thenBy { it.priority } + ) ?: return null + valid.filter { it !== selected }.forEach { candidate -> + logger( + "[AlbumArtSource] rejected smaller source=${candidate.source} " + + "width=${candidate.bitmap.width} height=${candidate.bitmap.height}" + ) + } + logger( + "[AlbumArtSource] selected source=${selected.source} " + + "width=${selected.bitmap.width} height=${selected.bitmap.height}" + ) + logger("[AlbumArtDebug] source ${selected.source} exists=true") logger( - "[AlbumArtSource] rejected smaller source=${candidate.source} " + - "width=${candidate.bitmap.width} height=${candidate.bitmap.height}" + "[AlbumArtDebug] bitmap width=${selected.bitmap.width} " + + "height=${selected.bitmap.height}" ) + val result = NotificationAlbumArt( + bitmap = selected.bitmap, + source = selected.source + ) + if (notificationFingerprint != null) { + sharedArtworkReadCache = SharedArtworkReadCache( + notificationFingerprint = notificationFingerprint, + result = result, + createdAtElapsedMs = now + ) + } + return result } - logger( - "[AlbumArtSource] selected source=${selected.source} " + - "width=${selected.bitmap.width} height=${selected.bitmap.height}" - ) - logger("[AlbumArtDebug] source ${selected.source} exists=true") - logger( - "[AlbumArtDebug] bitmap width=${selected.bitmap.width} " + - "height=${selected.bitmap.height}" - ) - return NotificationAlbumArt( - bitmap = selected.bitmap, - source = selected.source - ) } fun readCurrentNotificationLargeIcon(): Bitmap? { @@ -186,21 +212,26 @@ class AlbumArtTestManager( ) } - private fun readNotificationCandidates( + private fun currentNotification( expectedTitle: String, expectedArtist: String - ): List { + ): StatusBarNotification? { if (!isNotificationAccessEnabled() || !PlayerNotificationListenerService.isConnected() ) { - logger("[AlbumArtSource] notificationLargeIcon size=missing") - return emptyList() + return null } - val selected = selectMusicNotification( + return selectMusicNotification( PlayerNotificationListenerService.activeNotificationsSnapshot(), expectedTitle, expectedArtist - ) ?: run { + ) + } + + private fun readNotificationCandidates( + selected: StatusBarNotification? + ): List { + if (selected == null) { logger("[AlbumArtSource] notificationLargeIcon size=missing") return emptyList() } @@ -224,6 +255,19 @@ class AlbumArtTestManager( ) } + private fun notificationFingerprint( + notification: StatusBarNotification, + expectedTitle: String + ): String { + val extras = notification.notification.extras + val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() + val artist = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty() + // A title-less notification is accepted by the legacy matcher, but it + // must not share artwork across two different expected tracks. + val expectedScope = if (title.isBlank()) expectedTitle.trim().lowercase() else "" + return "${notification.key}|${notification.postTime}|$title|$artist|$expectedScope" + } + private fun albumArtCandidate( source: String, bitmap: Bitmap?, @@ -444,9 +488,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/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..4db806b --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt @@ -0,0 +1,13 @@ +package com.example.playeragent.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BleGattServerManagerPolicyTest { + @Test + fun playbackPollingSlowsOnlyAfterPausedStateIsKnown() { + assertEquals(1_000L, BleGattServerManager.autoPushPollIntervalMs(null)) + assertEquals(1_000L, BleGattServerManager.autoPushPollIntervalMs(true)) + assertEquals(5_000L, BleGattServerManager.autoPushPollIntervalMs(false)) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt index befacd4..346358e 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleLinkProfileTest.kt @@ -11,13 +11,24 @@ class BleLinkProfileTest { profile.recordFailure(BleLinkProfile.PayloadKind.BINARY_LYRIC) assertEquals(7L, profile.binaryDelayMs) assertEquals(5L, profile.jsonDelayMs) + assertEquals(3L, profile.artworkDelayMs) + + profile.recordFailure(BleLinkProfile.PayloadKind.BINARY_ARTWORK) + assertEquals(8L, profile.artworkDelayMs) + repeat(20) { + profile.recordSuccess( + BleLinkProfile.PayloadKind.BINARY_ARTWORK, + callbackRttMs = 20 + ) + } + assertEquals(7L, profile.artworkDelayMs) profile.recordSuccess( BleLinkProfile.PayloadKind.JSON_LYRIC, callbackRttMs = 180 ) assertEquals(10L, profile.jsonDelayMs) - assertTrue(profile.ewmaCallbackRttMs >= 180.0) + assertTrue(profile.ewmaCallbackRttMs > 20.0) } @Test @@ -36,6 +47,7 @@ class BleLinkProfileTest { assertEquals(247, profile.mtu) assertEquals(5L, profile.jsonDelayMs) assertEquals(2L, profile.binaryDelayMs) + assertEquals(3L, profile.artworkDelayMs) assertEquals(0L, profile.successCount) assertEquals(0L, profile.failureCount) } diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt index 108dcbb..db4535f 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt @@ -72,4 +72,25 @@ class BleNotifyQueuePolicyTest { } 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)) + } + } } From 64c7df0195a40c05ea83271f82da0f26c28f838c Mon Sep 17 00:00:00 2001 From: sqz Date: Mon, 27 Jul 2026 00:12:54 +0800 Subject: [PATCH 04/13] perf: reduce lyric and artwork latency --- .../IOSBleFeasibility/AlbumArtReceiver.swift | 26 +++- .../BLEObservableStateModels.swift | 36 ++++++ .../IOSBleFeasibility/BLETestManager.swift | 118 +++++++++++++++--- .../IOSBleFeasibility/ContentView.swift | 2 - .../PerformanceStabilityTests.swift | 14 +++ .../playeragent/ble/BleGattServerManager.kt | 1 + .../playeragent/ble/CurrentWordPushEngine.kt | 92 +++++++++++--- .../history/PlaybackHistoryModels.kt | 19 +++ .../history/PlaybackHistoryMonitor.kt | 54 ++++++-- .../example/playeragent/media/LyricManager.kt | 39 +++++- .../playeragent/media/PlaybackStateReader.kt | 7 +- .../playeragent/media/QrcLyricManager.kt | 4 + .../media/TrackCapabilityTracker.kt | 16 ++- .../QQMusicLyricAccessibilityService.kt | 55 ++++++-- .../ble/CurrentWordPushEngineTest.kt | 87 +++++++++++++ .../PlaybackHistorySamplingPolicyTest.kt | 49 ++++++++ .../AccessibilityTraversalPolicyTest.kt | 47 +++++++ 17 files changed, 594 insertions(+), 72 deletions(-) create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/history/PlaybackHistorySamplingPolicyTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/service/AccessibilityTraversalPolicyTest.kt diff --git a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift index 6f9002f..b0447cc 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift @@ -833,6 +833,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 && appLifecycleState == "active" } } + +struct CurrentWordOrderingFence { + private(set) var generation: Int64 = -1 + private(set) var sequence: Int64 = -1 + private(set) var positionMs: Int64 = -1 + + mutating func shouldAccept( + generation incomingGeneration: Int64, + sequence incomingSequence: Int64, + positionMs incomingPositionMs: Int64 + ) -> Bool { + if incomingGeneration > 0, incomingSequence > 0 { + if incomingGeneration < generation || + (incomingGeneration == generation && incomingSequence <= sequence) { + return false + } + } + if positionMs >= 0, + incomingPositionMs < positionMs, + positionMs - incomingPositionMs <= 1_500 { + return false + } + if incomingGeneration > 0, incomingSequence > 0 { + generation = incomingGeneration + sequence = incomingSequence + } + positionMs = incomingPositionMs + return true + } + + mutating func reset() { + generation = -1 + sequence = -1 + positionMs = -1 + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index 0b48052..9da0966 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -324,6 +324,9 @@ final class BLETestManager: NSObject, ObservableObject { 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) @@ -368,6 +371,8 @@ final class BLETestManager: NSObject, ObservableObject { private var connectionReadyAt: Date? private var lastHealthProbeAt: Date? private var healthProbeStartedAt: Date? + private var healthPingClockProbeStartedAt: Date? + private var sonyClockOffsetMs: Int64? private var healthProbeFailureCount = 0 private var serverProtocolVersion = 1 private var serverSupportsFullLyricsZlib = false @@ -566,8 +571,7 @@ final class BLETestManager: NSObject, ObservableObject { if artworkEnhancementSharpness != receiver.artworkEnhancementSharpness { artworkEnhancementSharpness = receiver.artworkEnhancementSharpness } - let snapshot = receiver.snapshot() - let transfer = snapshot.transfer + let transfer = receiver.transferSnapshot() let artworkStage: ArtworkLoadingStage if transfer.state == "failed" || transfer.state == "timeout" { artworkStage = .failed(reason: transfer.lastFailureReason) @@ -2064,6 +2068,7 @@ final class BLETestManager: NSObject, ObservableObject { basePlaybackPositionMs = targetPosition playbackStateReceivedAt = Date() isSeeking = false + resetCurrentWordFence() log("[iOS][Seek] user set position=\(targetPosition)") updateLiveActivity(force: true, reason: "seek") seek(to: targetPosition) @@ -2732,6 +2737,7 @@ final class BLETestManager: NSObject, ObservableObject { } lastHealthProbeAt = now healthProbeStartedAt = now + healthPingClockProbeStartedAt = serverSupportsPing ? now : nil updateConnectionHealthDebugFields() let seq = nextCommandSeq() log("[BLE-Health] probe sent seq=\(seq) reason=\(reason)") @@ -2746,6 +2752,7 @@ final class BLETestManager: NSObject, ObservableObject { let costMs = Int64(Date().timeIntervalSince(startedAt) * 1_000) self.log("[BLE-Health] probe timeout costMs=\(costMs)") self.healthProbeStartedAt = nil + self.healthPingClockProbeStartedAt = nil self.healthProbeTimeoutWorkItem = nil self.healthProbeFailureCount += 1 if self.healthProbeFailureCount >= 2 { @@ -2803,7 +2810,8 @@ final class BLETestManager: NSObject, ObservableObject { type == "historyPayloadChunk" || type == "lyricSecondaryPart" || type == "mediaFieldDumpChunk" || - type == "logChunk" + type == "logChunk" || + type == "currentWord" } private func setAutoReconnectState(_ state: AutoReconnectState) { @@ -3712,6 +3720,8 @@ extension BLETestManager: CBCentralManagerDelegate { lastStatusNotifyAt = nil lastPlaybackStateAt = nil healthProbeStartedAt = nil + healthPingClockProbeStartedAt = nil + resetCurrentWordFence() reconnectStateSyncWindowUntilMs = 0 reconnectStateSyncPlaybackLogged = false log("[BLE] connected") @@ -4104,7 +4114,26 @@ extension BLETestManager: CBPeripheralDelegate { } case "pong": - self.log("[BLE-Health] pong seq=\(object["seq"] ?? "")") + 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 { + self.log("[BLE-Health] pong seq=\(object["seq"] ?? "")") + } case "playbackState": let oldLyric = self.lyric @@ -4680,6 +4709,7 @@ extension BLETestManager: CBPeripheralDelegate { newAlbum != album || (!trackID.isEmpty && trackID != currentTrackID) if trackChanged { + resetCurrentWordFence() lyric = "" fullLyricsTrackId = "" isFullLyricsCurrent = false @@ -4783,6 +4813,7 @@ extension BLETestManager: CBPeripheralDelegate { basePlaybackPositionMs = 0 currentWordLineIndex = -1 currentWordIndex = -1 + resetCurrentWordFence() isShowingLastNowPlayingSnapshot = false mediaLoadingState = MediaLoadingState() LastNowPlayingSnapshotStore.shared.clear() @@ -6147,8 +6178,29 @@ extension BLETestManager: CBPeripheralDelegate { let wordIndex = Self.intValue(object["word"]) let remotePositionMs = Self.int64Value(object["position"]) let timestampMs = Self.int64Value(object["timestamp"]) + let generation = Self.int64Value(object["generation"]) + let sequence = Self.int64Value(object["seq"]) let nowMs = currentTimeMs() + let previousGeneration = currentWordFence.generation + let previousSequence = currentWordFence.sequence + let previousPositionMs = currentWordFence.positionMs + guard currentWordFence.shouldAccept( + generation: generation, + sequence: sequence, + positionMs: remotePositionMs + ) else { + currentWordDropCount += 1 + log( + "[CurrentWordFence] ordered discard trackId=\(trackID) " + + "generation=\(generation) seq=\(sequence) " + + "position=\(remotePositionMs) " + + "lastGeneration=\(previousGeneration) " + + "lastSeq=\(previousSequence) lastPosition=\(previousPositionMs)" + ) + return + } + currentWordLineIndex = lineIndex currentWordIndex = wordIndex currentWordPushCount += 1 @@ -6163,12 +6215,26 @@ 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)" - ) + if timestampMs > 0, let sonyClockOffsetMs { + currentWordLastLatencyMs = max( + nowMs - (timestampMs - sonyClockOffsetMs), + 0 + ) + } else { + currentWordLastLatencyMs = 0 + } + let shouldLogDiagnostic = lineIndex != lastCurrentWordLoggedLineIndex || + nowMs - lastCurrentWordDiagnosticLogAtMs >= 5_000 + if shouldLogDiagnostic { + lastCurrentWordLoggedLineIndex = lineIndex + lastCurrentWordDiagnosticLogAtMs = nowMs + log( + "[LyricTrace-iOS] id=\(trackID) stage=currentWordAccepted " + + "generation=\(generation) seq=\(sequence) " + + "line=\(lineIndex) word=\(wordIndex) position=\(remotePositionMs) " + + "latencyMs=\(currentWordLastLatencyMs)" + ) + } let lineByOffset = fullLyrics.indices.contains(lineIndex) ? fullLyrics[lineIndex] : nil if isSameTrackId(incoming: trackID, current: fullLyricsTrackId), @@ -6184,11 +6250,14 @@ extension BLETestManager: CBPeripheralDelegate { playbackStateReceivedAt = Date() } - log( - "[Lyrics-iOS] currentWord line=\(lineIndex) word=\(wordIndex) " + - "position=\(remotePositionMs) latencyMs=\(currentWordLastLatencyMs) " + - "count=\(currentWordPushCount) avgIntervalMs=\(currentWordAverageUpdateIntervalMs)" - ) + if shouldLogDiagnostic { + log( + "[Lyrics-iOS] currentWord line=\(lineIndex) word=\(wordIndex) " + + "position=\(remotePositionMs) latencyMs=\(currentWordLastLatencyMs) " + + "count=\(currentWordPushCount) " + + "avgIntervalMs=\(currentWordAverageUpdateIntervalMs)" + ) + } if isInReconnectStateSyncWindow() { log( "[Reconnect] currentWord accepted after reconnect " + @@ -6199,6 +6268,12 @@ extension BLETestManager: CBPeripheralDelegate { _ = updateLiveActivityForCurrentLyricIfNeeded(reason: "currentWord") } + private func resetCurrentWordFence() { + currentWordFence.reset() + lastCurrentWordLoggedLineIndex = -1 + lastCurrentWordDiagnosticLogAtMs = 0 + } + private func isInReconnectStateSyncWindow() -> Bool { reconnectStateSyncWindowUntilMs > 0 && currentTimeMs() <= reconnectStateSyncWindowUntilMs } @@ -6231,13 +6306,24 @@ extension BLETestManager: CBPeripheralDelegate { return } guard progressTimer == nil else { return } - let timer = Timer(timeInterval: 0.2, repeats: true) { [weak self] _ in + 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 updateInterpolatedProgress() { guard !isSeeking else { return } guard durationMs > 0 else { diff --git a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift index 952e627..abca64f 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift @@ -6,7 +6,6 @@ struct ContentView: View { @ObservedObject private var playbackStateModel: ObservableStateSlice @ObservedObject private var lyricsStateModel: ObservableStateSlice @ObservedObject private var artworkStateModel: ObservableStateSlice - @ObservedObject private var diagnosticsStateModel: ObservableStateSlice @ObservedObject private var preferences = PreferencesStore.shared @State private var showFullLyrics = false @State private var showDebugPage = false @@ -23,7 +22,6 @@ struct ContentView: View { _playbackStateModel = ObservedObject(wrappedValue: manager.playbackStateModel) _lyricsStateModel = ObservedObject(wrappedValue: manager.lyricsStateModel) _artworkStateModel = ObservedObject(wrappedValue: manager.artworkStateModel) - _diagnosticsStateModel = ObservedObject(wrappedValue: manager.diagnosticsStateModel) } var body: some View { diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index 8b876d0..8dd76c5 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -4,6 +4,20 @@ import XCTest @testable import sonyMusic final class PerformanceStabilityTests: XCTestCase { + 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 testA1AndA2DispatchAndOutOfOrderAssembly() { let a1 = packet(magic: 0xA1, kind: 3, index: 0, total: 1, payload: Data([9])) let decodedA1 = BLEBinaryChunkCodec.decode(a1, expectedMagic: 0xA1) 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 74b79c0..794fc0f 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -876,6 +876,7 @@ class BleGattServerManager( "SEEK_TO" -> { cancelHistoryTransfersForControl(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") 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..83c118a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt @@ -17,7 +17,11 @@ 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 expectedGeneration: () -> Long = { CurrentTrackRuntimeCache.currentGeneration() }, + private val currentWordState: () -> CurrentWordState? = { + CurrentTrackRuntimeCache.currentWordState() + }, + private val elapsedRealtime: () -> Long = { SystemClock.elapsedRealtime() } ) { private val lock = Any() private var lastPushedKey: String = "" @@ -30,13 +34,17 @@ class CurrentWordPushEngine( private var lastSkipLogAtMs: Long = 0L private var lastObservedGeneration: Long = -1L private var generationFirstSeenAtMs: Long = 0L + private var lastPushedPositionMs: Long = -1L + private var nextSequence: Long = 0L + private var lastLoggedLineIndex: Int = -1 + @Synchronized fun pushCurrentWord( reason: String = "diff", force: Boolean = false ): CurrentWordState? { - val startedAt = SystemClock.elapsedRealtime() - val state = CurrentTrackRuntimeCache.currentWordState() ?: run { + val startedAt = elapsedRealtime() + val state = currentWordState() ?: run { recordSkip("missing") return null } @@ -49,7 +57,7 @@ class CurrentWordPushEngine( protocolId = outgoingTrackId ) synchronized(lock) { - recordSkipLocked("stale_generation", SystemClock.elapsedRealtime(), state, key) + recordSkipLocked("stale_generation", elapsedRealtime(), state, key) } logger( "[CurrentWordFence] skip reason=stale_generation " + @@ -60,10 +68,14 @@ class CurrentWordPushEngine( } synchronized(lock) { - val now = SystemClock.elapsedRealtime() + val now = elapsedRealtime() if (state.trackGeneration != lastObservedGeneration) { lastObservedGeneration = state.trackGeneration generationFirstSeenAtMs = now + lastPushedKey = "" + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 } val generationAgeMs = now - generationFirstSeenAtMs if (!force && generationAgeMs < TRACK_SWITCH_BASELINE_HOLDOFF_MS) { @@ -86,6 +98,27 @@ class CurrentWordPushEngine( recordSkipLocked("same word", now, state, key) return null } + if (!force && lastPushedPositionMs >= 0L && state.positionMs < lastPushedPositionMs) { + val regressionMs = lastPushedPositionMs - state.positionMs + if (regressionMs <= MAX_JITTER_REGRESSION_MS) { + recordSkipLocked( + "position regression", + now, + state, + key, + extra = " regressionMs=$regressionMs" + ) + return null + } + // A larger backwards jump is a real seek. Start a new local + // timeline while preserving the connection generation. + lastPushedKey = "" + lastPushedPositionMs = -1L + logger( + "[CurrentWordFence] timeline discontinuity " + + "trackId=$outgoingTrackId regressionMs=$regressionMs" + ) + } if (!force && lastPushElapsedMs > 0L && now - lastPushElapsedMs < MIN_CURRENT_WORD_INTERVAL_MS @@ -95,9 +128,15 @@ class CurrentWordPushEngine( } } + val sequence = synchronized(lock) { + nextSequence += 1L + nextSequence + } val payload = JSONObject() .put("type", "currentWord") .put("trackId", outgoingTrackId) + .put("generation", state.trackGeneration) + .put("seq", sequence) .put("line", state.lineIndex) .put("word", state.wordIndex) .put("position", state.positionMs) @@ -107,16 +146,17 @@ class CurrentWordPushEngine( val sent = sendStatusMessage(payload.toString()) synchronized(lock) { if (!sent) { - recordSkipLocked("send failed", SystemClock.elapsedRealtime(), state, key) + recordSkipLocked("send failed", elapsedRealtime(), state, key) return null } - val now = SystemClock.elapsedRealtime() + val now = elapsedRealtime() if (lastPushElapsedMs > 0L) { intervalTotalMs += (now - lastPushElapsedMs).coerceAtLeast(0L) intervalCount += 1 } lastPushElapsedMs = now lastPushedKey = key + lastPushedPositionMs = state.positionMs pushCount += 1 lastPushCostMs = now - startedAt val normalizedSuffix = if (outgoingTrackId != state.trackId) { @@ -124,15 +164,21 @@ class CurrentWordPushEngine( } else { " idMode=canonical" } - logger( - "[CurrentWordPush] push trackId=$outgoingTrackId$normalizedSuffix " + - "generation=${state.trackGeneration} " + - "line=${state.lineIndex} word=${state.wordIndex} " + - "wordText=${state.wordText.take(MAX_LOG_WORD_TEXT)} " + - "wordStartMs=${state.wordStartMs} wordEndMs=${state.wordEndMs} " + - "hasWordTiming=${state.hasWordTiming} positionMs=${state.positionMs} " + - "currentWordKey=$key reason=$reason force=$force costMs=$lastPushCostMs" - ) + if (force || + state.lineIndex != lastLoggedLineIndex || + pushCount % PUSH_LOG_SAMPLE_INTERVAL == 0L + ) { + lastLoggedLineIndex = state.lineIndex + logger( + "[CurrentWordPush] push trackId=$outgoingTrackId$normalizedSuffix " + + "generation=${state.trackGeneration} seq=$sequence " + + "line=${state.lineIndex} word=${state.wordIndex} " + + "wordText=${state.wordText.take(MAX_LOG_WORD_TEXT)} " + + "wordStartMs=${state.wordStartMs} wordEndMs=${state.wordEndMs} " + + "hasWordTiming=${state.hasWordTiming} positionMs=${state.positionMs} " + + "currentWordKey=$key reason=$reason force=$force costMs=$lastPushCostMs" + ) + } TrackCapabilityTracker.onCurrentWordPushed( trackId = state.trackId, protocolId = outgoingTrackId @@ -147,6 +193,16 @@ class CurrentWordPushEngine( lastPushElapsedMs = 0L lastObservedGeneration = -1L generationFirstSeenAtMs = 0L + lastPushedPositionMs = -1L + nextSequence = 0L + lastLoggedLineIndex = -1 + } + } + + fun resetTimeline() { + synchronized(lock) { + lastPushedKey = "" + lastPushedPositionMs = -1L } } @@ -176,7 +232,7 @@ class CurrentWordPushEngine( private fun recordSkip(reason: String) { synchronized(lock) { - recordSkipLocked(reason, SystemClock.elapsedRealtime()) + recordSkipLocked(reason, elapsedRealtime()) } } @@ -214,5 +270,7 @@ class CurrentWordPushEngine( private const val TRACK_SWITCH_BASELINE_HOLDOFF_MS = 450L private const val SKIP_LOG_INTERVAL_MS = 5_000L private const val MAX_LOG_WORD_TEXT = 24 + private const val MAX_JITTER_REGRESSION_MS = 1_500L + private const val PUSH_LOG_SAMPLE_INTERVAL = 10L } } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/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..4c1c69c 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/history/PlaybackHistoryMonitor.kt @@ -34,6 +34,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 +43,7 @@ class PlaybackHistoryMonitor( } running = true updateStatus() - scheduledFuture = executor.scheduleWithFixedDelay( - { safeTick() }, - 0L, - MONITOR_INTERVAL_MS, - TimeUnit.MILLISECONDS - ) + scheduledFuture = executor.schedule({ safeTick() }, 0L, TimeUnit.MILLISECONDS) logger("[History] monitor started") } @@ -71,10 +68,20 @@ class PlaybackHistoryMonitor( } 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) { + scheduledFuture = executor.schedule( + { safeTick() }, + nextTickDelayMs, + TimeUnit.MILLISECONDS + ) + } } } @@ -85,6 +92,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 +109,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 +393,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 +411,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/media/LyricManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt index 701e05a..38a9f3a 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt @@ -83,6 +83,8 @@ class LyricManager( context = appContext, logger = logger ) + @Volatile + private var fuzzyIndexReady: Boolean = qrcLyricManager.fuzzyIndexStatus().ready private val predictiveLyricsPipeline = PredictiveLyricsPipeline( logger = logger, loader = { track -> @@ -116,6 +118,7 @@ class LyricManager( ) 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. @@ -603,6 +606,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 +641,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 +716,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", @@ -1849,6 +1874,10 @@ class LyricManager( @Synchronized fun currentStatusText(): String { + return currentStatusTextLocked() + } + + private fun currentStatusTextLocked(): String { val key = activeSongKey ?: return "unknown" return when { key == loadedSongKey && cachedLines.isNotEmpty() -> "loaded" @@ -2361,6 +2390,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 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..6646dc1 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt @@ -262,9 +262,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( 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..77eff1e 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 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/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/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..11a9a93 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt @@ -0,0 +1,87 @@ +package com.example.playeragent.ble + +import com.example.playeragent.media.CurrentWordState +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CurrentWordPushEngineTest { + @Test + fun blocksSmallPositionRegressionAndAddsOrderingFence() { + var nowMs = 1_000L + var state = word(positionMs = 1_000L, wordIndex = 1) + val payloads = mutableListOf() + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + payloads += JSONObject(it) + true + }, + expectedGeneration = { 7L }, + currentWordState = { state }, + elapsedRealtime = { nowMs } + ) + + // Establish the new-track baseline after its short safety holdoff. + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + state = word(positionMs = 900L, wordIndex = 0) + nowMs += 100L + assertNull(engine.pushCurrentWord()) + + state = word(positionMs = 1_700L, wordIndex = 2) + nowMs += 100L + assertTrue(engine.pushCurrentWord() != null) + + assertEquals(2, payloads.size) + assertEquals(7L, payloads[0].getLong("generation")) + assertEquals(1L, payloads[0].getLong("seq")) + assertEquals(2L, payloads[1].getLong("seq")) + assertEquals(1_700L, payloads[1].getLong("position")) + } + + @Test + fun acceptsLargeBackwardSeekAsNewTimeline() { + var nowMs = 1_000L + var state = word(positionMs = 8_000L, wordIndex = 4) + var sent = 0 + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + sent += 1 + true + }, + expectedGeneration = { 7L }, + currentWordState = { state }, + elapsedRealtime = { nowMs } + ) + + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + state = word(positionMs = 2_000L, wordIndex = 1) + nowMs += 100L + assertTrue(engine.pushCurrentWord() != null) + assertEquals(2, sent) + } + + private fun word(positionMs: Long, wordIndex: Int): CurrentWordState { + return CurrentWordState( + trackId = "track-1", + trackGeneration = 7L, + lineIndex = 0, + wordIndex = wordIndex, + wordText = "word-$wordIndex", + wordStartMs = positionMs, + wordEndMs = positionMs + 400L, + hasWordTiming = true, + positionMs = positionMs, + timestampMs = 10_000L + positionMs + ) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/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/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 + ) + ) + } +} From 5dac153413c155934ebbc03ac3796908349f4218 Mon Sep 17 00:00:00 2001 From: sqz Date: Thu, 30 Jul 2026 23:24:52 +0800 Subject: [PATCH 05/13] fix: harden album art and Sony BLE stability --- .../IOSBleFeasibility/AlbumArtReceiver.swift | 456 +++++++++++++----- .../IOSBleFeasibility/BLETestManager.swift | 15 +- .../IOSBleFeasibility/ContentView.swift | 2 +- .../PerformanceStabilityTests.swift | 249 ++++++++++ .../playeragent/ble/AlbumArtRequestPolicy.kt | 30 ++ .../playeragent/ble/BleGattServerManager.kt | 187 +++---- .../example/playeragent/ble/BleNotifyQueue.kt | 119 +++-- .../ble/BleSubscribedLinkPolicy.kt | 48 ++ .../media/AlbumArtPlaceholderPolicy.kt | 119 +++++ .../playeragent/media/AlbumArtTestManager.kt | 53 +- .../ble/BleGattServerManagerPolicyTest.kt | 96 ++++ .../ble/BleNotifyQueuePolicyTest.kt | 25 + .../media/AlbumArtPlaceholderPolicyTest.kt | 89 ++++ 13 files changed, 1196 insertions(+), 292 deletions(-) create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/AlbumArtRequestPolicy.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtPlaceholderPolicy.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/AlbumArtPlaceholderPolicyTest.kt diff --git a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift index b0447cc..4c2c071 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/AlbumArtReceiver.swift @@ -12,6 +12,7 @@ private let ALBUM_ART_TRANSIENT_SOURCE_TTL_MS: Int64 = 30 * 60 * 1_000 private let ALBUM_ART_STABLE_SOURCE_TTL_MS: Int64 = 24 * 60 * 60 * 1_000 private let ALBUM_ART_HARD_CACHE_TTL_MS: Int64 = 30 * 24 * 60 * 60 * 1_000 private let ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT = 300 +private let ALBUM_ART_PROVISIONAL_REFRESH_DELAYS: [TimeInterval] = [0.05, 2, 5, 12] struct AlbumArtSnapshot { let id: String @@ -84,12 +85,13 @@ private struct AlbumArtCacheValidation { return expired || source == "notificationLargeIcon" || source == "unknown" || + reason == "low_information_requires_refresh" || pixelWidth <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT || pixelHeight <= ALBUM_ART_SMALL_CACHE_PIXEL_LIMIT } var shouldDisplayWhileRefreshing: Bool { - valid + valid && reason != "low_information_requires_refresh" } } @@ -115,6 +117,116 @@ extension UIImage { } } +enum AlbumArtPlaceholderDisposition: Equatable { + case normal + case provisional +} + +enum AlbumArtPlaceholderPolicy { + static func disposition( + image: UIImage, + dataSize: Int + ) -> AlbumArtPlaceholderDisposition { + isLowInformation(image, dataSize: dataSize) || + isKnownQQMusicFallback(image) + ? .provisional + : .normal + } + + static func isLowInformation(_ image: UIImage, dataSize: Int) -> Bool { + guard dataSize < 1_800, let stats = visualStats(image) else { + return false + } + return stats.visiblePixels > 0 && + stats.colorBuckets <= 10 && + stats.colorfulPixels * 20 <= stats.visiblePixels + } + + private static func isKnownQQMusicFallback(_ image: UIImage) -> Bool { + let width = image.pixelWidth + let height = image.pixelHeight + guard (200...240).contains(width), + (200...240).contains(height), + abs(width - height) <= max(width, height) / 20, + let stats = visualStats(image), + stats.visiblePixels > 0 else { + return false + } + // JPEG color conversion differs slightly between simulator and device. + // Keep the dominant-tone guard strict while allowing anti-aliased gray + // edges to span a few neighboring quantized buckets. + return stats.colorBuckets <= 8 && + stats.colorfulPixels * 100 <= stats.visiblePixels && + stats.dominantBucketPixels * 100 >= stats.visiblePixels * 70 + } + + private static func visualStats(_ image: UIImage) -> VisualStats? { + guard let cgImage = image.cgImage else { + return nil + } + let width = 24 + let height = 24 + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + context.interpolationQuality = .low + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + var buckets: [Int: Int] = [:] + var colorfulPixels = 0 + var visiblePixels = 0 + stride(from: 0, to: pixels.count, by: 4).forEach { index in + let red = Int(pixels[index]) + let green = Int(pixels[index + 1]) + let blue = Int(pixels[index + 2]) + let alpha = Int(pixels[index + 3]) + guard alpha >= 24 else { return } + let maximum = max(red, max(green, blue)) + let minimum = min(red, min(green, blue)) + if maximum > 0 && Double(maximum - minimum) / Double(maximum) > 0.12 { + colorfulPixels += 1 + } + let bucket = ((red / 32) << 10) | ((green / 32) << 5) | (blue / 32) + buckets[bucket, default: 0] += 1 + visiblePixels += 1 + } + + return VisualStats( + colorBuckets: buckets.count, + colorfulPixels: colorfulPixels, + visiblePixels: visiblePixels, + dominantBucketPixels: buckets.values.max() ?? 0 + ) + } + + private struct VisualStats { + let colorBuckets: Int + let colorfulPixels: Int + let visiblePixels: Int + let dominantBucketPixels: Int + } +} + +enum AlbumArtSourceRefreshPolicy { + static func needsRefresh( + cacheRequiresRefresh: Bool, + refreshAttempted: Bool, + refreshInFlight: Bool + ) -> Bool { + cacheRequiresRefresh && (!refreshAttempted || refreshInFlight) + } +} + protocol AlbumArtReceiverDelegate: AnyObject { var albumArtCurrentTrackID: String { get } var albumArtCurrentTitle: String { get } @@ -179,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 @@ -206,6 +321,7 @@ 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", @@ -278,8 +394,17 @@ final class AlbumArtReceiver { 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 image = hqValidation.image, hqValidation.valid, @@ -350,14 +475,17 @@ final class AlbumArtReceiver { } cancelAlbumArtFallback() cancelHqAlbumArtRequest() + cancelProvisionalAlbumArtRefresh() albumArtRequestStartTimeouts.values.forEach { $0.cancel() } albumArtRequestStartTimeouts.removeAll() albumArtRequestStartRetryCounts.removeAll() requestedAlbumArtKeys.removeAll() requestedHqAlbumArtIDs.removeAll() + forceRefreshAlbumArtIDs.removeAll() albumArtPreviewRetryCounts.removeAll() albumArtHqRetryCounts.removeAll() sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() authoritativeAlbumArtID = "" deferredAlbumArtOfferWorkItem?.cancel() deferredAlbumArtOfferWorkItem = nil @@ -594,6 +722,9 @@ final class AlbumArtReceiver { requestedAlbumArtKeys.remove("\(id)|\(quality)") if id == currentAlbumArtID, quality == "hq" { + forceRefreshAlbumArtIDs.remove(id) + sourceRefreshInFlightAlbumArtIDs.remove(id) + sourceRefreshAttemptedAlbumArtIDs.remove(id) hqAlbumArtUnavailableReason = reason hqAlbumArtUnavailableBestBytes = bestBytes hqAlbumArtUnavailableBestChunks = bestChunks @@ -621,11 +752,15 @@ final class AlbumArtReceiver { } } log("[AlbumArt] unavailable id=\(id) quality=\(quality) reason=\(reason)") + if id == currentAlbumArtID, + provisionalRefreshAttempts[id] != nil { + scheduleProvisionalAlbumArtRefresh(id: id) + } notifyStateChanged() } @discardableResult - func requestCurrentPreviewAlbumArt() -> Bool { + func requestCurrentPreviewAlbumArt(forceRefresh: Bool = false) -> Bool { let id = currentAlbumArtID guard !id.isEmpty else { log("[AlbumArt] preview request skipped reason=no artwork id") @@ -639,12 +774,12 @@ final class AlbumArtReceiver { ) return false } - requestAlbumArt(id: id, quality: "preview") + requestAlbumArt(id: id, quality: "preview", forceRefresh: forceRefresh) return true } @discardableResult - func requestCurrentHqAlbumArt() -> Bool { + func requestCurrentHqAlbumArt(forceRefresh: Bool = false) -> Bool { let id = currentAlbumArtID guard !id.isEmpty else { log("[NowDiag] hq artwork request skipped reason=no artwork id") @@ -680,7 +815,7 @@ final class AlbumArtReceiver { requestedHqAlbumArtIDs.remove(id) requestedAlbumArtKeys.remove("\(id)|hq") log("[NowDiag] request HQ artwork id=\(id)") - requestHqAlbumArt(id: id) + requestHqAlbumArt(id: id, forceRefresh: forceRefresh) return true } @@ -692,8 +827,6 @@ final class AlbumArtReceiver { return false } log("[AlbumArtRefresh] force refresh id=\(id)") - removeCachedAlbumArt(id: id, quality: "preview", reason: "force refresh") - removeCachedAlbumArt(id: id, quality: "hq", reason: "force refresh") ArtworkEnhancementManager.shared.cancel(artworkId: id) ArtworkEnhancementManager.shared.clearCachedArtwork(artworkId: id) { [weak self] in self?.log("[ArtworkEnhance] cache removed id=\(id) reason=force refresh") @@ -701,20 +834,26 @@ final class AlbumArtReceiver { requestedAlbumArtKeys.remove("\(id)|preview") requestedAlbumArtKeys.remove("\(id)|hq") requestedHqAlbumArtIDs.remove(id) + forceRefreshAlbumArtIDs.insert(id) + cancelProvisionalAlbumArtRefresh(clearAttemptsFor: id) + albumArtRequestStartRetryCounts.removeValue(forKey: "\(id)|preview") + albumArtRequestStartRetryCounts.removeValue(forKey: "\(id)|hq") cancelHqAlbumArtRequest() cancelAlbumArtFallback() - if artworkDisplayQuality != .placeholder { - artworkDisplayQuality = .placeholder - albumArtImage = nil - displayedAlbumArtID = "" - currentCachedAlbumArtQuality = "" - updateArtworkEnhancementStatus(message: "force refresh") - delegate?.albumArtClearLiveArtwork(reason: "forceRefresh", shouldUpdate: false) - notifyStateChanged() - } - let requested = requestCurrentPreviewAlbumArt() + updateArtworkEnhancementStatus(message: "force refresh pending") + log( + "[AlbumArtRefresh] retain current artwork while refreshing " + + "quality=\(artworkDisplayQuality.label)" + ) + notifyStateChanged() + let requested = requestCurrentPreviewAlbumArt(forceRefresh: true) if requested { - schedulePredictiveHqPrefetch(id: id, delay: 2.2, reason: "force refresh") + schedulePredictiveHqPrefetch( + id: id, + delay: 2.2, + reason: "force refresh", + forceRefresh: true + ) } return requested } @@ -806,7 +945,11 @@ final class AlbumArtReceiver { binaryAlbumArtRetryCounts.removeAll() requestedAlbumArtKeys.removeAll() requestedHqAlbumArtIDs.removeAll() + forceRefreshAlbumArtIDs.removeAll() + sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() cancelHqAlbumArtRequest() + cancelProvisionalAlbumArtRefresh() predictivePendingHqId = "" notifyStateChanged() } @@ -1042,36 +1185,40 @@ final class AlbumArtReceiver { image: UIImage, source: String ) { - if isLikelyPlaceholderAlbumArt(image, dataSize: data.count) { + let disposition = AlbumArtPlaceholderPolicy.disposition( + image: image, + dataSize: data.count + ) + if disposition == .provisional { let prefix = source == "binary" ? "[AlbumArtBinary]" : "[AlbumArt]" - let message = "\(prefix) placeholder ignored id=\(id)" + let message = + "\(prefix) QQ fallback artwork rejected as terminal " + + "id=\(id) quality=\(quality)" log(message) if source == "binary" { consoleLog(message) } - if id == currentAlbumArtID, quality == "preview" { - cancelAlbumArtFallback() - if displayedAlbumArtID != id { - albumArtImage = nil - displayedAlbumArtID = "" - currentCachedAlbumArtQuality = "" - artworkDisplayQuality = .placeholder - updateArtworkEnhancementStatus(message: "placeholder") - delegate?.albumArtClearLiveArtwork(reason: "placeholder", shouldUpdate: false) - delegate?.albumArtUpdateLiveActivity(force: true, reason: "albumArt") - notifyStateChanged() - } else { - log("[AlbumArtCache] retain stale image after placeholder refresh id=\(id)") - } - } requestedAlbumArtKeys.remove("\(id)|\(quality)") - if source == "binary" { - resetBinaryAlbumArtTransfer(reason: "complete placeholder") + if quality == "hq" || quality == "full" { + requestedHqAlbumArtIDs.remove(id) + } + removeCachedAlbumArt( + id: id, + quality: quality == "full" ? nil : quality, + reason: "qq fallback placeholder" + ) + if id == currentAlbumArtID { + updateArtworkEnhancementStatus(message: "waiting for real artwork") + scheduleProvisionalAlbumArtRefresh(id: id) } + notifyStateChanged() return } + cancelProvisionalAlbumArtRefresh(clearAttemptsFor: id) if quality == "hq", !shouldAcceptHqAlbumArt(image: image, data: data, id: id) { + forceRefreshAlbumArtIDs.remove(id) + sourceRefreshInFlightAlbumArtIDs.remove(id) requestedAlbumArtKeys.remove("\(id)|\(quality)") if source == "binary" { resetBinaryAlbumArtTransfer(reason: "complete hq no visual upgrade") @@ -1096,8 +1243,15 @@ final class AlbumArtReceiver { log("[AlbumArt] decode success imageSize=\(image.pixelWidth)x\(image.pixelHeight)") albumArtPreviewRetryCount = 0 if quality == "preview" { - schedulePredictiveHqPrefetch(id: id, delay: 0.05, reason: "preview complete") + schedulePredictiveHqPrefetch( + id: id, + delay: 0.05, + reason: "preview complete", + forceRefresh: forceRefreshAlbumArtIDs.contains(id) + ) } else if quality == "hq" || quality == "full" { + forceRefreshAlbumArtIDs.remove(id) + sourceRefreshInFlightAlbumArtIDs.remove(id) recordPredictiveHqReady(id: id) } notifyStateChanged() @@ -1111,6 +1265,8 @@ final class AlbumArtReceiver { cancelPredictiveHqPrefetch(reason: "track changed", oldId: currentAlbumArtID) } currentAlbumArtID = id + forceRefreshAlbumArtIDs.removeAll() + cancelProvisionalAlbumArtRefresh() albumArtDecodeToken = nil albumArtCacheLookupToken = nil currentCachedAlbumArtQuality = "" @@ -1129,6 +1285,7 @@ final class AlbumArtReceiver { albumArtHqRetryCounts.removeAll() binaryAlbumArtRetryCounts.removeAll() sourceRefreshAttemptedAlbumArtIDs.removeAll() + sourceRefreshInFlightAlbumArtIDs.removeAll() resetAlbumArtTransfer() resetBinaryAlbumArtTransfer(reason: "track changed") cancelHqAlbumArtRequest() @@ -1415,8 +1572,19 @@ final class AlbumArtReceiver { count > 0 ? total / count : 0 } - private func schedulePredictiveHqPrefetch(id: String, delay: TimeInterval, reason: String) { - scheduleHqAlbumArtRequest(id: id, delay: delay, reason: reason, predictive: true) + private func schedulePredictiveHqPrefetch( + id: String, + delay: TimeInterval, + reason: String, + forceRefresh: Bool = false + ) { + scheduleHqAlbumArtRequest( + id: id, + delay: delay, + reason: reason, + predictive: true, + forceRefresh: forceRefresh + ) } private enum PredictiveHqSkipCategory { @@ -1528,10 +1696,11 @@ final class AlbumArtReceiver { id: String, delay: TimeInterval = 1.8, reason: String = "standard", - predictive: Bool = false + predictive: Bool = false, + forceRefresh: Bool = false ) { guard id == currentAlbumArtID else { return } - guard artworkDisplayQuality < .hq else { + guard forceRefresh || artworkDisplayQuality < .hq else { if predictive { recordPredictiveHqSkip(id: id, reason: "cache hit", category: .cacheHit) } @@ -1555,7 +1724,12 @@ final class AlbumArtReceiver { 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))") @@ -1579,9 +1753,56 @@ final class AlbumArtReceiver { hqAlbumArtWorkItem = nil } - private func requestHqAlbumArt(id: String, reason: String = "standard", predictive: Bool = false) { + private func scheduleProvisionalAlbumArtRefresh(id: String) { guard id == currentAlbumArtID else { return } - 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) } @@ -1609,7 +1830,13 @@ final class AlbumArtReceiver { if predictive { recordPredictiveHqSkip(id: id, reason: "in flight", category: .inFlight) } - scheduleHqAlbumArtRequest(id: id, delay: 1.0, reason: "busy retry", predictive: predictive) + scheduleHqAlbumArtRequest( + id: id, + delay: 1.0, + reason: "busy retry", + predictive: predictive, + forceRefresh: forceRefresh + ) return } guard requestedHqAlbumArtIDs.insert(id).inserted else { @@ -1624,10 +1851,14 @@ final class AlbumArtReceiver { recordPredictiveHqRequest(id: id) log("[PredictiveAlbumArt] request hq id=\(id) reason=\(reason)") } - requestAlbumArt(id: id, quality: "hq") + requestAlbumArt(id: id, quality: "hq", forceRefresh: forceRefresh) } - private func requestAlbumArt(id: String, quality: String) { + private func requestAlbumArt( + id: String, + quality: String, + forceRefresh: Bool = false + ) { guard id == currentAlbumArtID else { return } cancelStaleBinaryAlbumArtTransferIfNeeded(reason: "request found stale transfer") if let session = binaryAlbumArtSession { @@ -1643,12 +1874,24 @@ final class AlbumArtReceiver { } let key = "\(id)|\(quality)" guard requestedAlbumArtKeys.insert(key).inserted else { return } - log("[AlbumArt] request \(quality)") - delegate?.albumArtSendCommand(cmd: "ALBUM_ART_REQUEST", extra: ["id": id, "quality": quality]) - scheduleAlbumArtRequestStartTimeout(id: id, quality: quality) + log("[AlbumArt] request \(quality) forceRefresh=\(forceRefresh)") + var extra: [String: Any] = ["id": id, "quality": quality] + if forceRefresh { + extra["forceRefresh"] = true + } + delegate?.albumArtSendCommand(cmd: "ALBUM_ART_REQUEST", extra: extra) + scheduleAlbumArtRequestStartTimeout( + id: id, + quality: quality, + forceRefresh: forceRefresh + ) } - private func scheduleAlbumArtRequestStartTimeout(id: String, quality: String) { + private func scheduleAlbumArtRequestStartTimeout( + id: String, + quality: String, + forceRefresh: Bool = false + ) { let key = "\(id)|\(quality)" albumArtRequestStartTimeouts[key]?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -1667,13 +1910,22 @@ final class AlbumArtReceiver { let attempts = (self.albumArtRequestStartRetryCounts[key] ?? 0) + 1 self.albumArtRequestStartRetryCounts[key] = attempts guard attempts <= ALBUM_ART_REQUEST_START_MAX_RETRIES else { + if quality == "hq" { + self.forceRefreshAlbumArtIDs.remove(id) + self.sourceRefreshInFlightAlbumArtIDs.remove(id) + self.sourceRefreshAttemptedAlbumArtIDs.remove(id) + } self.log("[AlbumArt] request start timeout id=\(id) quality=\(quality), giving up") return } self.log("[AlbumArt] request start timeout id=\(id) quality=\(quality), retry=\(attempts)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self, self.currentAlbumArtID == id else { return } - self.requestAlbumArt(id: id, quality: quality) + self.requestAlbumArt( + id: id, + quality: quality, + forceRefresh: forceRefresh + ) } } albumArtRequestStartTimeouts[key] = workItem @@ -2158,12 +2410,15 @@ final class AlbumArtReceiver { } else if !decoded || size <= 0 { reason = "decode_failed" valid = false - } else if let image, isLikelyPlaceholderAlbumArt(image, dataSize: size) { - reason = "placeholder" - valid = false } else if hardExpired { reason = "hard_ttl_expired" valid = false + } else if let image, + AlbumArtPlaceholderPolicy.disposition( + image: image, + dataSize: size + ) == .provisional { + reason = "low_information_requires_refresh" } else if expired { reason = "source_ttl_expired" } else if source == "notificationLargeIcon" || source == "unknown" { @@ -2200,23 +2455,6 @@ final class AlbumArtReceiver { reason: reason ) } - if isLikelyPlaceholderAlbumArt(image, dataSize: size) { - removePlaceholderAlbumArt(id: id, quality: preferredQuality) - return AlbumArtCacheValidation( - image: nil, - quality: quality, - source: source, - ageMs: ageMs, - ttlMs: ttlMs, - pixelWidth: pixelWidth, - pixelHeight: pixelHeight, - bytes: size, - valid: false, - expired: expired, - reason: reason - ) - } - if expired { let message = "[AlbumArtCache] expired id=\(id) source=\(source) " + @@ -2247,9 +2485,17 @@ 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 image = selected.image, selected.valid { + 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 : ArtworkDisplayQuality.hq @@ -2271,7 +2517,9 @@ final class AlbumArtReceiver { ) } } - updateArtworkEnhancementStatus(message: "refreshing cache") + if !rejectedFallback { + updateArtworkEnhancementStatus(message: "refreshing cache") + } let staleReason = selected.expired ? "source_ttl_expired" : reason let staleMessage = "[AlbumArtCache] stale id=\(id) reason=\(staleReason)" log(staleMessage) @@ -2291,8 +2539,13 @@ final class AlbumArtReceiver { "id=\(id) source=\(selected.source) cacheReason=\(reason)" log(message) consoleLog(message) - requestAlbumArt(id: id, quality: "preview") - schedulePredictiveHqPrefetch(id: id, delay: 2.2, reason: "stale cache") + requestAlbumArt(id: id, quality: "preview", forceRefresh: true) + schedulePredictiveHqPrefetch( + id: id, + delay: 2.2, + reason: "stale cache", + forceRefresh: true + ) notifyStateChanged() } @@ -2300,10 +2553,6 @@ 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) @@ -2318,46 +2567,7 @@ final class AlbumArtReceiver { } private func isLikelyPlaceholderAlbumArt(_ image: UIImage, dataSize: Int) -> Bool { - guard dataSize < 1_800, let cgImage = image.cgImage else { - return false - } - let width = 24 - let height = 24 - var pixels = [UInt8](repeating: 0, count: width * height * 4) - let colorSpace = CGColorSpaceCreateDeviceRGB() - guard let context = CGContext( - data: &pixels, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return false - } - context.interpolationQuality = .low - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) - - var buckets = Set() - var colorfulPixels = 0 - var visiblePixels = 0 - stride(from: 0, to: pixels.count, by: 4).forEach { index in - let red = Int(pixels[index]) - let green = Int(pixels[index + 1]) - let blue = Int(pixels[index + 2]) - let alpha = Int(pixels[index + 3]) - guard alpha >= 24 else { return } - let maximum = max(red, max(green, blue)) - let minimum = min(red, min(green, blue)) - if maximum > 0 && Double(maximum - minimum) / Double(maximum) > 0.12 { - colorfulPixels += 1 - } - buckets.insert(((red / 32) << 10) | ((green / 32) << 5) | (blue / 32)) - visiblePixels += 1 - } - - return visiblePixels > 0 && buckets.count <= 10 && colorfulPixels * 20 <= visiblePixels + AlbumArtPlaceholderPolicy.isLowInformation(image, dataSize: dataSize) } private func shouldAcceptHqAlbumArt(image: UIImage, data: Data, id: String) -> Bool { diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index 9da0966..ec51221 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -975,7 +975,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( @@ -984,8 +987,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)" + ) } } } @@ -1377,8 +1382,8 @@ final class BLETestManager: NSObject, ObservableObject { } @discardableResult - func requestCurrentHqAlbumArt() -> Bool { - albumArtReceiver.requestCurrentHqAlbumArt() + func requestCurrentHqAlbumArt(forceRefresh: Bool = false) -> Bool { + albumArtReceiver.requestCurrentHqAlbumArt(forceRefresh: forceRefresh) } @discardableResult diff --git a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift index abca64f..70331e7 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift @@ -145,7 +145,7 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { - if bleManager.title != "-" { + if isDebugMode, bleManager.title != "-" { mediaLoadingOverlay .padding(.top, 48) } diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index 8dd76c5..08429e2 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -134,6 +134,219 @@ final class PerformanceStabilityTests: XCTestCase { ) } + func testQQFallbackIsRejectedAndForceRefreshRetainsRealArtwork() throws { + let delegate = AlbumArtReceiverTestDelegate() + let receiver = AlbumArtReceiver(delegate: delegate) + let artworkID = "low-information-\(UUID().uuidString)" + delegate.trackID = artworkID + receiver.handleIdentity(id: artworkID) + + 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") + wait(for: [rejected], timeout: 2) + 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") + wait(for: [displayed], timeout: 2) + + 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() 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") + wait(for: [cacheSaved], timeout: 2) + + 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) + wait(for: [forcedPreview], timeout: 2) + + 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) + wait(for: [secondOfferHeld], timeout: 2) + XCTAssertFalse( + delegate.commands.contains { + $0.command == "ALBUM_ART_SKIP" && + ($0.extra["quality"] as? String) == "hq" + } + ) + } + func testObservableSliceDeduplicatesAndPlaybackClockPauses() { let slice = ObservableStateSlice(BLEConnectionViewState()) var emissions = 0 @@ -199,3 +412,39 @@ final class PerformanceStabilityTests: XCTestCase { return result } } + +private final class AlbumArtReceiverTestDelegate: AlbumArtReceiverDelegate { + var trackID = "" + var commands: [(command: String, extra: [String: Any])] = [] + var onCommand: ((String, [String: Any]) -> 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/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/BleGattServerManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt index 794fc0f..18ba3cd 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -13,6 +13,7 @@ import android.bluetooth.BluetoothProfile import android.content.Context 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 +21,7 @@ import com.example.playeragent.history.HistorySessionRow import com.example.playeragent.history.PlaybackHistoryRepository import com.example.playeragent.history.PlaybackStatsSummary import com.example.playeragent.history.StatsRange +import com.example.playeragent.media.AlbumArtPlaceholderPolicy import com.example.playeragent.media.AlbumArtTestManager import com.example.playeragent.logging.LogConfig import com.example.playeragent.logging.LogBuffer @@ -96,7 +98,12 @@ class BleGattServerManager( private val commandExecutor = mediaExecutionContext.realtime private val lyricCommandExecutor = mediaExecutionContext.foregroundIO private val albumArtFastPathExecutor = mediaExecutionContext.foregroundIO - private val albumArtHandler = Handler(Looper.getMainLooper()) + 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 { @@ -139,7 +146,7 @@ class BleGattServerManager( @Volatile private var currentAlbumArtId: String? = null private val albumArtRequestsInFlight = ConcurrentHashMap.newKeySet() - private val albumArtRequestsCompleted = ConcurrentHashMap.newKeySet() + private val albumArtRequestCompletedAtMs = ConcurrentHashMap() @Volatile private var pendingAlbumArt: PendingAlbumArt? = null @Volatile @@ -153,6 +160,7 @@ class BleGattServerManager( private val albumArtPendingRequests = ConcurrentHashMap() private val albumArtSourceRetryCounts = ConcurrentHashMap() + private val albumArtUnavailableProtocolIds = ConcurrentHashMap.newKeySet() private val albumArtCache = LinkedHashMap(ALBUM_ART_CACHE_CAPACITY * 2, 0.75f, true) private val encodedAlbumArtCache = @@ -253,8 +261,9 @@ class BleGattServerManager( lastAlbumArtKey = null currentAlbumArtId = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() albumArtPendingRequests.clear() + albumArtUnavailableProtocolIds.clear() pendingAlbumArt = null resetClientCapabilities() lastAutoPushSongKey = null @@ -460,7 +469,8 @@ class BleGattServerManager( lastAlbumArtKey = null currentAlbumArtId = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() + albumArtUnavailableProtocolIds.clear() pendingAlbumArt = null resetClientCapabilities() lastAutoPushSongKey = null @@ -626,7 +636,7 @@ class BleGattServerManager( lastAlbumArtKey = null currentAlbumArtId = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() pendingAlbumArt = null lyricsTransferCoordinator.reset() albumArtTransferCoordinator.reset() @@ -636,7 +646,9 @@ class BleGattServerManager( albumArtFastPathProtocolId = null albumArtFastPathCompletionPending = false albumArtPendingRequests.clear() + albumArtUnavailableProtocolIds.clear() albumArtHandler.removeCallbacksAndMessages(null) + mediaCallbackThread.quitSafely() gattLifecycleHandler.removeCallbacksAndMessages(null) PlayerNotificationListenerService.removeQqMusicArtworkListener(qqMusicArtworkListener) lastAutoPushSongKey = null @@ -761,16 +773,25 @@ class BleGattServerManager( } fun hasSuccessHeartbeatOlderThan(ageMs: Long): Boolean { - return isSuccessHeartbeatStale(SystemClock.elapsedRealtime(), ageMs) + return BleSubscribedLinkPolicy.isActivityStale( + subscribed = subscribedDevices.isNotEmpty(), + subscribedAtMs = lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtMs, + lastNotifySuccessAtMs = lastNotifySuccessAtMs, + nowMs = SystemClock.elapsedRealtime(), + maxAgeMs = ageMs + ) } fun subscribedWithoutSuccessOlderThan(ageMs: Long): Boolean { - val subscribedAt = lastSubscribedAtMs - return subscribedDevices.isNotEmpty() && - hasOutboundWork() && - lastSuccessHeartbeatAtMs() == 0L && - subscribedAt > 0L && - SystemClock.elapsedRealtime() - subscribedAt > ageMs + return BleSubscribedLinkPolicy.isWithoutSuccessStale( + subscribed = subscribedDevices.isNotEmpty(), + subscribedAtMs = lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtMs, + lastNotifySuccessAtMs = lastNotifySuccessAtMs, + nowMs = SystemClock.elapsedRealtime(), + maxAgeMs = ageMs + ) } fun notifyFailureCount(): Int = notifyFailureCount @@ -815,16 +836,14 @@ class BleGattServerManager( } 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 + return BleSubscribedLinkPolicy.isActivityStale( + subscribed = subscribedDevices.isNotEmpty(), + subscribedAtMs = lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtMs, + lastNotifySuccessAtMs = lastNotifySuccessAtMs, + nowMs = nowMs, + maxAgeMs = maxAgeMs + ) } private fun logDisconnectDiagnostics(address: String) { @@ -1787,6 +1806,7 @@ class BleGattServerManager( currentAlbumArtId = protocolId currentAlbumArtPlaybackState = JSONObject(playbackState.toString()) albumArtSourceRetryCounts.clear() + albumArtUnavailableProtocolIds.clear() CurrentTrackRuntimeCache.updateAlbumArt( trackId = protocolId, albumArtId = protocolId, @@ -1863,9 +1883,10 @@ class BleGattServerManager( currentAlbumArtPlaybackState = null pendingAlbumArt = null albumArtRequestsInFlight.clear() - albumArtRequestsCompleted.clear() + albumArtRequestCompletedAtMs.clear() albumArtPendingRequests.clear() albumArtSourceRetryCounts.clear() + albumArtUnavailableProtocolIds.clear() notifyQueue.cancelJobTypes( setOf(ALBUM_ART_JOB_TYPE), "no active QQ track" @@ -1915,7 +1936,7 @@ class BleGattServerManager( albumArtRequestsInFlight.removeIf { !it.startsWith("$protocolId|") } - albumArtRequestsCompleted.removeIf { + albumArtRequestCompletedAtMs.keys.removeIf { !it.startsWith("$protocolId|") } @@ -2118,7 +2139,7 @@ class BleGattServerManager( } return@synchronized } - if (isLikelyPlaceholderAlbumArt(albumArt.bitmap)) { + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(albumArt.bitmap)) { reactiveMediaController.markAlbumArtFinished( trackId = pending.protocolId, generation = mediaGeneration, @@ -2150,6 +2171,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, @@ -2193,6 +2219,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. @@ -2214,6 +2251,15 @@ 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 @@ -2308,6 +2354,7 @@ class BleGattServerManager( } private fun failPendingAlbumArtRequests(protocolId: String, reason: String) { + albumArtUnavailableProtocolIds.add(protocolId) val pending = albumArtPendingRequests.values .filter { it.protocolId == protocolId } .toList() @@ -2362,16 +2409,30 @@ class BleGattServerManager( } } val requestKey = "$protocolId|${quality.wireValue}" - if (albumArtRequestsCompleted.contains(requestKey)) { - logger("[AlbumArt] skip duplicate sending id=$protocolId") + 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" ) TrackCapabilityTracker.onAlbumArtRequested(protocolId) if (quality == AlbumArtQuality.HQ) { @@ -2545,7 +2606,7 @@ class BleGattServerManager( }, onComplete = { albumArtRequestsInFlight.remove(requestKey) - albumArtRequestsCompleted.add(requestKey) + albumArtRequestCompletedAtMs[requestKey] = SystemClock.elapsedRealtime() val costMs = SystemClock.elapsedRealtime() - startAt if (quality == AlbumArtQuality.HQ) { logger("[AlbumArtHQ] send end costMs=$costMs") @@ -2850,6 +2911,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, @@ -3291,57 +3367,6 @@ 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) 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 03c30b0..a07d7fd 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt @@ -16,6 +16,22 @@ import java.util.ArrayDeque import java.util.LinkedHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal class BleQueueTerminalCallbackGate { + private val dispatched = AtomicBoolean(false) + + fun dispatch( + post: ((() -> Unit) -> Unit), + callback: (() -> Unit)? + ): Boolean { + if (!dispatched.compareAndSet(false, true)) { + return false + } + callback?.let(post) + return true + } +} class BleNotifyQueue( private val serverProvider: () -> BluetoothGattServer?, @@ -193,9 +209,16 @@ class BleNotifyQueue( private fun cancelJobTypesOnQueue(types: Set, reason: String) { val removedJobs = jobs.filter { it.type in types } jobs.removeAll { it.type in types } - removedJobs.forEach { failJob(it, reason) } val current = activeJob - if (current != null && current.type in types) { + val activeJobRemoved = current != null && current.type in types + if (removedJobs.isNotEmpty() || activeJobRemoved) { + // Cancel stale packet/timeout work before terminal callbacks are + // posted. Removing callbacks afterwards would silently discard + // the deferred cleanup which releases manager transfer state. + handler.removeCallbacksAndMessages(null) + } + removedJobs.forEach { failJob(it, reason) } + if (activeJobRemoved && current != null) { failJob(current, reason) activeJob = null activePacketIndex = 0 @@ -204,12 +227,11 @@ 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() } } } } @@ -248,12 +270,12 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null if (status == BluetoothGatt.GATT_SUCCESS) { - onNotifySuccess(type) + dispatchNotifySuccess(type) recordAdaptiveSuccess(type, callbackAddress, callbackRttMs) } else { recordAdaptiveFailure(type, callbackAddress) logger("[BleNotifyQueue] interleaved notify failed type=$type status=$status") - onNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(type, status, "callback_failed") } handler.postDelayed({ sendNextPacket() }, interleavedDelayAfterMs) interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -267,11 +289,11 @@ class BleNotifyQueue( "[BleNotifyQueue] notify failed " + "type=$type status=$status" ) - onNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(type, status, "callback_failed") abortActiveJob(job, "notify callback failed type=$type status=$status") return } else { - onNotifySuccess(packet?.type ?: job.type) + dispatchNotifySuccess(packet?.type ?: job.type) packet?.let { recordAdaptiveSuccess(it.type, callbackAddress, callbackRttMs) } @@ -300,17 +322,19 @@ class BleNotifyQueue( interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS } + if (removedJobs.isNotEmpty() || activeDeviceRemoved) { + handler.removeCallbacksAndMessages(null) + } 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() } } } @@ -446,12 +470,11 @@ class BleNotifyQueue( job.startedAtMs = activeJobStartedAtMs } if (job.shouldCancel?.invoke() == true) { - markJobFailed(job, "cancelled") - job.onFailure?.invoke() + failJob(job, "cancelled") activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } val maxSendDurationMs = job.maxSendDurationMs @@ -463,12 +486,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) @@ -502,7 +524,7 @@ class BleNotifyQueue( interleaved.packet.value ) if (!requested) { - onNotifyFailure( + dispatchNotifyFailure( interleaved.packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -529,7 +551,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( @@ -545,13 +567,18 @@ class BleNotifyQueue( logger("[TrackInfo] send end") else -> logger("[BleNotifyQueue] job end type=${job.type}") } - job.onComplete?.invoke() } } activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + if (job.isLongJob && !job.failed) { + dispatchJobComplete(job) + } + // Terminal callbacks can enter BleGattServerManager. Queue the + // next drain behind them so no callback runs while this monitor + // is held and callback ordering remains deterministic. + handler.post { sendNextPacket() } return } @@ -564,7 +591,7 @@ class BleNotifyQueue( activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L - sendNextPacket() + handler.post { sendNextPacket() } return } @@ -614,7 +641,7 @@ class BleNotifyQueue( clearActiveNotifyMetrics() recordAdaptiveFailure(packet.type, job.device.address) logger("[BleNotifyQueue] notify request rejected type=${packet.type}") - onNotifyFailure( + dispatchNotifyFailure( packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -626,8 +653,7 @@ class BleNotifyQueue( } private fun abortActiveJob(job: SendJob, reason: String) { - markJobFailed(job, reason) - job.onFailure?.invoke() + failJob(job, reason) activeJob = null activePacketIndex = 0 activeJobStartedAtMs = 0L @@ -648,10 +674,13 @@ class BleNotifyQueue( logger("[BleNotifyQueue] notify callback timeout type=$type job=${job?.type}") val address = activeNotifyDeviceAddress ?: job?.device?.address.orEmpty() recordAdaptiveFailure(type, address) - onNotifyFailure(type, NOTIFY_CALLBACK_TIMEOUT_STATUS, "callback_timeout") + dispatchNotifyFailure( + 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 @@ -780,7 +809,35 @@ class BleNotifyQueue( private fun failJob(job: SendJob, reason: String) { markJobFailed(job, reason) - job.onFailure?.invoke() + dispatchJobFailure(job) + } + + /** + * Queue methods use the BleNotifyQueue monitor to serialize state reads + * from diagnostic and media threads. Never call a manager callback inline + * while that monitor is held: BleGattServerManager may hold its own monitor + * while querying this queue, which otherwise creates an AB/BA deadlock. + */ + private fun dispatchJobComplete(job: SendJob) { + job.terminalCallbackGate.dispatch( + post = { callback -> handler.post(callback) }, + callback = job.onComplete + ) + } + + private fun dispatchJobFailure(job: SendJob) { + job.terminalCallbackGate.dispatch( + post = { callback -> handler.post(callback) }, + callback = job.onFailure + ) + } + + private fun dispatchNotifySuccess(type: String) { + handler.post { onNotifySuccess(type) } + } + + private fun dispatchNotifyFailure(type: String, status: Int, reason: String) { + handler.post { onNotifyFailure(type, status, reason) } } private fun pollNextJob(): SendJob? { @@ -991,7 +1048,9 @@ class BleNotifyQueue( var failureLogged: Boolean = false, var nextPacketIndex: Int = 0, var startedAtMs: Long = 0L, - var packetsSinceYield: Int = 0 + var packetsSinceYield: Int = 0, + val terminalCallbackGate: BleQueueTerminalCallbackGate = + BleQueueTerminalCallbackGate() ) { val chunkCount: Int get() = packets.count { 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..35a3296 --- /dev/null +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt @@ -0,0 +1,48 @@ +package com.example.playeragent.ble + +/** + * Detects a subscribed GATT link that has stopped producing any observable + * activity. This must not depend on queued outbound work: some vendor stacks + * miss the disconnect callback and otherwise leave an idle ghost subscriber + * blocking advertising indefinitely. + */ +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 + } +} 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 ea57e8d..9282c1c 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/AlbumArtTestManager.kt @@ -64,7 +64,7 @@ class AlbumArtTestManager( } val valid = candidates.filter { candidate -> - if (isLikelyPlaceholder(candidate.bitmap)) { + if (AlbumArtPlaceholderPolicy.isLikelyPlaceholder(candidate.bitmap)) { logger("[AlbumArtSource] rejected placeholder source=${candidate.source}") false } else { @@ -425,57 +425,6 @@ class AlbumArtTestManager( return bitmap } - private fun isLikelyPlaceholder(bitmap: Bitmap): Boolean { - if (bitmap.width <= 0 || bitmap.height <= 0) return true - val sampleWidth = minOf(24, bitmap.width) - val sampleHeight = minOf(24, bitmap.height) - val sampled = if (sampleWidth == bitmap.width && - sampleHeight == bitmap.height - ) { - bitmap - } else { - Bitmap.createScaledBitmap(bitmap, sampleWidth, sampleHeight, true) - } - return try { - val pixels = IntArray(sampleWidth * sampleHeight) - sampled.getPixels( - pixels, - 0, - sampleWidth, - 0, - 0, - sampleWidth, - sampleHeight - ) - val colorBuckets = HashSet() - var colorfulPixels = 0 - var visiblePixels = 0 - pixels.forEach { pixel -> - val alpha = pixel ushr 24 - if (alpha < 24) return@forEach - val red = (pixel ushr 16) and 0xff - val green = (pixel ushr 8) and 0xff - val blue = pixel and 0xff - val maximum = maxOf(red, green, blue) - val minimum = minOf(red, green, blue) - if (maximum > 0 && - (maximum - minimum).toFloat() / maximum.toFloat() > 0.12f - ) { - colorfulPixels += 1 - } - colorBuckets.add( - ((red / 32) shl 10) or - ((green / 32) shl 5) or - (blue / 32) - ) - visiblePixels += 1 - } - visiblePixels == 0 || (bitmap.width <= 16 && bitmap.height <= 16) - } finally { - if (sampled !== bitmap) sampled.recycle() - } - } - private data class AlbumArtCandidate( val source: String, val bitmap: Bitmap, diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt index 4db806b..1d8c955 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt @@ -1,6 +1,8 @@ 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 { @@ -10,4 +12,98 @@ class BleGattServerManagerPolicyTest { 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 subscribedLinkStalenessDoesNotDependOnQueuedOutboundWork() { + 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 + ) + ) + } } 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 db4535f..c242954 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt @@ -93,4 +93,29 @@ class BleNotifyQueuePolicyTest { 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/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) + } +} From 4b5f5dd902ac4a560f0f9baf9e96c89276553cb3 Mon Sep 17 00:00:00 2001 From: sqz Date: Fri, 31 Jul 2026 06:57:16 +0800 Subject: [PATCH 06/13] feat: rebuild Android controller with Sony BLE V2 --- .github/workflows/build.yml | 6 + .gitignore | 2 + ControllerApp/build.gradle | 56 +- .../controllerapp/ui/ControllerUiSmokeTest.kt | 66 + ControllerApp/src/debug/AndroidManifest.xml | 14 + .../debug/DebugCommandReceiver.kt | 42 + ControllerApp/src/main/AndroidManifest.xml | 25 +- .../controllerapp/ControllerApplication.kt | 44 + .../controllerapp/ControllerCommandGateway.kt | 46 + .../controllerapp/ControllerRepository.kt | 1834 +++++++++++++++++ .../controllerapp/ControllerViewModel.kt | 184 ++ .../com/example/controllerapp/MainActivity.kt | 688 +------ .../controllerapp/ble/BleGattTransport.kt | 578 ++++++ .../ble/BoundedGattWriteQueue.kt | 23 + .../classicbluetooth/RfcommServerManager.kt | 20 +- .../controllerapp/data/ControllerLogStore.kt | 86 + .../data/ControllerPreferences.kt | 115 ++ .../data/NowPlayingSnapshotStore.kt | 146 ++ .../data/PlaybackHistoryDatabase.kt | 98 + .../controllerapp/media/ArtworkCache.kt | 297 +++ .../media/PlaybackClockPolicy.kt | 18 + .../controllerapp/model/ControllerModels.kt | 250 +++ .../protocol/ControllerProtocolCodec.kt | 202 ++ .../controllerapp/protocol/LyricTimeline.kt | 27 + .../protocol/MediaIdentityPolicy.kt | 21 + .../service/ControllerConnectionService.kt | 525 +++++ .../controllerapp/service/ReconnectPolicy.kt | 11 + .../controllerapp/ui/ControllerAppUi.kt | 1657 +++++++++++++++ .../controllerapp/ui/ControllerTheme.kt | 26 + ControllerApp/src/main/res/values/styles.xml | 9 +- ControllerApp/src/main/res/xml/file_paths.xml | 6 + .../controllerapp/ble/BlePoliciesTest.kt | 35 + .../media/ArtworkPlaceholderPolicyTest.kt | 34 + .../media/PlaybackClockPolicyTest.kt | 54 + .../protocol/CapabilityAndTimelineTest.kt | 68 + .../protocol/ControllerProtocolCodecTest.kt | 128 ++ .../protocol/MediaIdentityPolicyTest.kt | 24 + .../playeragent/ble/BleGattPayloadPolicy.kt | 16 + .../playeragent/ble/BleGattServerManager.kt | 4 +- .../ble/BleGattPayloadPolicyTest.kt | 22 + README.md | 60 +- README.zh-CN.md | 61 +- docs/PROJECT_OVERVIEW.md | 10 +- gradle.properties | 1 + 44 files changed, 6959 insertions(+), 680 deletions(-) create mode 100644 ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt create mode 100644 ControllerApp/src/debug/AndroidManifest.xml create mode 100644 ControllerApp/src/debug/java/com/example/controllerapp/debug/DebugCommandReceiver.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ControllerApplication.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ble/BoundedGattWriteQueue.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/data/ControllerLogStore.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/data/ControllerPreferences.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/data/NowPlayingSnapshotStore.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/data/PlaybackHistoryDatabase.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/protocol/ControllerProtocolCodec.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/service/ReconnectPolicy.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerTheme.kt create mode 100644 ControllerApp/src/main/res/xml/file_paths.xml create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/ble/BlePoliciesTest.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/media/ArtworkPlaceholderPolicyTest.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/protocol/ControllerProtocolCodecTest.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattPayloadPolicy.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattPayloadPolicyTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1632f8a..126e1ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,16 @@ jobs: 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 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/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/ui/ControllerUiSmokeTest.kt b/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt new file mode 100644 index 0000000..20e04c3 --- /dev/null +++ b/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt @@ -0,0 +1,66 @@ +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.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("本地封面增强").assertIsDisplayed() + compose.onNodeWithText("调试").performClick() + compose.onNodeWithContentDescription("返回").performClick() + + compose.onNodeWithContentDescription("打开菜单").performClick() + compose.onNodeWithText("调试工具").performClick() + compose.onNodeWithText("启动 RFCOMM 兼容模式").assertIsDisplayed() + compose.onNodeWithText("停止 RFCOMM 并恢复 BLE").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..947c1f9 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt @@ -0,0 +1,46 @@ +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 retryLyrics() { + repository.requestLyricWindow() + repository.requestFullLyrics() + } + + 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..108a69b --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt @@ -0,0 +1,1834 @@ +package com.example.controllerapp + +import android.content.Context +import android.graphics.Bitmap +import android.os.SystemClock +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.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.MediaIdentityPolicy +import com.example.controllerapp.protocol.ServerCapabilities +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +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 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 capabilityTimeout: Job? = null + private var currentWordFence = CurrentWordOrderingFence() + private var trackInfoTransfer: JsonChunkTransfer? = null + private var lyricWindowTransfer: LyricWindowTransfer? = null + private var legacyLyricsTransfer: LegacyLyricsTransfer? = null + private var binaryLyricsTransfer: BinaryLyricsTransfer? = null + private val binaryLyricsRetryCounts = HashMap() + private var secondaryTransfer: SecondaryTransfer? = null + private var artworkTransfer: ArtworkTransfer? = null + private val artworkRetryCounts = HashMap() + private val artworkPlaceholderRefreshCounts = HashMap() + private var textTransfer: TextTransfer? = null + private val historyTransfers = HashMap() + private val historySyncQueue = ArrayDeque() + private val requestedArtwork = HashSet() + 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(), + lines = 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() + 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() + resetTransfers("new connection") + currentWordFence = CurrentWordOrderingFence() + sendCapabilities() + scope.launch { + delay(80L) + sendCommand("GET_PLAYBACK_STATE") + delay(80L) + sendCommand("GET_VOLUME") + } + } + + 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() + 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() = sendCommand("GET_VOLUME") + 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) { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + val extra = JSONObject() + .put("trackId", trackId) + .put("positionMs", displayedPositionMs()) + .put("includeWordsAroundCurrent", true) + if (!forceLegacy && capabilities.fullLyricsZlib) { + extra.put("format", "zlib-json-v1") + } + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FULL_LYRICS, + failureReason = "" + ) + } + sendCommand("GET_FULL_LYRICS", extra) + } + + fun requestLyricWindow() { + val trackId = _playback.value.trackId + if (trackId.isBlank() || !capabilities.lyricWindow) return + sendCommand( + "GET_LYRIC_WINDOW", + JSONObject() + .put("trackId", trackId) + .put("positionMs", displayedPositionMs()) + ) + } + + fun requestSecondary(mode: String) { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + sendCommand( + "GET_LYRIC_SECONDARY", + JSONObject().put("trackId", trackId).put("mode", mode) + ) + } + + 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) { + val id = _playback.value.trackId + if (id.isBlank()) return + val wireQuality = if (quality == ArtworkQuality.HQ) "hq" else "preview" + requestedArtwork += "$id|$wireQuality" + _artwork.update { + it.copy( + artworkId = id, + loadingStage = if (quality == ArtworkQuality.HQ) { + ArtworkLoadingStage.HQ + } else { + ArtworkLoadingStage.PREVIEW + }, + failureReason = "" + ) + } + sendCommand( + "ALBUM_ART_REQUEST", + JSONObject() + .put("id", id) + .put("quality", wireQuality) + .put("forceRefresh", forceRefresh) + ) + } + + fun forceRefreshArtwork() { + val id = _playback.value.trackId + if (id.isBlank()) return + val iterator = requestedArtwork.iterator() + while (iterator.hasNext()) { + if (iterator.next().startsWith("$id|")) iterator.remove() + } + requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) + } + + fun requestSonyLogs(limit: Int = 80) = + sendCommand("GET_LOGS", JSONObject().put("limit", limit.coerceIn(1, 200))) + + fun requestMediaDump() = sendCommand("DUMP_MEDIA_FIELDS") + + fun syncHistory() { + _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 + val sent = sendCommand( + "GET_PLAY_HISTORY_SINCE", + JSONObject() + .put("requestId", requestId("history")) + .put("afterSessionId", after) + .put("limit", 100) + ) + if (!sent) { + synchronized(historySyncQueue) { historySyncQueue.clear() } + _history.update { it.copy(loading = false, status = "Sony 未连接,保留本地历史") } + } + } + } + + fun loadMoreHistory() { + val before = _history.value.sessions.minOfOrNull { it.sessionId } ?: Long.MAX_VALUE + sendCommand( + "GET_PLAY_HISTORY_PAGE", + JSONObject() + .put("requestId", requestId("page")) + .put("beforeSessionId", before) + .put("limit", 50) + ) + } + + 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 + } + + private fun sendCapabilities() { + 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) + val fallback = CapabilityPolicy.fallbackIfUnacknowledged(capabilities) + if (fallback != capabilities) { + capabilities = fallback + _connection.update { + it.copy(serverProtocolVersion = 1, serverSupportsV2 = false) + } + logStore.append("[Protocol] capability ACK timeout, legacy fallback") + } + } + } + + 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" -> lyricWindowTransfer = null + "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" -> secondaryTransfer = null + "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" -> handleArtworkFailure(value.optString("message")) + "albumArtUnavailable" -> handleArtworkUnavailable(value) + "logStart", "mediaFieldDumpStart" -> startTextTransfer(type, value) + "logChunk", "mediaFieldDumpChunk" -> appendTextTransfer(type, value) + "logEnd", "mediaFieldDumpEnd" -> finishTextTransfer(type, value) + "mediaFieldDumpError" -> reportIssue( + "Media Dump失败:${value.optString("message")}" + ) + "playHistoryPage", "playHistorySince", "playStats" -> handleHistoryPayload(value) + "historyPayloadStart" -> startHistoryPayload(value) + "historyPayloadChunk" -> appendHistoryPayload(value) + "historyPayloadEnd" -> finishHistoryPayload(value) + "playHistoryError" -> { + _history.update { + it.copy(status = value.optString("message", "同步失败")) + } + requestNextHistoryStat() + } + 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") + ) + capabilityTimeout?.cancel() + _connection.update { + it.copy( + serverProtocolVersion = capabilities.protocolVersion, + serverSupportsV2 = capabilities.protocolVersion >= 2 + ) + } + val trackId = _playback.value.trackId + if (trackId.isNotBlank()) { + requestLyricWindow() + requestFullLyrics() + } + logStore.append("[Protocol] V2 capability negotiated") + } + + 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()) { + _lyrics.update { + if (it.trackId == trackId) it.copy(currentText = lyricText) else it + } + if (_lyrics.value.lines.isEmpty() && trackId.isNotBlank()) { + requestFullLyrics() + } + } + 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) + requestLyricWindow() + delay(80L) + requestFullLyrics() + } + } + 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) { + _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 (trackId != _playback.value.trackId || + !MediaIdentityPolicy.generationMatches(_playback.value.generation, generation) || + count !in 1..5 + ) { + return + } + 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 + } + publishLyrics(transfer.lines.values.sortedBy(LyricLine::index), final = false) + } + + private fun startLegacyLyrics(value: JSONObject) { + val trackId = value.optString("trackId") + val count = value.optInt("count") + if (trackId != _playback.value.trackId || count <= 0) return + legacyLyricsTransfer = LegacyLyricsTransfer(trackId, count) + _lyrics.update { + it.copy( + loadingStage = LyricLoadingStage.FULL_LYRICS, + receivedChunks = 0, + expectedChunks = 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 + ) + } + } + } + } + } + + private fun finishLegacyLyrics(value: JSONObject) { + val transfer = legacyLyricsTransfer ?: return + legacyLyricsTransfer = null + if (value.optString("trackId") != transfer.trackId || + transfer.trackId != _playback.value.trackId || + transfer.lines.size != transfer.expectedCount + ) { + return + } + publishLyrics(transfer.lines.values.sortedBy(LyricLine::index), final = true) + } + + private fun startBinaryLyrics(value: JSONObject) { + val trackId = value.optString("trackId", value.optString("id")) + val generation = value.optLong("generation", value.optLong("g")) + if (trackId != _playback.value.trackId || + !MediaIdentityPolicy.generationMatches(_playback.value.generation, 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 + } + 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 + ) + } + } + + 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 + } + 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) { + 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 + ) { + retryBinaryLyrics(transfer, emptyList(), true, "decode failed") + return@launch + } + if (_playback.value.trackId == transfer.trackId && + MediaIdentityPolicy.generationMatches( + _playback.value.generation, + transfer.generation + ) && + binaryLyricsTransfer?.transferId == transfer.transferId + ) { + binaryLyricsRetryCounts.remove(transfer.transferId) + binaryLyricsTransfer = null + publishLyrics(decoded.third, final = true) + } + } + } + + 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) + ) + } + + private fun fallbackBinaryLyrics(reason: String) { + val trackId = binaryLyricsTransfer?.trackId ?: _playback.value.trackId + binaryLyricsTransfer = null + logStore.append("[Lyrics] binary fallback reason=$reason") + if (trackId == _playback.value.trackId) { + requestFullLyrics(forceLegacy = true) + } + } + + private fun handleLyricsUnavailable(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId != _playback.value.trackId) return + val reason = value.optString("reason", value.optString("lyricStatus")) + _lyrics.update { + it.copy( + loadingStage = if (reason.contains("loading", true) || + reason.contains("waiting", true) + ) { + LyricLoadingStage.WAITING_QQ_QRC + } else { + LyricLoadingStage.FAILED + }, + failureReason = reason + ) + } + } + + private fun publishLyrics(lines: List, final: Boolean) { + val trackId = _playback.value.trackId + if (trackId.isBlank()) return + _lyrics.update { + val localIndex = findCurrentLine(lines, displayedPositionMs()) + it.copy( + trackId = trackId, + lines = lines.sortedBy(LyricLine::index), + isFinal = final, + currentLineIndex = lines.getOrNull(localIndex)?.index ?: -1, + loadingStage = if (final) LyricLoadingStage.READY else { + LyricLoadingStage.WINDOW_READY + }, + receivedChunks = lines.size, + expectedChunks = lines.size, + failureReason = "" + ) + } + if (final) { + val mode = _settings.value.lyricDisplayMode + if (mode.showsTranslation) requestSecondary("translation") + if (mode.showsRomanization) requestSecondary("romanization") + } + saveSnapshotDebounced() + } + + private fun startSecondary(value: JSONObject) { + val trackId = value.optString("trackId") + if (trackId != _playback.value.trackId || _lyrics.value.lines.isEmpty()) return + secondaryTransfer = SecondaryTransfer( + trackId = trackId, + transferId = value.optString("transferId"), + mode = value.optString("mode"), + expectedItems = value.optInt("itemCount") + ) + } + + 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 + secondaryTransfer = null + 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() + _lyrics.update { state -> + state.copy( + lines = state.lines.map { line -> + val text = ControllerProtocolCodec.sanitizeSecondary(secondary[line.index]) + when (transfer.mode) { + "translation" -> line.copy(translation = text) + "romanization" -> line.copy(romanization = text) + else -> line + } + } + ) + } + saveSnapshotDebounced() + } + + 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 + } + 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 + artworkTransfer = ArtworkTransfer( + artworkId = id, + quality = quality(value.optString("quality")), + transferId = "", + generation = 0L, + expectedSize = size, + expectedChunks = chunks, + expectedCrc = null, + binary = false + ) + } + + 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 + finishArtworkData(transfer) + } + + private fun startBinaryArtwork(value: JSONObject) { + val id = value.optString("id") + val generation = value.optLong("generation") + if (id != _playback.value.trackId || + !MediaIdentityPolicy.generationMatches(_playback.value.generation, 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 + 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 + ) + } + } + + 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.generationMatches( + _playback.value.generation, + transfer.generation + ) || + transfer.artworkId != _playback.value.trackId + ) { + return + } + val missing = ControllerProtocolCodec.missingIndexes( + transfer.chunks, + transfer.expectedChunks + ) + if (missing.isNotEmpty()) { + retryArtwork(transfer, missing, missing.size > 32) + return + } + artworkTransfer = 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) { + retryArtwork(transfer, emptyList(), true) + } else { + handleArtworkFailure(failure) + } + return@launch + } + val bitmap = ArtworkCache.decodeDownsampled(data!!, 780) + if (bitmap == null || ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { + bitmap?.recycle() + handleArtworkFailure("等待QQ音乐真实封面") + val refreshCount = + (artworkPlaceholderRefreshCounts[transfer.artworkId] ?: 0) + 1 + artworkPlaceholderRefreshCounts[transfer.artworkId] = refreshCount + if (refreshCount <= MAX_PLACEHOLDER_REFRESHES) { + scope.launch { + delay(500L * refreshCount) + if (_playback.value.trackId == transfer.artworkId) { + requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) + } + } + } + return@launch + } + if (_playback.value.trackId != transfer.artworkId || + !MediaIdentityPolicy.generationMatches( + _playback.value.generation, + 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 + } + artworkPlaceholderRefreshCounts.remove(transfer.artworkId) + artworkCache.store( + artworkId = transfer.artworkId, + quality = transfer.quality, + data = data, + bitmap = bitmap, + maximumPixelSize = 780 + ) + 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 + ) + saveSnapshotDebounced() + if (transfer.quality == ArtworkQuality.PREVIEW) { + scope.launch { + delay(hqDelayMs()) + if (_playback.value.trackId == transfer.artworkId) { + requestArtwork(ArtworkQuality.HQ) + } + } + } else if (_settings.value.artworkEnhancementEnabled) { + val enhanced = ArtworkEnhancer.enhance(bitmap) + if (_playback.value.trackId == transfer.artworkId && enhanced !== bitmap) { + _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) + ) + } + + private fun handleArtworkUnavailable(value: JSONObject) { + if (value.optString("id") != _playback.value.trackId) return + 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 handleArtworkFailure(reason: String) { + _artwork.update { + it.copy( + loadingStage = ArtworkLoadingStage.FAILED, + failureReason = reason + ) + } + reportIssue(reason) + } + + private fun restoreCachedArtwork(id: String, restored: Boolean) { + scope.launch { + val hq = artworkCache.load(id, ArtworkQuality.HQ, 780) + val cached = hq ?: artworkCache.load(id, ArtworkQuality.PREVIEW, 780) + if (_playback.value.trackId != id || cached == null) return@launch + _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, + enhancementMessage = if (cached.requiresRefresh) "缓存展示·后台刷新" else "缓存命中" + ) + if (cached.requiresRefresh && _connection.value.connected) { + requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) + } + } + } + + 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")) { + if (kind == "log") { + _diagnostics.update { it.copy(sonyLogs = "Sony 暂无日志") } + } + return + } + textTransfer = TextTransfer(kind, value.optInt("size"), chunks) + _diagnostics.update { it.copy(remoteTransferInProgress = true) } + } + + 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 } + } + + private fun finishTextTransfer(type: String, value: JSONObject) { + val transfer = textTransfer + textTransfer = 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") + ) { + _diagnostics.update { it.copy(remoteTransferInProgress = false) } + return + } + val bytes = ControllerProtocolCodec.reassemble( + transfer.chunks, + transfer.expectedChunks + ) ?: 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 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 + ) + } + + 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 } + } + + private fun finishHistoryPayload(value: JSONObject) { + val requestId = value.optString("requestId") + val transfer = historyTransfers.remove(requestId) ?: return + 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(::handleHistoryPayload) + } + + private fun handleHistoryPayload(value: JSONObject) { + when (value.optString("type")) { + "playHistoryPage", "playHistorySince" -> { + val sessions = decodeHistorySessions(value.optJSONArray("items")) + scope.launch(Dispatchers.IO) { + historyDao.upsert(sessions.map(PlaybackHistoryEntity::from)) + _history.update { + it.copy( + status = if (sessions.isEmpty()) "历史已是最新" 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) { + _history.update { it.copy(loading = false, status = "同步完成") } + return + } + val sent = sendCommand( + "GET_PLAY_STATS", + JSONObject() + .put("requestId", requestId("stats")) + .put("range", range) + ) + if (!sent) requestNextHistoryStat() + } + + 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() + artworkPlaceholderRefreshCounts.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) { + trackInfoTransfer = null + lyricWindowTransfer = null + legacyLyricsTransfer = null + binaryLyricsTransfer = null + secondaryTransfer = null + artworkTransfer = 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 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 -> 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 LegacyLyricsTransfer( + val trackId: String, + val expectedCount: Int, + val lines: MutableMap = HashMap() + ) + + 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 MAX_PLACEHOLDER_REFRESHES = 3 + } +} 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..9847730 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt @@ -0,0 +1,184 @@ +package com.example.controllerapp + +import android.app.Application +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) + + init { + viewModelScope.launch { + while (true) { + if (!uiVisible.value) { + uiVisible.first { it } + continue + } + val state = playback.value + _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 + ) ?: continue + delay(interval) + } + } + } + + fun setUiVisible(visible: Boolean) { + uiVisible.value = visible + if (visible) _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 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) + } + + 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) +} 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..b0205f8 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt @@ -0,0 +1,578 @@ +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) { + handler.post { + logger("[BLE] reconnect reason=$reason") + closing = false + closeGattOnThread() + 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..1271788 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt @@ -0,0 +1,297 @@ +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: Bitmap): Int = + (value.allocationByteCount / 1024).coerceAtLeast(1) + } + + suspend fun load( + artworkId: String, + quality: ArtworkQuality, + maximumPixelSize: Int + ): CachedArtwork? = withContext(Dispatchers.IO) { + val key = key(artworkId, quality, maximumPixelSize) + memory.get(key)?.let { + return@withContext CachedArtwork( + bitmap = it, + quality = quality, + createdAtMs = System.currentTimeMillis(), + requiresRefresh = false + ) + } + val imageFile = imageFile(artworkId, quality) + val metadataFile = metadataFile(artworkId, quality) + if (!imageFile.isFile || !metadataFile.isFile) return@withContext null + val metadata = runCatching { JSONObject(metadataFile.readText()) }.getOrNull() + ?: return@withContext 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@withContext null + } + val bitmap = decodeDownsampled(imageFile.readBytes(), maximumPixelSize) + ?: return@withContext null + if (ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { + bitmap.recycle() + imageFile.delete() + metadataFile.delete() + return@withContext null + } + memory.put(key, bitmap) + trimDecodedEntries() + CachedArtwork( + bitmap = bitmap, + quality = quality, + createdAtMs = createdAt, + requiresRefresh = ageMs > metadata.optLong( + "refreshAfterMs", + STABLE_REFRESH_MS + ) + ) + } + + 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), bitmap) + 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..a244c3b --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt @@ -0,0 +1,18 @@ +package com.example.controllerapp.media + +import com.example.controllerapp.model.PlaybackPerformanceMode + +object PlaybackClockPolicy { + fun refreshIntervalMs( + uiVisible: Boolean, + isPlaying: Boolean, + performanceMode: PlaybackPerformanceMode + ): Long? { + if (!uiVisible || !isPlaying) return null + return when (performanceMode) { + PlaybackPerformanceMode.SMOOTH -> 50L + PlaybackPerformanceMode.POWER_SAVING -> 200L + PlaybackPerformanceMode.AUTOMATIC -> 100L + } + } +} 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..2111240 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt @@ -0,0 +1,250 @@ +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 lines: 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 failureReason: String = "" +) + +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 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 && + 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 = "" +) 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..2f3aae7 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt @@ -0,0 +1,27 @@ +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) + + private const val SERVER_LEAD_TOLERANCE_MS = 1_000L +} 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..5d0a535 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt @@ -0,0 +1,21 @@ +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 + } +} 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..4e945f0 --- /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") + if (forceScan) { + transport.reconnect(reason) + } else { + scope.launch { + val address = repository.preferences.savedDeviceAddress.first() + startConnection(address, 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..24d9e8e --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt @@ -0,0 +1,1657 @@ +package com.example.controllerapp.ui + +import android.content.Intent +import android.graphics.Bitmap +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.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.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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 +import kotlin.math.sin + +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() + + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + artwork.bitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + listOf( + Color(0xe8090b10), + Color(0xb5090b10), + 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, + 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 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, + 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, + 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, + 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, + 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 + ) + Spectrum( + playing = playback.isPlaying, + positionMs = positionMs, + animated = settings.performanceMode != PlaybackPerformanceMode.POWER_SAVING + ) + 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 Spectrum(playing: Boolean, positionMs: Long, animated: Boolean) { + val phase = if (playing && animated) positionMs / 90f else 0f + Canvas(Modifier.fillMaxWidth().height(30.dp).padding(horizontal = 56.dp)) { + val bars = 20 + val spacing = size.width / bars + repeat(bars) { index -> + val amplitude = if (playing) { + 0.25f + (sin(phase + index * 0.8f) + 1f) * 0.28f + } else { + 0.18f + } + val barHeight = size.height * amplitude + drawLine( + color = Color.White.copy(alpha = 0.48f), + start = Offset(index * spacing + spacing / 2, size.height / 2 - barHeight / 2), + end = Offset(index * spacing + spacing / 2, size.height / 2 + barHeight / 2), + strokeWidth = max(2f, spacing * 0.32f) + ) + } + } +} + +@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) + Card( + onClick = onClick, + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.055f)), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth().height(178.dp).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) + auxiliaryText(current, settings.lyricDisplayMode)?.let { + Text( + it, + 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) { + val completed = line.words.indexOfLast { positionMs >= it.startMs + it.durationMs } + val active = line.words.indexOfLast { positionMs >= it.startMs }.coerceAtLeast(0) + val value = if (line.words.isEmpty()) { + buildAnnotatedString { append(line.text) } + } else { + buildAnnotatedString { + line.words.forEachIndexed { index, word -> + withStyle( + SpanStyle( + color = when { + index <= completed -> Color.White + index == active -> Color(0xffb9c7ff) + else -> Color.White.copy(alpha = 0.45f) + }, + fontWeight = if (index == active) FontWeight.Bold else FontWeight.Medium + ) + ) { + append(word.text) + } + } + } + } + Text( + value, + fontSize = if (line.text.length > 42) 16.sp else 20.sp, + lineHeight = if (line.text.length > 42) 21.sp else 26.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) +} + +@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 lyrics by viewModel.lyrics.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val position by viewModel.displayedPositionMs.collectAsStateWithLifecycle() + val current = currentLineIndex( + lyrics.lines, + position + settings.lyricOffsetMs, + lyrics.currentLineIndex + ) + val listState = rememberLazyListState() + var following by remember { mutableStateOf(true) } + LaunchedEffect(current, following) { + if (following && current >= 0 && current < lyrics.lines.size) { + listState.animateScrollToItem(current, scrollOffset = -180) + } + } + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress) { + following = false + delay(4_000L) + following = true + } + } + Scaffold( + topBar = { + ControllerTopBar( + title = "完整歌词", + navController = navController, + action = { + TextButton(onClick = { following = !following }) { + Text(if (following) "跟随中" else "恢复跟随") + } + } + ) + } + ) { padding -> + if (lyrics.lines.isEmpty()) { + EmptyState( + title = lyricStageText(lyrics.loadingStage), + message = lyrics.failureReason.ifBlank { "正在等待 Sony 解析 QQ 音乐歌词" }, + action = "重新请求", + onAction = viewModel::retryLyrics, + modifier = Modifier.padding(padding) + ) + } else { + LazyColumn( + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = padding.calculateTopPadding() + 120.dp, + bottom = padding.calculateBottomPadding() + 240.dp, + start = 22.dp, + end = 22.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(lyrics.lines, key = LyricLine::index) { line -> + val selected = line.index == lyrics.lines.getOrNull(current)?.index + Column( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { viewModel.seekTo(line.timeMs) } + .background( + if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else Color.Transparent + ) + .padding(12.dp) + ) { + if (selected) { + WordHighlightedText(line, position + settings.lyricOffsetMs) + } else { + Text( + line.text, + fontSize = if (line.text.length > 48) 16.sp else 19.sp, + lineHeight = 25.sp, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (line.timeMs < position) 0.48f else 0.76f + ) + ) + } + auxiliaryText(line, settings.lyricDisplayMode)?.let { + Text( + it, + fontSize = 13.sp, + lineHeight = 18.sp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + ) + } + } + } + } + } + } +} + +@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()) { + item { + OutlinedButton( + onClick = viewModel::loadMoreHistory, + modifier = Modifier.fillMaxWidth() + ) { + Text("加载更多") + } + } + } + } + } +} + +@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.lines.size.toString()) + DiagnosticValue("歌词状态", lyrics.loadingStage.name) + 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, + action: @Composable (() -> Unit)? = null +) { + CenterAlignedTopAppBar( + title = { Text(title) }, + 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 auxiliaryText(line: LyricLine, mode: LyricDisplayMode): String? = + buildList { + if (mode.showsTranslation) line.translation?.takeIf(String::isNotBlank)?.let(::add) + if (mode.showsRomanization) line.romanization?.takeIf(String::isNotBlank)?.let(::add) + }.takeIf { it.isNotEmpty() }?.joinToString(" · ") + +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/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..fa0525b --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt @@ -0,0 +1,54 @@ +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 + ) + ) + } +} 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..573ee97 --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt @@ -0,0 +1,68 @@ +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.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)) + } +} 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/MediaIdentityPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt new file mode 100644 index 0000000..ca107ce --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt @@ -0,0 +1,24 @@ +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)) + } +} 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 18ba3cd..9c90e3c 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -1686,6 +1686,7 @@ class BleGattServerManager( val artist = source.optString("artist") val album = source.optString("album") val trackId = buildAlbumArtProtocolId(source) + val generation = reactiveMediaController.generation() val candidates = listOf( TrackInfoLimit(30, 30, 20, includeAlbum = true), TrackInfoLimit(30, 30, 0, includeAlbum = false), @@ -1697,6 +1698,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)) } @@ -3369,7 +3371,7 @@ class BleGattServerManager( 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 { 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/README.md b/README.md index 00bbacf..4586544 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@

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

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

-MusicBleController is a source-available iPhone companion for Sony Walkman devices -running Android. A lightweight Android agent reads QQ Music playback metadata, -QRC lyrics and album art, then streams them to a native SwiftUI app over -Bluetooth Low Energy (BLE). The iPhone app provides playback controls, synced -lyrics, artwork caching, Dynamic Island and Live Activities. +MusicBleController is a source-available, cross-platform companion for Sony +Walkman devices running Android. A lightweight agent reads QQ Music playback +metadata, QRC lyrics and album art, then streams them over Bluetooth Low Energy +(BLE) to either a native SwiftUI iPhone app or a Jetpack Compose Android +controller. Both clients provide playback controls, synced lyrics and artwork +caching; iPhone additionally supports Dynamic Island and Live Activities. > [!IMPORTANT] > The current implementation supports **QQ Music on Android only**. It is an @@ -30,10 +31,11 @@ lyrics, artwork caching, Dynamic Island and Live Activities. ## 中文简介 -MusicBleController 是面向 **Sony Android Walkman 与 iPhone** 的 QQ 音乐 -BLE 伴侣项目。Sony 端读取 QQ 音乐的播放状态、本地 QRC 逐字歌词和通知封面, -再通过低功耗蓝牙实时同步到 iPhone;iPhone 端负责播放器界面、歌词高亮、封面缓存、 -播放控制、锁屏实时活动和灵动岛展示,全程不依赖云端中转服务。 +MusicBleController 是面向 **Sony Android Walkman、iPhone 与 Android 控制端** +的 QQ 音乐 BLE 伴侣项目。Sony 端读取 QQ 音乐的播放状态、本地 QRC 逐字歌词和 +通知封面,再通过低功耗蓝牙实时同步到 SwiftUI iPhone App 或 Jetpack Compose +Android App;两种控制端都支持歌词高亮、封面缓存和播放控制,iPhone 还支持锁屏 +实时活动与灵动岛,全程不依赖云端中转服务。 - 支持 QQ 音乐 QRC 逐字歌词、翻译和罗马音数据; - 支持预览封面优先传输、高清封面后台升级和缓存复用; @@ -69,8 +71,10 @@ devices without a cloud service: - **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,13 +88,16 @@ 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 +MediaSession, notifications and QQ Music's local QRC cache. The iOS and Android +apps are interchangeable BLE controllers, caches and presentation layers; only +one controller should connect to a Sony device at a time. No external server is required. ## Compatibility @@ -100,6 +107,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 +128,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 +164,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..b4a0d03 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,8 +6,8 @@

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

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

-MusicBleController 是一个面向 Sony Android Walkman 和 iPhone 的 QQ 音乐 -状态同步与远程控制项目。运行在 Sony 播放器上的轻量 Android 服务负责读取 -QQ 音乐的 MediaSession、通知封面和本地 QRC 歌词,再通过低功耗蓝牙(BLE) -传输到原生 SwiftUI iPhone App。iPhone 可以显示逐字歌词、歌曲封面和播放进度, -也可以控制播放、切歌、进度和音量,并在锁屏实时活动与灵动岛中展示当前歌曲。 +MusicBleController 是一个面向 Sony Android Walkman、iPhone 和 Android 手机的 +QQ 音乐状态同步与远程控制项目。运行在 Sony 播放器上的轻量 Android 服务负责读取 +QQ 音乐的 MediaSession、通知封面和本地 QRC 歌词,再通过低功耗蓝牙(BLE)传输到 +原生 SwiftUI iPhone App 或 Jetpack Compose Android App。两种控制端都可以显示 +逐字歌词、歌曲封面和播放进度,也可以控制播放、切歌、进度和音量;iPhone 还支持 +锁屏实时活动与灵动岛。 > [!IMPORTANT] > 当前版本只支持 **Android 版 QQ 音乐**,不支持 Apple Music、Spotify、网易云音乐 @@ -58,7 +59,9 @@ Sony 的 Android Walkman 可以安装 QQ 音乐,但它的播放状态、QRC - **QQ 音乐逐字歌词**:解析本地加密 QRC 歌词,支持逐字时间、翻译和罗马音数据; - **快速封面显示**:优先发送小型预览图,再在后台升级高清封面; - **完整播放控制**:支持播放、暂停、上一首、下一首、进度和音量; -- **原生 iPhone 体验**:使用 SwiftUI、ActivityKit、锁屏实时活动和灵动岛; +- **双原生控制端**:iPhone 使用 SwiftUI,Android 使用 Jetpack Compose,均支持 + 主播放器、完整歌词、历史、设置与诊断; +- **iPhone 专属能力**:使用 ActivityKit 提供锁屏实时活动和灵动岛; - **弱性能设备优化**:使用可抢占 BLE 队列、压缩歌词、索引缓存和分级封面; - **兼容与恢复**:支持新旧协议协商、健康探测、有限重试和自动重连; - **可诊断与可测试**:两端均提供诊断日志,并包含 Android、iOS 和跨端冒烟测试。 @@ -69,17 +72,21 @@ 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 是可替换的 +控制、缓存和展示端(同一时刻只建议连接一个控制端): 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、已有控制命令和旧协议兼容路径会被保留,新功能只在双方确认能力后启用。 @@ -114,6 +121,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 +158,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 +187,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 +215,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 +233,9 @@ iOS 模拟器无法验证真实的 BLE 媒体传输链路。 ```bash # Android 单元测试与 Debug 构建 -bash gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug +bash gradlew \ + :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug \ + :ControllerApp:testDebugUnitTest :ControllerApp:assembleDebug # 已安装设备的快速跨端冒烟测试 ./tools/smoke/run_all_smoke_tests.sh --quick --json diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 4ba558f..8c64ca6 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -7,7 +7,7 @@ - `PlayerAgentApp/`:运行在 Sony Android 设备上的权威数据源。负责读取 MediaSession/通知、记录播放历史、解析 QQMusic QRC 歌词、监听 QRC 文件变化、提供 BLE GATT Server、执行播放/音量控制。 - `IOSBleFeasibility/IOSBleFeasibility/`:iPhone 主 App。负责 CoreBluetooth Central 连接 Sony、发送命令、接收状态、缓存封面/歌词/历史、展示播放器、诊断页和设置页。 - `IOSBleFeasibility/SonyMusicLiveActivityExtension/`:Live Activity / Dynamic Island / 锁屏 Widget Extension。只展示 `ActivityKit` 状态和 App Group 中的小封面缩略图,不直接连接 Sony。 -- `ControllerApp/`:旧 Android Controller 侧代码,当前主链路不依赖它。除非任务明确要求,不要修改。 +- `ControllerApp/`:Android BLE V2 控制端。使用 Jetpack Compose、ViewModel、StateFlow 和前台连接服务,负责连接 Sony、播放控制、逐字/完整歌词、封面、历史统计、设置与诊断;RFCOMM 仅作为调试兼容入口。 - `tools/ios-smoke-tests/`:iOS-only smoke test 工具。只依赖 iPhone、`devicectl` 和 iOS App 日志,不使用 adb。 ## 核心文件 @@ -21,6 +21,9 @@ - Sony BLE 队列:[BleNotifyQueue.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt) - Sony 播放状态:[PlaybackStateReader.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt) - Sony 歌词:[LyricManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/LyricManager.kt)、[QrcLyricManager.kt](/Volumes/雷电/project/MusicBleController/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricManager.kt) +- Android Controller 服务:[ControllerConnectionService.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt) +- Android Controller 协议与状态:[ControllerRepository.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt)、[ControllerModels.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt) +- Android Controller 界面:[ControllerAppUi.kt](/Volumes/雷电/project/MusicBleController/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt) ## 总体数据流 @@ -30,11 +33,14 @@ 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` 可替代 iPhone 作为 Central,使用同一 BLE V2 + 协议更新 Compose 界面和 MediaStyle 通知;同一时刻只连接一个控制端。 ## 关键状态 - Sony 是播放、历史、歌词和封面来源的权威端。 - iPhone 是同步、缓存和展示端。 +- Android Controller 是与 iPhone 对等的同步、缓存和展示端,不包含 Live Activity。 - iOS `BLETestManager` 仍是连接/协议分发中心,但设置已拆到 `PreferencesStore`,封面接收已拆到 `AlbumArtReceiver`。 - Live Activity 状态必须保持轻量,`ContentState` 不允许放图片、完整歌词、大数组或 Base64。 - iOS 日志落盘在 App 容器 `Documents/Logs/ios_ble.log`。 @@ -63,4 +69,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/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 From 747cef54f6546cf7af7ce4b9e4cf9accb458f516 Mon Sep 17 00:00:00 2001 From: sqz Date: Fri, 31 Jul 2026 07:12:40 +0800 Subject: [PATCH 07/13] test: stabilize asynchronous artwork XCTest --- .../PerformanceStabilityTests.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index 08429e2..71db862 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -134,12 +134,13 @@ final class PerformanceStabilityTests: XCTestCase { ) } - func testQQFallbackIsRejectedAndForceRefreshRetainsRealArtwork() throws { + 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 @@ -183,7 +184,7 @@ final class PerformanceStabilityTests: XCTestCase { base64: fallbackData.base64EncodedString() ) receiver.handleLegacyEnd(id: artworkID, quality: "hq") - wait(for: [rejected], timeout: 2) + await fulfillment(of: [rejected], timeout: 5) XCTAssertNil(receiver.albumArtImage) XCTAssertEqual(receiver.artworkDisplayQuality, .placeholder) @@ -228,7 +229,7 @@ final class PerformanceStabilityTests: XCTestCase { base64: realData.base64EncodedString() ) receiver.handleLegacyEnd(id: artworkID, quality: "preview") - wait(for: [displayed], timeout: 2) + await fulfillment(of: [displayed], timeout: 5) let displayedImage = try XCTUnwrap(receiver.albumArtImage) delegate.commands.removeAll() @@ -275,7 +276,7 @@ final class PerformanceStabilityTests: XCTestCase { ) } - func testRepeatedOfferDuringSourceRefreshDoesNotSkipHQ() throws { + func testRepeatedOfferDuringSourceRefreshDoesNotSkipHQ() async throws { let delegate = AlbumArtReceiverTestDelegate() let receiver = AlbumArtReceiver(delegate: delegate) let artworkID = "source-refresh-\(UUID().uuidString)" @@ -316,7 +317,7 @@ final class PerformanceStabilityTests: XCTestCase { base64: data.base64EncodedString() ) receiver.handleLegacyEnd(id: artworkID, quality: "hq") - wait(for: [cacheSaved], timeout: 2) + await fulfillment(of: [cacheSaved], timeout: 5) delegate.commands.removeAll() let forcedPreview = expectation(description: "source refresh preview requested") @@ -329,7 +330,7 @@ final class PerformanceStabilityTests: XCTestCase { forcedPreview.fulfill() } receiver.handleOffer(id: artworkID) - wait(for: [forcedPreview], timeout: 2) + await fulfillment(of: [forcedPreview], timeout: 5) let secondOfferHeld = expectation(description: "second offer remains in refresh") delegate.onLog = { message in @@ -338,7 +339,7 @@ final class PerformanceStabilityTests: XCTestCase { } } receiver.handleOffer(id: artworkID) - wait(for: [secondOfferHeld], timeout: 2) + await fulfillment(of: [secondOfferHeld], timeout: 5) XCTAssertFalse( delegate.commands.contains { $0.command == "ALBUM_ART_SKIP" && From ca76c0f155839c22fdcddaa48306b025dd82e2c4 Mon Sep 17 00:00:00 2001 From: sqz Date: Fri, 31 Jul 2026 07:22:00 +0800 Subject: [PATCH 08/13] ci: retry transient iOS simulator failures --- .github/workflows/build.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 126e1ba..47f2273 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,10 +69,19 @@ jobs: ' )" echo "Using iOS simulator: ${simulator_udid}" - xcodebuild \ - -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ - -scheme sonyMusic \ - -destination "platform=iOS Simulator,id=${simulator_udid}" \ - -derivedDataPath "${RUNNER_TEMP}/IOSBleFeasibilityDerivedData" \ - CODE_SIGNING_ALLOWED=NO \ - test + 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 From 1bf5704fb945421f0d2c6622a2bf0ef009d56724 Mon Sep 17 00:00:00 2001 From: sqz Date: Sat, 1 Aug 2026 00:30:54 +0800 Subject: [PATCH 09/13] feat: complete Android controller media synchronization --- .../ControllerRepositoryProtocolTest.kt | 347 ++++ .../controllerapp/ui/ControllerUiSmokeTest.kt | 13 +- .../controllerapp/ControllerCommandGateway.kt | 6 +- .../controllerapp/ControllerRepository.kt | 1443 ++++++++++++++--- .../controllerapp/ControllerViewModel.kt | 39 +- .../controllerapp/ble/BleGattTransport.kt | 14 +- .../controllerapp/media/ArtworkCache.kt | 69 +- .../media/PlaybackClockPolicy.kt | 13 +- .../controllerapp/model/ControllerModels.kt | 28 +- .../protocol/LyricsPublicationPolicy.kt | 78 + .../protocol/MediaIdentityPolicy.kt | 31 + .../service/ControllerConnectionService.kt | 14 +- .../controllerapp/ui/ControllerAppUi.kt | 98 +- .../media/PlaybackClockPolicyTest.kt | 29 + .../protocol/LyricsPublicationPolicyTest.kt | 106 ++ .../protocol/MediaIdentityPolicyTest.kt | 40 + 16 files changed, 2125 insertions(+), 243 deletions(-) create mode 100644 ControllerApp/src/androidTest/java/com/example/controllerapp/ControllerRepositoryProtocolTest.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricsPublicationPolicy.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/protocol/LyricsPublicationPolicyTest.kt 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 index 20e04c3..ec1edf0 100644 --- a/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt +++ b/ControllerApp/src/androidTest/java/com/example/controllerapp/ui/ControllerUiSmokeTest.kt @@ -6,6 +6,7 @@ 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 @@ -38,14 +39,18 @@ class ControllerUiSmokeTest { compose.onNodeWithContentDescription("打开菜单").performClick() compose.onNodeWithText("设置").performClick() compose.onNodeWithText("性能模式").assertIsDisplayed() - compose.onNodeWithText("本地封面增强").assertIsDisplayed() - compose.onNodeWithText("调试").performClick() + compose.onNodeWithText("本地封面增强").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("调试").performScrollTo().performClick() compose.onNodeWithContentDescription("返回").performClick() compose.onNodeWithContentDescription("打开菜单").performClick() compose.onNodeWithText("调试工具").performClick() - compose.onNodeWithText("启动 RFCOMM 兼容模式").assertIsDisplayed() - compose.onNodeWithText("停止 RFCOMM 并恢复 BLE").assertIsDisplayed() + compose.onNodeWithText("启动 RFCOMM 兼容模式") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithText("停止 RFCOMM 并恢复 BLE") + .performScrollTo() + .assertIsDisplayed() compose.onNodeWithContentDescription("返回").performClick() compose.onNodeWithContentDescription("打开菜单").performClick() diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt index 947c1f9..ab15899 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerCommandGateway.kt @@ -20,10 +20,8 @@ class ControllerCommandGateway internal constructor( fun requestVolume() = repository.requestVolume() fun requestFullLyrics() = repository.requestFullLyrics() - fun retryLyrics() { - repository.requestLyricWindow() - repository.requestFullLyrics() - } + fun ensureFullLyrics() = repository.ensureFullLyrics() + fun retryLyrics() = repository.retryLyrics() fun requestLyricDiagnostic() = repository.requestLyricDiagnostic() fun requestArtwork(quality: ArtworkQuality = ArtworkQuality.HQ) = diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt b/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt index 108a69b..6db69e1 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerRepository.kt @@ -3,6 +3,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -37,12 +39,16 @@ 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 @@ -53,6 +59,7 @@ 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 { @@ -70,6 +77,11 @@ class ControllerRepository( ) { 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) @@ -77,21 +89,48 @@ class ControllerRepository( 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 @@ -133,7 +172,7 @@ class ControllerRepository( _lyrics.value = LyricsState( trackId = snapshot.playback.trackId, currentText = snapshot.lines.firstOrNull()?.text.orEmpty(), - lines = snapshot.lines, + windowLines = snapshot.lines, loadingStage = LyricLoadingStage.WINDOW_READY ) if (snapshot.artworkId.isNotBlank()) { @@ -184,6 +223,7 @@ class ControllerRepository( ) if (phase == ConnectionPhase.DISCONNECTED) { capabilities = ServerCapabilities() + capabilityDecisionReady = false capabilityTimeout?.cancel() } } @@ -202,6 +242,7 @@ class ControllerRepository( lastNotifyElapsedMs = SystemClock.elapsedRealtime() ) capabilities = ServerCapabilities() + capabilityDecisionReady = false resetTransfers("new connection") currentWordFence = CurrentWordOrderingFence() sendCapabilities() @@ -209,7 +250,7 @@ class ControllerRepository( delay(80L) sendCommand("GET_PLAYBACK_STATE") delay(80L) - sendCommand("GET_VOLUME") + requestVolume() } } @@ -259,6 +300,11 @@ class ControllerRepository( 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) @@ -286,7 +332,28 @@ class ControllerRepository( fun previous() = sendCommand("PREVIOUS") fun next() = sendCommand("NEXT") fun requestPlaybackState() = sendCommand("GET_PLAYBACK_STATE") - fun requestVolume() = sendCommand("GET_VOLUME") + 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() @@ -297,42 +364,162 @@ class ControllerRepository( 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 (!forceLegacy && capabilities.fullLyricsZlib) { + 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 = "" ) } - sendCommand("GET_FULL_LYRICS", extra) + 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 - sendCommand( + 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()) return - sendCommand( - "GET_LYRIC_SECONDARY", - JSONObject().put("trackId", trackId).put("mode", mode) - ) + 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() { @@ -342,10 +529,22 @@ class ControllerRepository( } 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" - requestedArtwork += "$id|$wireQuality" + 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, @@ -357,31 +556,55 @@ class ControllerRepository( failureReason = "" ) } - sendCommand( + 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() { - val id = _playback.value.trackId - if (id.isBlank()) return - val iterator = requestedArtwork.iterator() - while (iterator.hasNext()) { - if (iterator.next().startsWith("$id|")) iterator.remove() + 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) } - requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) } - fun requestSonyLogs(limit: Int = 80) = - sendCommand("GET_LOGS", JSONObject().put("limit", limit.coerceIn(1, 200))) + fun requestSonyLogs(limit: Int = 80) { + mediaScope.launch { + requestRemoteText( + command = "GET_LOGS", + kind = "log", + extra = JSONObject().put("limit", limit.coerceIn(1, 200)) + ) + } + } - fun requestMediaDump() = sendCommand("DUMP_MEDIA_FIELDS") + 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) { @@ -389,31 +612,30 @@ class ControllerRepository( 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_SINCE", + "GET_PLAY_HISTORY_PAGE", JSONObject() - .put("requestId", requestId("history")) - .put("afterSessionId", after) - .put("limit", 100) + .put("requestId", requestId("page")) + .put("beforeSessionId", before) + .put("limit", 50) ) - if (!sent) { - synchronized(historySyncQueue) { historySyncQueue.clear() } - _history.update { it.copy(loading = false, status = "Sony 未连接,保留本地历史") } + if (sent) { + scheduleHistoryTimeout("更多历史") + } else { + _history.update { it.copy(loading = false, status = "Sony 未连接") } } } } - fun loadMoreHistory() { - val before = _history.value.sessions.minOfOrNull { it.sessionId } ?: Long.MAX_VALUE - sendCommand( - "GET_PLAY_HISTORY_PAGE", - JSONObject() - .put("requestId", requestId("page")) - .put("beforeSessionId", before) - .put("limit", 50) - ) - } - fun clearLocalHistory() { scope.launch(Dispatchers.IO) { historyDao.clear() @@ -490,9 +712,13 @@ class ControllerRepository( 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) @@ -504,13 +730,17 @@ class ControllerRepository( capabilityTimeout?.cancel() capabilityTimeout = scope.launch { delay(CapabilityPolicy.ACK_TIMEOUT_MS) - val fallback = CapabilityPolicy.fallbackIfUnacknowledged(capabilities) - if (fallback != capabilities) { - capabilities = fallback - _connection.update { - it.copy(serverProtocolVersion = 1, serverSupportsV2 = false) + 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() } - logStore.append("[Protocol] capability ACK timeout, legacy fallback") } } } @@ -547,7 +777,7 @@ class ControllerRepository( "lyricWindowStart" -> startLyricWindow(value) "lyricWindowChunk" -> appendLyricWindow(value) "lyricWindowEnd" -> finishLyricWindow(value) - "lyricWindowUnavailable" -> lyricWindowTransfer = null + "lyricWindowUnavailable" -> handleLyricWindowUnavailable(value) "fullLyricsStart" -> startLegacyLyrics(value) "fullLyricsChunk" -> appendLegacyLyrics(value) "fullLyricsEnd" -> finishLegacyLyrics(value) @@ -558,7 +788,8 @@ class ControllerRepository( "lyricSecondaryStart" -> startSecondary(value) "lyricSecondaryPart" -> appendSecondary(value) "lyricSecondaryEnd" -> finishSecondary(value) - "lyricSecondaryUnavailable", "lyricSecondaryError" -> secondaryTransfer = null + "lyricSecondaryUnavailable", "lyricSecondaryError" -> + handleSecondaryUnavailable(value) "lyricDiagnostic" -> handleLyricDiagnostic(value) "lyricDiagnosticUnavailable" -> reportIssue( "歌词诊断不可用:${value.optString("reason")}" @@ -569,12 +800,12 @@ class ControllerRepository( "albumArtEnd" -> finishLegacyArtwork(value) "albumArtBinaryStart" -> startBinaryArtwork(value) "albumArtBinaryEnd" -> finishBinaryArtwork(value) - "albumArtBinaryError" -> handleArtworkFailure(value.optString("message")) + "albumArtBinaryError" -> handleArtworkProtocolError(value) "albumArtUnavailable" -> handleArtworkUnavailable(value) "logStart", "mediaFieldDumpStart" -> startTextTransfer(type, value) "logChunk", "mediaFieldDumpChunk" -> appendTextTransfer(type, value) "logEnd", "mediaFieldDumpEnd" -> finishTextTransfer(type, value) - "mediaFieldDumpError" -> reportIssue( + "mediaFieldDumpError" -> finishRemoteTextWithError( "Media Dump失败:${value.optString("message")}" ) "playHistoryPage", "playHistorySince", "playStats" -> handleHistoryPayload(value) @@ -582,10 +813,15 @@ class ControllerRepository( "historyPayloadChunk" -> appendHistoryPayload(value) "historyPayloadEnd" -> finishHistoryPayload(value) "playHistoryError" -> { + historySyncTimeout?.cancel() + historySyncTimeout = null + synchronized(historySyncQueue) { historySyncQueue.clear() } _history.update { - it.copy(status = value.optString("message", "同步失败")) + it.copy( + loading = false, + status = value.optString("message", "同步失败") + ) } - requestNextHistoryStat() } else -> logStore.append("[Protocol] unsupported type=$type") } @@ -601,6 +837,7 @@ class ControllerRepository( ping = value.optBoolean("ping"), transferRetry = value.optBoolean("transferRetry") ) + capabilityDecisionReady = true capabilityTimeout?.cancel() _connection.update { it.copy( @@ -608,12 +845,18 @@ class ControllerRepository( serverSupportsV2 = capabilities.protocolVersion >= 2 ) } + requestCurrentMediaPayloadsOnMedia() + logStore.append("[Protocol] V2 capability negotiated") + } + + private fun requestCurrentMediaPayloadsOnMedia() { val trackId = _playback.value.trackId - if (trackId.isNotBlank()) { - requestLyricWindow() - requestFullLyrics() + if (trackId.isBlank()) return + requestLyricWindowOnMedia(force = false) + requestFullLyricsOnMedia(forceLegacy = false, force = false) + if (_artwork.value.artworkId != trackId || _artwork.value.bitmap == null) { + requestArtworkOnMedia(ArtworkQuality.PREVIEW) } - logStore.append("[Protocol] V2 capability negotiated") } private fun handlePlayback(value: JSONObject) { @@ -629,11 +872,25 @@ class ControllerRepository( _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 } - if (_lyrics.value.lines.isEmpty() && trackId.isNotBlank()) { - requestFullLyrics() + 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") @@ -685,10 +942,13 @@ class ControllerRepository( restoreCachedArtwork(trackId, restored = false) scope.launch { delay(40L) - requestLyricWindow() - delay(80L) - requestFullLyrics() + mediaScope.launch { + if (capabilityDecisionReady) requestCurrentMediaPayloadsOnMedia() + } } + } else if (_artwork.value.artworkId != trackId || _artwork.value.bitmap == null) { + restoreCachedArtwork(trackId, restored = false) + requestArtwork(ArtworkQuality.PREVIEW) } saveSnapshotDebounced() } @@ -721,6 +981,9 @@ class ControllerRepository( } private fun handleVolume(value: JSONObject) { + volumeSyncTimeout?.cancel() + volumeSyncTimeout = null + volumeSyncAttempts = 0 _playback.update { it.copy( volumeCurrent = value.optInt("current").coerceAtLeast(0), @@ -733,12 +996,14 @@ class ControllerRepository( val trackId = value.optString("trackId") val count = value.optInt("count") val generation = value.optLong("generation") - if (trackId != _playback.value.trackId || - !MediaIdentityPolicy.generationMatches(_playback.value.generation, generation) || + if (!adoptIncomingGeneration(trackId, generation) || count !in 1..5 ) { return } + lyricWindowRequestTimeout?.cancel() + lyricWindowRequestTimeout = null + activeLyricWindowRequest = null lyricWindowTransfer = LyricWindowTransfer( trackId = trackId, transferId = value.optString("transferId"), @@ -768,21 +1033,48 @@ class ControllerRepository( ) { return } - publishLyrics(transfer.lines.values.sortedBy(LyricLine::index), final = false) + 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 <= 0) return + 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 + expectedChunks = count, + transferId = "legacy", + protocolFormat = "legacy" ) } + logStore.append("[Lyrics] legacy start trackId=$trackId lines=$count") } private fun appendLegacyLyrics(value: JSONObject) { @@ -806,6 +1098,13 @@ class ControllerRepository( ) } } + val now = SystemClock.elapsedRealtime() + if (transfer.lines.size >= 3 && + now - lastLegacyPartialPublishAtMs >= PARTIAL_LYRICS_PUBLISH_MS + ) { + lastLegacyPartialPublishAtMs = now + publishPartialLyrics(transfer.lines.values.sortedBy(LyricLine::index)) + } } } } @@ -814,20 +1113,29 @@ class ControllerRepository( val transfer = legacyLyricsTransfer ?: return legacyLyricsTransfer = null if (value.optString("trackId") != transfer.trackId || - transfer.trackId != _playback.value.trackId || - transfer.lines.size != transfer.expectedCount + transfer.trackId != _playback.value.trackId ) { return } - publishLyrics(transfer.lines.values.sortedBy(LyricLine::index), final = true) + 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 (trackId != _playback.value.trackId || - !MediaIdentityPolicy.generationMatches(_playback.value.generation, generation) - ) { + if (!adoptIncomingGeneration(trackId, generation)) { return } val transferId = value.optString("transferId", value.optString("tid")) @@ -848,6 +1156,9 @@ class ControllerRepository( fallbackBinaryLyrics("invalid start") return } + fullLyricsRequestStartTimeout?.cancel() + fullLyricsRequestStartTimeout = null + legacyLyricsTransfer = null binaryLyricsTransfer = BinaryLyricsTransfer( trackId = trackId, transferId = transferId, @@ -862,9 +1173,15 @@ class ControllerRepository( it.copy( loadingStage = LyricLoadingStage.FULL_LYRICS, receivedChunks = 0, - expectedChunks = chunks + 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) { @@ -910,6 +1227,8 @@ class ControllerRepository( retryBinaryLyrics(transfer, missing, missing.size > 32, "missing chunks") return } + fullLyricsTransferTimeout?.cancel() + fullLyricsTransferTimeout = null scope.launch(Dispatchers.Default) { val compressed = ControllerProtocolCodec.reassemble( transfer.chunks, @@ -922,7 +1241,11 @@ class ControllerRepository( else -> null } if (reason != null) { - retryBinaryLyrics(transfer, emptyList(), true, reason) + mediaScope.launch { + if (binaryLyricsTransfer?.transferId == transfer.transferId) { + retryBinaryLyrics(transfer, emptyList(), true, reason) + } + } return@launch } val raw = ControllerProtocolCodec.zlibDecompress( @@ -935,19 +1258,34 @@ class ControllerRepository( decoded.second != transfer.generation || decoded.third.size != transfer.expectedLineCount ) { - retryBinaryLyrics(transfer, emptyList(), true, "decode failed") + 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 - ) && - binaryLyricsTransfer?.transferId == transfer.transferId + ) ) { - binaryLyricsRetryCounts.remove(transfer.transferId) - binaryLyricsTransfer = null - publishLyrics(decoded.third, final = true) + 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" + ) + } } } } @@ -972,26 +1310,48 @@ class ControllerRepository( .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) { - requestFullLyrics(forceLegacy = true) + 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 (reason.contains("loading", true) || - reason.contains("waiting", true) - ) { + loadingStage = if (waitingForQrc) { LyricLoadingStage.WAITING_QQ_QRC } else { LyricLoadingStage.FAILED @@ -999,42 +1359,205 @@ class ControllerRepository( failureReason = reason ) } + logStore.append("[Lyrics] unavailable trackId=$trackId reason=$reason") + if (waitingForQrc) scheduleQrcWaitRetry(trackId) } - private fun publishLyrics(lines: List, final: Boolean) { + private fun publishLyricWindow(lines: List) { val trackId = _playback.value.trackId if (trackId.isBlank()) return _lyrics.update { val localIndex = findCurrentLine(lines, displayedPositionMs()) - it.copy( + LyricsPublicationPolicy.publishWindow( + state = it, trackId = trackId, - lines = lines.sortedBy(LyricLine::index), - isFinal = final, - currentLineIndex = lines.getOrNull(localIndex)?.index ?: -1, - loadingStage = if (final) LyricLoadingStage.READY else { - LyricLoadingStage.WINDOW_READY - }, - receivedChunks = lines.size, - expectedChunks = lines.size, - failureReason = "" + generation = _playback.value.generation, + lines = lines, + currentLineIndex = lines.getOrNull(localIndex)?.index ?: -1 ) } - if (final) { - val mode = _settings.value.lyricDisplayMode - if (mode.showsTranslation) requestSecondary("translation") - if (mode.showsRomanization) requestSecondary("romanization") + 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") - if (trackId != _playback.value.trackId || _lyrics.value.lines.isEmpty()) return + 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 = value.optString("transferId"), - mode = value.optString("mode"), - expectedItems = value.optInt("itemCount") + transferId = transferId, + mode = mode, + expectedItems = expectedItems + ) + scheduleSecondaryTimeout(trackId, mode, transferId) + logStore.append( + "[Lyrics] secondary start trackId=$trackId mode=$mode items=$expectedItems" ) } @@ -1053,7 +1576,6 @@ class ControllerRepository( private fun finishSecondary(value: JSONObject) { val transfer = secondaryTransfer ?: return - secondaryTransfer = null if (!transfer.matches(value, _playback.value.trackId)) return val secondary = transfer.parts.mapNotNull { (line, parts) -> if (parts.parts.size != parts.expectedCount) { @@ -1064,9 +1586,18 @@ class ControllerRepository( } } }.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( - lines = state.lines.map { line -> + fullLines = state.fullLines.map { line -> val text = ControllerProtocolCodec.sanitizeSecondary(secondary[line.index]) when (transfer.mode) { "translation" -> line.copy(translation = text) @@ -1076,7 +1607,91 @@ class ControllerRepository( } ) } + 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) { @@ -1109,6 +1724,15 @@ class ControllerRepository( ) 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) } @@ -1118,6 +1742,8 @@ class ControllerRepository( 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")), @@ -1128,6 +1754,11 @@ class ControllerRepository( 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) { @@ -1156,15 +1787,15 @@ class ControllerRepository( 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 (id != _playback.value.trackId || - !MediaIdentityPolicy.generationMatches(_playback.value.generation, generation) - ) { + if (!adoptIncomingGeneration(id, generation)) { return } val transferId = value.optString("transferId") @@ -1172,6 +1803,8 @@ class ControllerRepository( 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")), @@ -1194,6 +1827,11 @@ class ControllerRepository( 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) { @@ -1231,14 +1869,25 @@ class ControllerRepository( value.optString("id") != transfer.artworkId || value.optString("transferId") != transfer.transferId || value.optLong("generation") != transfer.generation || - !MediaIdentityPolicy.generationMatches( - _playback.value.generation, - transfer.generation - ) || - transfer.artworkId != _playback.value.trackId + !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 @@ -1248,6 +1897,8 @@ class ControllerRepository( return } artworkTransfer = null + artworkTransferTimeout?.cancel() + artworkTransferTimeout = null finishArtworkData(transfer) } @@ -1266,33 +1917,38 @@ class ControllerRepository( } if (failure != null) { if (transfer.binary) { - retryArtwork(transfer, emptyList(), true) + mediaScope.launch { retryArtwork(transfer, emptyList(), true) } } else { - handleArtworkFailure(failure) + mediaScope.launch { handleArtworkFailure(failure) } } return@launch } val bitmap = ArtworkCache.decodeDownsampled(data!!, 780) if (bitmap == null || ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { bitmap?.recycle() - handleArtworkFailure("等待QQ音乐真实封面") - val refreshCount = - (artworkPlaceholderRefreshCounts[transfer.artworkId] ?: 0) + 1 - artworkPlaceholderRefreshCounts[transfer.artworkId] = refreshCount - if (refreshCount <= MAX_PLACEHOLDER_REFRESHES) { - scope.launch { + 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) { - requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) + requestArtworkOnMedia( + ArtworkQuality.PREVIEW, + forceRefresh = true + ) } } } return@launch } - if (_playback.value.trackId != transfer.artworkId || - !MediaIdentityPolicy.generationMatches( - _playback.value.generation, - transfer.generation + 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 @@ -1301,7 +1957,6 @@ class ControllerRepository( bitmap.recycle() return@launch } - artworkPlaceholderRefreshCounts.remove(transfer.artworkId) artworkCache.store( artworkId = transfer.artworkId, quality = transfer.quality, @@ -1309,36 +1964,58 @@ class ControllerRepository( bitmap = bitmap, maximumPixelSize = 780 ) - 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 - ) - saveSnapshotDebounced() - if (transfer.quality == ArtworkQuality.PREVIEW) { - scope.launch { - delay(hqDelayMs()) - if (_playback.value.trackId == transfer.artworkId) { - requestArtwork(ArtworkQuality.HQ) - } + mediaScope.launch publishArtwork@{ + if (!MediaIdentityPolicy.transferStillBelongsToCurrentTrack( + currentTrackId = _playback.value.trackId, + transferTrackId = transfer.artworkId, + currentGeneration = _playback.value.generation, + transferGeneration = transfer.generation + ) + ) { + return@publishArtwork } - } else if (_settings.value.artworkEnhancementEnabled) { - val enhanced = ArtworkEnhancer.enhance(bitmap) - if (_playback.value.trackId == transfer.artworkId && enhanced !== bitmap) { - _artwork.update { - it.copy( - bitmap = enhanced, - quality = ArtworkQuality.ENHANCED, - enhancementMessage = "本地增强完成" - ) + 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 = "本地增强完成" + ) + } + } } } } @@ -1365,10 +2042,106 @@ class ControllerRepository( .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 @@ -1385,10 +2158,45 @@ class ControllerRepository( } } + 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 = ArtworkLoadingStage.FAILED, + loadingStage = when { + it.bitmap == null -> ArtworkLoadingStage.FAILED + it.quality.rank >= ArtworkQuality.HQ.rank -> ArtworkLoadingStage.HQ_READY + else -> ArtworkLoadingStage.PREVIEW_READY + }, failureReason = reason ) } @@ -1396,24 +2204,57 @@ class ControllerRepository( } private fun restoreCachedArtwork(id: String, restored: Boolean) { - scope.launch { - val hq = artworkCache.load(id, ArtworkQuality.HQ, 780) - val cached = hq ?: artworkCache.load(id, ArtworkQuality.PREVIEW, 780) - if (_playback.value.trackId != id || cached == null) return@launch - _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, - enhancementMessage = if (cached.requiresRefresh) "缓存展示·后台刷新" else "缓存命中" - ) - if (cached.requiresRefresh && _connection.value.connected) { - requestArtwork(ArtworkQuality.PREVIEW, forceRefresh = true) + 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) } } } @@ -1422,13 +2263,20 @@ class ControllerRepository( 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 暂无日志") } + _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) { @@ -1439,12 +2287,17 @@ class ControllerRepository( if (index !in 0 until transfer.expectedChunks) return runCatching { Base64.decode(value.optString("data"), Base64.NO_WRAP) } .getOrNull() - ?.let { transfer.chunks[index] = it } + ?.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) @@ -1456,13 +2309,16 @@ class ControllerRepository( (type.startsWith("log") && transfer.kind != "log") || (type.startsWith("mediaFieldDump") && transfer.kind != "dump") ) { - _diagnostics.update { it.copy(remoteTransferInProgress = false) } + finishRemoteTextWithError("远程诊断数据分包不完整") return } val bytes = ControllerProtocolCodec.reassemble( transfer.chunks, transfer.expectedChunks - ) ?: return + ) ?: run { + finishRemoteTextWithError("远程诊断数据重组失败") + return + } val text = String(bytes, Charsets.UTF_8) _diagnostics.update { if (transfer.kind == "log") { @@ -1473,6 +2329,40 @@ class ControllerRepository( } } + 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") @@ -1482,6 +2372,7 @@ class ControllerRepository( expectedSize = value.optInt("size"), expectedChunks = chunks ) + scheduleHistoryTimeout("历史数据传输") } private fun appendHistoryPayload(value: JSONObject) { @@ -1491,7 +2382,10 @@ class ControllerRepository( if (index !in 0 until transfer.expectedChunks) return runCatching { Base64.decode(value.optString("data"), Base64.NO_WRAP) } .getOrNull() - ?.let { transfer.chunks[index] = it } + ?.let { + transfer.chunks[index] = it + scheduleHistoryTimeout("历史数据传输") + } } private fun finishHistoryPayload(value: JSONObject) { @@ -1500,25 +2394,44 @@ class ControllerRepository( 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(::handleHistoryPayload) + ) + 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 "历史同步完成" + status = if (sessions.isEmpty()) "历史已是最新" else "历史同步完成", + hasMore = if (responseType == "playHistoryPage") hasMore else it.hasMore ) } - requestNextHistoryStat() + mediaScope.launch { + if (responseType == "playHistorySince" && hasMore && lastSessionId > 0L) { + requestHistorySinceOnMedia(lastSessionId) + } else { + requestNextHistoryStat() + } + } } } "playStats" -> { @@ -1540,6 +2453,8 @@ class ControllerRepository( if (historySyncQueue.isEmpty()) null else historySyncQueue.removeFirst() } if (range == null) { + historySyncTimeout?.cancel() + historySyncTimeout = null _history.update { it.copy(loading = false, status = "同步完成") } return } @@ -1549,7 +2464,52 @@ class ControllerRepository( .put("requestId", requestId("stats")) .put("range", range) ) - if (!sent) requestNextHistoryStat() + 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 = @@ -1664,7 +2624,18 @@ class ControllerRepository( 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 @@ -1677,12 +2648,40 @@ class ControllerRepository( 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() } @@ -1725,6 +2724,27 @@ class ControllerRepository( 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 @@ -1734,7 +2754,11 @@ class ControllerRepository( private fun hqDelayMs(): Long = when (_settings.value.performanceMode) { PlaybackPerformanceMode.SMOOTH -> 750L PlaybackPerformanceMode.POWER_SAVING -> 2_500L - PlaybackPerformanceMode.AUTOMATIC -> 1_200L + PlaybackPerformanceMode.AUTOMATIC -> { + val powerSaving = appContext.getSystemService(PowerManager::class.java) + ?.isPowerSaveMode == true + if (powerSaving) 2_500L else 1_200L + } } private fun reportIssue(message: String) { @@ -1765,12 +2789,27 @@ class ControllerRepository( 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, @@ -1829,6 +2868,32 @@ class ControllerRepository( ) 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 index 9847730..aa841ef 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ControllerViewModel.kt @@ -1,6 +1,9 @@ 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 @@ -31,6 +34,8 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati 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 { @@ -40,6 +45,7 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati continue } val state = playback.value + refreshPowerPolicy() _displayedPositionMs.value = repository.displayedPositionMs() if (!state.isPlaying) { playback.first { next -> @@ -52,7 +58,8 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati val interval = PlaybackClockPolicy.refreshIntervalMs( uiVisible = true, isPlaying = true, - performanceMode = settings.value.performanceMode + performanceMode = settings.value.performanceMode, + systemPowerSaveMode = isPowerSaveMode() ) ?: continue delay(interval) } @@ -61,7 +68,10 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati fun setUiVisible(visible: Boolean) { uiVisible.value = visible - if (visible) _displayedPositionMs.value = repository.displayedPositionMs() + if (visible) { + refreshPowerPolicy() + _displayedPositionMs.value = repository.displayedPositionMs() + } } fun playPause() { @@ -92,6 +102,10 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati commands.requestFullLyrics() } + fun ensureFullLyrics() { + commands.ensureFullLyrics() + } + fun retryLyrics() { commands.retryLyrics() } @@ -154,6 +168,7 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati fun updatePerformanceMode(value: PlaybackPerformanceMode) { repository.updatePerformanceMode(value) + refreshPowerPolicy(value) } fun updateAutoReconnect(value: Boolean) { @@ -181,4 +196,24 @@ class ControllerViewModel(application: Application) : AndroidViewModel(applicati 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/ble/BleGattTransport.kt b/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt index b0205f8..41b2f52 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ble/BleGattTransport.kt @@ -259,10 +259,22 @@ class BleGattTransport( } 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] reconnect reason=$reason") + 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) } } diff --git a/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt b/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt index 1271788..3fd2ee1 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/ArtworkCache.kt @@ -24,9 +24,9 @@ data class CachedArtwork( 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: Bitmap): Int = - (value.allocationByteCount / 1024).coerceAtLeast(1) + private val memory = object : LruCache(32 * 1024) { + override fun sizeOf(key: String, value: CachedArtwork): Int = + (value.bitmap.allocationByteCount / 1024).coerceAtLeast(1) } suspend fun load( @@ -34,38 +34,60 @@ class ArtworkCache(context: Context) { 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@withContext CachedArtwork( - bitmap = it, - quality = quality, - createdAtMs = System.currentTimeMillis(), - requiresRefresh = false - ) + return it } val imageFile = imageFile(artworkId, quality) val metadataFile = metadataFile(artworkId, quality) - if (!imageFile.isFile || !metadataFile.isFile) return@withContext null + if (!imageFile.isFile || !metadataFile.isFile) return null val metadata = runCatching { JSONObject(metadataFile.readText()) }.getOrNull() - ?: return@withContext null + ?: 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@withContext null + return null } val bitmap = decodeDownsampled(imageFile.readBytes(), maximumPixelSize) - ?: return@withContext null + ?: return null if (ArtworkPlaceholderPolicy.isLikelyPlaceholder(bitmap)) { bitmap.recycle() imageFile.delete() metadataFile.delete() - return@withContext null + return null } - memory.put(key, bitmap) - trimDecodedEntries() - CachedArtwork( + val cached = CachedArtwork( bitmap = bitmap, quality = quality, createdAtMs = createdAt, @@ -74,6 +96,9 @@ class ArtworkCache(context: Context) { STABLE_REFRESH_MS ) ) + memory.put(key, cached) + trimDecodedEntries() + return cached } suspend fun store( @@ -117,7 +142,15 @@ class ArtworkCache(context: Context) { metadata.delete() metadataTemp.renameTo(metadata) } - memory.put(key(artworkId, quality, maximumPixelSize), bitmap) + memory.put( + key(artworkId, quality, maximumPixelSize), + CachedArtwork( + bitmap = bitmap, + quality = quality, + createdAtMs = System.currentTimeMillis(), + requiresRefresh = false + ) + ) trimDecodedEntries() trimDisk() } diff --git a/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt b/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt index a244c3b..e4bd4a4 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/media/PlaybackClockPolicy.kt @@ -6,13 +6,22 @@ object PlaybackClockPolicy { fun refreshIntervalMs( uiVisible: Boolean, isPlaying: Boolean, - performanceMode: PlaybackPerformanceMode + performanceMode: PlaybackPerformanceMode, + systemPowerSaveMode: Boolean = false ): Long? { if (!uiVisible || !isPlaying) return null return when (performanceMode) { PlaybackPerformanceMode.SMOOTH -> 50L PlaybackPerformanceMode.POWER_SAVING -> 200L - PlaybackPerformanceMode.AUTOMATIC -> 100L + 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 index 2111240..14bc7ca 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/model/ControllerModels.kt @@ -116,7 +116,9 @@ data class LyricsState( val trackId: String = "", val generation: Long = 0L, val currentText: String = "", - val lines: List = emptyList(), + val windowLines: List = emptyList(), + val partialFullLines: List = emptyList(), + val fullLines: List = emptyList(), val isFinal: Boolean = false, val currentLineIndex: Int = -1, val currentWordIndex: Int = -1, @@ -125,8 +127,25 @@ data class LyricsState( 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), @@ -152,6 +171,7 @@ data class ArtworkState( val receivedChunks: Int = 0, val expectedChunks: Int = 0, val restoredSnapshot: Boolean = false, + val cacheRequiresRefresh: Boolean = false, val failureReason: String = "", val enhancementMessage: String = "" ) { @@ -165,6 +185,7 @@ data class ArtworkState( receivedChunks == other.receivedChunks && expectedChunks == other.expectedChunks && restoredSnapshot == other.restoredSnapshot && + cacheRequiresRefresh == other.cacheRequiresRefresh && failureReason == other.failureReason && enhancementMessage == other.enhancementMessage } @@ -246,5 +267,6 @@ data class HistoryState( val sessions: List = emptyList(), val stats: Map = emptyMap(), val loading: Boolean = false, - val status: String = "" + val status: String = "", + val hasMore: Boolean = true ) 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 index 5d0a535..92a45bb 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/MediaIdentityPolicy.kt @@ -18,4 +18,35 @@ internal object MediaIdentityPolicy { 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 index 4e945f0..90a5561 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/service/ControllerConnectionService.kt @@ -174,13 +174,13 @@ class ControllerConnectionService : reconnectAttempt = 0 repository.setReconnectAttempt(0, reason) repository.recordSelfHealing("重新连接:$reason") - if (forceScan) { - transport.reconnect(reason) - } else { - scope.launch { - val address = repository.preferences.savedDeviceAddress.first() - startConnection(address, forceScan = address.isBlank()) - } + scope.launch { + val address = repository.preferences.savedDeviceAddress.first() + transport.restart( + savedAddress = address, + reason = reason, + forceScan = forceScan || address.isBlank() + ) } } diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt index 24d9e8e..5658300 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt @@ -193,6 +193,7 @@ private fun PlayerScreen( 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)) { artwork.bitmap?.let { bitmap -> @@ -231,6 +232,7 @@ private fun PlayerScreen( lyrics = lyrics, artwork = artwork, settings = settings, + reduceAnimations = reduceAnimations, positionMs = position, onOpenLyrics = { navController.navigate(Routes.LYRICS) }, onPrevious = viewModel::previous, @@ -333,6 +335,7 @@ private fun PlayerBody( lyrics: LyricsState, artwork: ArtworkState, settings: ControllerSettings, + reduceAnimations: Boolean, positionMs: Long, onOpenLyrics: () -> Unit, onPrevious: () -> Unit, @@ -363,6 +366,7 @@ private fun PlayerBody( lyrics, artwork, settings, + reduceAnimations, positionMs, onOpenLyrics, onPrevious, @@ -395,6 +399,7 @@ private fun PlayerBody( lyrics, artwork, settings, + reduceAnimations, positionMs, onOpenLyrics, onPrevious, @@ -476,6 +481,7 @@ private fun PlayerControlsPanel( lyrics: LyricsState, artwork: ArtworkState, settings: ControllerSettings, + reduceAnimations: Boolean, positionMs: Long, onOpenLyrics: () -> Unit, onPrevious: () -> Unit, @@ -509,7 +515,7 @@ private fun PlayerControlsPanel( Spectrum( playing = playback.isPlaying, positionMs = positionMs, - animated = settings.performanceMode != PlaybackPerformanceMode.POWER_SAVING + animated = !reduceAnimations ) CompactLyrics( lyrics = lyrics, @@ -822,21 +828,43 @@ private fun FullLyricsScreen( val lyrics by viewModel.lyrics.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() val position by viewModel.displayedPositionMs.collectAsStateWithLifecycle() + val displayLines = lyrics.lines val current = currentLineIndex( - lyrics.lines, + displayLines, position + settings.lyricOffsetMs, lyrics.currentLineIndex ) val listState = rememberLazyListState() var following by remember { mutableStateOf(true) } - LaunchedEffect(current, following) { - if (following && current >= 0 && current < lyrics.lines.size) { - listState.animateScrollToItem(current, scrollOffset = -180) + var programmaticScroll by remember { mutableStateOf(false) } + var resumeFollowToken by remember { mutableStateOf(0) } + LaunchedEffect(lyrics.trackId, lyrics.isFinal) { + if (lyrics.trackId.isNotBlank() && !lyrics.isFinal) { + viewModel.ensureFullLyrics() + } + } + LaunchedEffect(current, following, lyrics.isFinal) { + if (following && current >= 0 && current < displayLines.size) { + programmaticScroll = true + try { + val loadingHeaderOffset = if (lyrics.isFinal) 0 else 1 + listState.animateScrollToItem( + current + loadingHeaderOffset, + scrollOffset = -180 + ) + } finally { + programmaticScroll = false + } } } - LaunchedEffect(listState.isScrollInProgress) { - if (listState.isScrollInProgress) { + LaunchedEffect(listState.isScrollInProgress, programmaticScroll) { + if (listState.isScrollInProgress && !programmaticScroll) { following = false + resumeFollowToken += 1 + } + } + LaunchedEffect(resumeFollowToken) { + if (resumeFollowToken > 0) { delay(4_000L) following = true } @@ -854,7 +882,7 @@ private fun FullLyricsScreen( ) } ) { padding -> - if (lyrics.lines.isEmpty()) { + if (displayLines.isEmpty()) { EmptyState( title = lyricStageText(lyrics.loadingStage), message = lyrics.failureReason.ifBlank { "正在等待 Sony 解析 QQ 音乐歌词" }, @@ -873,8 +901,46 @@ private fun FullLyricsScreen( ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(lyrics.lines, key = LyricLine::index) { line -> - val selected = line.index == lyrics.lines.getOrNull(current)?.index + if (!lyrics.isFinal) { + item(key = "full-lyrics-loading") { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + lyricStageText(lyrics.loadingStage), + fontWeight = FontWeight.Medium + ) + Text( + if (settings.experienceMode != AppExperienceMode.DEBUG) { + "正在获取完整歌词,当前内容可先浏览" + } else { + when { + lyrics.partialFullLines.isNotEmpty() -> + "已接收 ${lyrics.partialFullLines.size} 行,完成后自动替换" + lyrics.windowLines.isNotEmpty() -> + "当前仅展示 ${lyrics.windowLines.size} 行歌词窗口" + else -> "正在等待 Sony 返回完整歌词" + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = viewModel::retryLyrics) { Text("重试") } + } + } + } + } + items(displayLines, key = LyricLine::index) { line -> + val selected = line.index == displayLines.getOrNull(current)?.index Column( Modifier.fillMaxWidth() .clip(RoundedCornerShape(14.dp)) @@ -1003,13 +1069,14 @@ private fun HistoryScreen( } } } - if (history.sessions.isNotEmpty()) { + if (history.sessions.isNotEmpty() && history.hasMore) { item { OutlinedButton( onClick = viewModel::loadMoreHistory, + enabled = !history.loading, modifier = Modifier.fillMaxWidth() ) { - Text("加载更多") + Text(if (history.loading) "正在加载…" else "加载更多") } } } @@ -1369,8 +1436,13 @@ private fun TrackDiagnosticScreen( DiagnosticValue("标题", playback.title) DiagnosticValue("歌手", playback.artist) DiagnosticValue("专辑", playback.album) - DiagnosticValue("歌词行数", lyrics.lines.size.toString()) + 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) diff --git a/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt index fa0525b..47642f7 100644 --- a/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt +++ b/ControllerApp/src/test/java/com/example/controllerapp/media/PlaybackClockPolicyTest.kt @@ -50,5 +50,34 @@ class PlaybackClockPolicyTest { 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/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 index ca107ce..c750b89 100644 --- a/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/MediaIdentityPolicyTest.kt @@ -21,4 +21,44 @@ class MediaIdentityPolicyTest { 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 + ) + ) + } } From c2c89f9772b16c7bb0c5db66d5ec70b819e3816c Mon Sep 17 00:00:00 2001 From: sqz Date: Sun, 2 Aug 2026 00:31:10 +0800 Subject: [PATCH 10/13] Improve multi-controller sync and karaoke lyrics --- .../controllerapp/protocol/LyricTimeline.kt | 108 ++ .../controllerapp/ui/ControllerAppUi.kt | 777 +++++++++++--- .../controllerapp/ui/LyricBrowsePolicy.kt | 36 + .../controllerapp/ui/NaturalSpectrumEngine.kt | 211 ++++ .../protocol/CapabilityAndTimelineTest.kt | 62 ++ .../ui/LyricVisualPoliciesTest.kt | 68 ++ .../IOSBleFeasibility/BLETestManager.swift | 19 +- .../PerformanceStabilityTests.swift | 16 + .../playeragent/ble/BleGattServerManager.kt | 970 ++++++++++++------ .../example/playeragent/ble/BleNotifyQueue.kt | 164 ++- .../ble/MediaTransferCoordinators.kt | 156 ++- .../playeragent/media/QrcLyricCacheManager.kt | 30 +- .../playeragent/media/QrcLyricModels.kt | 17 +- .../service/PlayerAgentForegroundService.kt | 50 + .../ble/BleNotifyQueuePolicyTest.kt | 44 +- .../ble/MediaTransferCoordinatorsTest.kt | 146 ++- .../media/QrcWordTimingParserTest.kt | 33 + README.md | 5 +- README.zh-CN.md | 5 +- docs/BLE_PROTOCOL.md | 15 +- docs/PROJECT_OVERVIEW.md | 8 +- 21 files changed, 2357 insertions(+), 583 deletions(-) create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ui/LyricBrowsePolicy.kt create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ui/NaturalSpectrumEngine.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/ui/LyricVisualPoliciesTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcWordTimingParserTest.kt diff --git a/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt index 2f3aae7..d0ac539 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/protocol/LyricTimeline.kt @@ -23,5 +23,113 @@ object LyricTimeline { 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/ui/ControllerAppUi.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt index 5658300..892b57d 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt @@ -2,12 +2,14 @@ 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 @@ -26,6 +28,7 @@ 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 @@ -79,25 +82,35 @@ 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 @@ -138,7 +151,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.max -import kotlin.math.sin private object Routes { const val PLAYER = "player" @@ -196,23 +208,12 @@ private fun PlayerScreen( val reduceAnimations by viewModel.reduceAnimations.collectAsStateWithLifecycle() Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - artwork.bitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } - Box( - Modifier.fillMaxSize().background( - Brush.verticalGradient( - listOf( - Color(0xe8090b10), - Color(0xb5090b10), - Color(0xff090b10) - ) - ) + ArtworkAtmosphereBackground( + bitmap = artwork.bitmap, + overlayColors = listOf( + Color(0xd8090b10), + Color(0xaa090b10), + Color(0xff090b10) ) ) CompositionLocalProvider( @@ -248,6 +249,35 @@ private fun PlayerScreen( } } +@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, @@ -512,8 +542,10 @@ private fun PlayerControlsPanel( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spectrum( - playing = playback.isPlaying, + NaturalSpectrum( + playback = playback, + lyrics = lyrics, + settings = settings, positionMs = positionMs, animated = !reduceAnimations ) @@ -565,23 +597,88 @@ private fun PlayerControlsPanel( } @Composable -private fun Spectrum(playing: Boolean, positionMs: Long, animated: Boolean) { - val phase = if (playing && animated) positionMs / 90f else 0f - Canvas(Modifier.fillMaxWidth().height(30.dp).padding(horizontal = 56.dp)) { - val bars = 20 - val spacing = size.width / bars - repeat(bars) { index -> - val amplitude = if (playing) { - 0.25f + (sin(phase + index * 0.8f) + 1f) * 0.28f - } else { - 0.18f - } - val barHeight = size.height * amplitude - drawLine( - color = Color.White.copy(alpha = 0.48f), - start = Offset(index * spacing + spacing / 2, size.height / 2 - barHeight / 2), - end = Offset(index * spacing + spacing / 2, size.height / 2 + barHeight / 2), - strokeWidth = max(2f, spacing * 0.32f) +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) ) } } @@ -602,11 +699,11 @@ private fun CompactLyrics( val previous = lyrics.lines.getOrNull(currentIndex - 1) val current = lyrics.lines.getOrNull(currentIndex) val next = lyrics.lines.getOrNull(currentIndex + 1) - Card( - onClick = onClick, - colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.055f)), - shape = RoundedCornerShape(20.dp), - modifier = Modifier.fillMaxWidth().height(178.dp).testTag("compact_lyrics") + Box( + modifier = Modifier.fillMaxWidth() + .height(172.dp) + .clickable(onClick = onClick) + .testTag("compact_lyrics") ) { Column( Modifier.fillMaxSize().padding(horizontal = 18.dp, vertical = 12.dp), @@ -623,9 +720,9 @@ private fun CompactLyrics( ) if (current != null) { WordHighlightedText(current, positionMs + settings.lyricOffsetMs) - auxiliaryText(current, settings.lyricDisplayMode)?.let { + lyricAuxiliaryLines(current, settings.lyricDisplayMode).take(2).forEach { text -> Text( - it, + text, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), fontSize = 12.sp, maxLines = 1, @@ -656,36 +753,41 @@ private fun CompactLyrics( } @Composable -private fun WordHighlightedText(line: LyricLine, positionMs: Long) { - val completed = line.words.indexOfLast { positionMs >= it.startMs + it.durationMs } - val active = line.words.indexOfLast { positionMs >= it.startMs }.coerceAtLeast(0) - val value = if (line.words.isEmpty()) { - buildAnnotatedString { append(line.text) } - } else { - buildAnnotatedString { - line.words.forEachIndexed { index, word -> - withStyle( - SpanStyle( - color = when { - index <= completed -> Color.White - index == active -> Color(0xffb9c7ff) - else -> Color.White.copy(alpha = 0.45f) - }, - fontWeight = if (index == active) FontWeight.Bold else FontWeight.Medium +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(word.text) - } + ) + ) { + append(character) } } } Text( value, - fontSize = if (line.text.length > 42) 16.sp else 20.sp, - lineHeight = if (line.text.length > 42) 21.sp else 26.sp, + modifier = modifier, + fontSize = fontSizeValue.sp, + lineHeight = lineHeightValue.sp, + fontWeight = FontWeight.SemiBold, maxLines = 3, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center + textAlign = textAlign ) } @@ -825,7 +927,10 @@ 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 @@ -836,148 +941,450 @@ private fun FullLyricsScreen( ) 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 { mutableStateOf(0) } + 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(current, following, lyrics.isFinal) { - if (following && current >= 0 && current < displayLines.size) { + 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 { - val loadingHeaderOffset = if (lyrics.isFinal) 0 else 1 listState.animateScrollToItem( - current + loadingHeaderOffset, - scrollOffset = -180 + current, + scrollOffset = -( + lyricViewportHeight / 2 - 56 - lyricTopPaddingPx + ).coerceAtLeast(0) ) } finally { programmaticScroll = false } } } - LaunchedEffect(listState.isScrollInProgress, programmaticScroll) { - if (listState.isScrollInProgress && !programmaticScroll) { + 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 } } - Scaffold( - topBar = { - ControllerTopBar( - title = "完整歌词", - navController = navController, - action = { - TextButton(onClick = { following = !following }) { - Text(if (following) "跟随中" else "恢复跟随") - } - } - ) - } - ) { padding -> - if (displayLines.isEmpty()) { - EmptyState( - title = lyricStageText(lyrics.loadingStage), - message = lyrics.failureReason.ifBlank { "正在等待 Sony 解析 QQ 音乐歌词" }, - action = "重新请求", - onAction = viewModel::retryLyrics, - modifier = Modifier.padding(padding) + + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + ArtworkAtmosphereBackground( + bitmap = artwork.bitmap, + overlayColors = listOf( + Color(0xe8090b10), + Color(0xc8090b10), + Color(0xff090b10) ) - } else { - LazyColumn( - state = listState, - contentPadding = androidx.compose.foundation.layout.PaddingValues( - top = padding.calculateTopPadding() + 120.dp, - bottom = padding.calculateBottomPadding() + 240.dp, - start = 22.dp, - end = 22.dp - ), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (!lyrics.isFinal) { - item(key = "full-lyrics-loading") { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ), - modifier = Modifier.fillMaxWidth() + ) + Scaffold( + containerColor = Color.Transparent, + topBar = { + ControllerTopBar( + title = "歌词", + navController = navController, + transparent = true, + action = { + TextButton( + onClick = { + following = true + selectedLinePosition = null + }, + enabled = !following ) { - Row( - Modifier.fillMaxWidth().padding(14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - lyricStageText(lyrics.loadingStage), - fontWeight = FontWeight.Medium - ) - Text( - if (settings.experienceMode != AppExperienceMode.DEBUG) { - "正在获取完整歌词,当前内容可先浏览" - } else { - when { - lyrics.partialFullLines.isNotEmpty() -> - "已接收 ${lyrics.partialFullLines.size} 行,完成后自动替换" - lyrics.windowLines.isNotEmpty() -> - "当前仅展示 ${lyrics.windowLines.size} 行歌词窗口" - else -> "正在等待 Sony 返回完整歌词" - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - TextButton(onClick = viewModel::retryLyrics) { Text("重试") } - } + 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) + ) } - items(displayLines, key = LyricLine::index) { line -> - val selected = line.index == displayLines.getOrNull(current)?.index - Column( - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .clickable { viewModel.seekTo(line.timeMs) } - .background( - if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - else Color.Transparent - ) - .padding(12.dp) - ) { - if (selected) { - WordHighlightedText(line, position + settings.lyricOffsetMs) - } else { - Text( - line.text, - fontSize = if (line.text.length > 48) 16.sp else 19.sp, - lineHeight = 25.sp, - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = if (line.timeMs < position) 0.48f else 0.76f + 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 + } + } ) - ) + } } - auxiliaryText(line, settings.lyricDisplayMode)?.let { - Text( - it, - fontSize = 13.sp, - lineHeight = 18.sp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + 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( @@ -1638,10 +2045,22 @@ private fun DiagnosticText(title: String, text: String) { 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, "返回") @@ -1716,11 +2135,31 @@ private fun currentLineIndex( serverIndex: Int ): Int = LyricTimeline.currentLinePosition(lines, positionMs, serverIndex) -private fun auxiliaryText(line: LyricLine, mode: LyricDisplayMode): String? = +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) - }.takeIf { it.isNotEmpty() }?.joinToString(" · ") + } + +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) 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/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt index 573ee97..1dc3c86 100644 --- a/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt +++ b/ControllerApp/src/test/java/com/example/controllerapp/protocol/CapabilityAndTimelineTest.kt @@ -6,6 +6,7 @@ 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 { @@ -64,5 +65,66 @@ class CapabilityAndTimelineTest { ) 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/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/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index ec51221..e79b09b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -39,6 +39,15 @@ 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 +enum CommandWriteSoftRecoveryPolicy { + static func shouldRetainPendingCommand(_ command: String) -> Bool { + // Pending commands have not reached CoreBluetooth yet. Dropping one here can + // strand higher-level deduplication state (for example lyric secondary modes), + // so the late-callback fence must retain every queued command. + true + } +} + struct LyricWord: Identifiable, Equatable { let id: Int let startMs: Int64 @@ -1940,8 +1949,11 @@ final class BLETestManager: NSObject, ObservableObject { volumeWriteInFlightSeq = nil } let pendingBefore = pendingCommandWrites.count - pendingCommandWrites.removeAll(where: { !$0.isControl }) - let droppedBackground = pendingBefore - pendingCommandWrites.count + pendingCommandWrites.removeAll(where: { + !CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand($0.cmd) + }) + let retainedBackground = pendingCommandWrites.filter { !$0.isControl }.count + let droppedPending = pendingBefore - pendingCommandWrites.count commandWriteRecoveryUntilMs = currentTimeMs() + COMMAND_WRITE_LATE_CALLBACK_FENCE_MS setConnectionHealth( .suspect, @@ -1950,7 +1962,8 @@ final class BLETestManager: NSObject, ObservableObject { ctrlLog( "[CTRL-iOS] write soft recovery seq=\(seq) cmd=\(cmd) " + "recentNotifyAgeMs=\(recentNotifyAgeMs) " + - "droppedBackground=\(droppedBackground)" + "retainedBackground=\(retainedBackground) " + + "droppedPending=\(droppedPending)" ) scheduleCommandWriteRecoveryFlush() } diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index 71db862..ee48dcb 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -4,6 +4,22 @@ import XCTest @testable import sonyMusic final class PerformanceStabilityTests: XCTestCase { + func testSoftWriteRecoveryRetainsQueuedMediaRequests() { + XCTAssertTrue( + CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand( + "GET_LYRIC_SECONDARY" + ) + ) + XCTAssertTrue( + CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand( + "ALBUM_ART_REQUEST" + ) + ) + XCTAssertTrue( + CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand("PING") + ) + } + func testCurrentWordOrderingFenceRejectsDuplicatesAndSmallRegression() { var fence = CurrentWordOrderingFence() XCTAssertTrue(fence.shouldAccept(generation: 7, sequence: 1, positionMs: 1_000)) 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 9c90e3c..f69ebbe 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -62,11 +62,14 @@ class BleGattServerManager( private val verboseLogger: (String) -> Unit = logger, private val advertisingStateProvider: () -> String = { "unknown" }, private val onAllClientsDisconnected: (reason: String) -> Unit = {}, + private val onControllerConnectionCountChanged: (connectedCount: Int) -> Unit = {}, private val onPlaybackUiState: (JSONObject) -> Unit = {} ) { 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() @@ -145,7 +148,9 @@ class BleGattServerManager( private var lastAlbumArtKey: String? = null @Volatile private var currentAlbumArtId: String? = null - private val albumArtRequestsInFlight = ConcurrentHashMap.newKeySet() + private val albumArtRequestsInFlight = Collections.newSetFromMap( + ConcurrentHashMap() + ) private val albumArtRequestCompletedAtMs = ConcurrentHashMap() @Volatile private var pendingAlbumArt: PendingAlbumArt? = null @@ -160,7 +165,9 @@ class BleGattServerManager( private val albumArtPendingRequests = ConcurrentHashMap() private val albumArtSourceRetryCounts = ConcurrentHashMap() - private val albumArtUnavailableProtocolIds = ConcurrentHashMap.newKeySet() + private val albumArtUnavailableProtocolIds = Collections.newSetFromMap( + ConcurrentHashMap() + ) private val albumArtCache = LinkedHashMap(ALBUM_ART_CACHE_CAPACITY * 2, 0.75f, true) private val encodedAlbumArtCache = @@ -168,13 +175,14 @@ class BleGattServerManager( private var encodedAlbumArtCacheBytes = 0 @Volatile private var currentAlbumArtPlaybackState: JSONObject? = null - @Volatile - private var pendingFullLyricsRequest: PendingFullLyricsRequest? = null - @Volatile - private var pendingLyricWindowRequest: PendingLyricWindowRequest? = null + private val pendingFullLyricsRequests = + ConcurrentHashMap() + private val pendingLyricWindowRequests = + ConcurrentHashMap() private val lyricsTransferCoordinator = LyricsTransferCoordinator() private val albumArtTransferCoordinator = AlbumArtTransferCoordinator() private val connectionCommandCoordinator = ConnectionCommandCoordinator() + private val multiControllerCommandGate = MultiControllerCommandGate() private var lastAutoPushSongKey: String? = null private var lastAutoPushPlaying: Boolean? = null @Volatile @@ -191,8 +199,9 @@ class BleGattServerManager( private var lastNotifySuccessAtMs = 0L @Volatile private var lastNotifyFailureAtMs = 0L - @Volatile - private var notifyFailureCount = 0 + private val notifyFailureCountByAddress = ConcurrentHashMap() + private val lastNotifySuccessAtByAddress = ConcurrentHashMap() + private val lastNotifyFailureAtByAddress = ConcurrentHashMap() @Volatile private var lastSubscribedAtMs = 0L @Volatile @@ -231,7 +240,7 @@ 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) @@ -240,15 +249,34 @@ class BleGattServerManager( 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) + lastNotifySuccessAtByAddress.remove(address) + lastNotifyFailureAtByAddress.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() @@ -257,19 +285,21 @@ class BleGattServerManager( forcedRediscoveryGenerationByAddress.clear() mtuByAddress.clear() notifyQueue.clearAllForDisconnect("unknown disconnect address") + notifyFailureCountByAddress.clear() + lastNotifySuccessAtByAddress.clear() + lastNotifyFailureAtByAddress.clear() + connectionCommandCoordinator.clear() + lyricsTransferCoordinator.clearRetryState() + albumArtTransferCoordinator.reset() + } + if (subscribedDevices.isEmpty()) { + clearSharedClientRuntimeState() } - lastAlbumArtKey = null - currentAlbumArtId = null - albumArtRequestsInFlight.clear() - albumArtRequestCompletedAtMs.clear() - albumArtPendingRequests.clear() - albumArtUnavailableProtocolIds.clear() - pendingAlbumArt = null - resetClientCapabilities() - lastAutoPushSongKey = null - lastAutoPushPlaying = null stopAutoPushIfUnused() logDisconnectDiagnostics(address) + if (connectionRemoved) { + onControllerConnectionCountChanged(connectedDeviceAddresses.size) + } if (connectedDeviceAddresses.isEmpty()) { onAllClientsDisconnected("gatt disconnected status=$status") } @@ -355,6 +385,16 @@ class BleGattServerManager( "parseCostMs=${SystemClock.elapsedRealtime() - parseStartedAtMs}" ) logger("[BLE-A] command received: $command") + // Capability fallback is timed from CCCD subscription, while command + // handling runs on a worker that can be busy with reconnect state sync. + // Record a valid capability frame at receipt time so the 250 ms timer + // cannot incorrectly downgrade this controller before its ACK is queued. + if (command == "CLIENT_CAPABILITIES" && device != null) { + connectionCommandCoordinator.accept( + device.address, + parseClientCapabilities(request) + ) + } if (responseNeeded) { val responseStartMs = SystemClock.elapsedRealtime() logger( @@ -392,7 +432,12 @@ class BleGattServerManager( "[CTRL-Sony] handle async begin seq=$seq cmd=$command " + "queueSnapshot=${controlQueueSnapshot()}" ) - runCatching { handleCommand(command, request, seq) } + val sourceDevice = device + if (sourceDevice == null) { + logger("[CTRL-Sony] command dropped reason=unknown source seq=$seq cmd=$command") + return@execute + } + runCatching { handleCommand(sourceDevice, command, request, seq) } .onFailure { exception -> logger( "[CTRL-Sony] handle async failed seq=$seq cmd=$command " + @@ -437,25 +482,56 @@ 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 = - connectionCommandCoordinator.negotiationGeneration + 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 (connectionCommandCoordinator.useLegacyIfCurrent( + device.address, capabilityGeneration ) ) { - logger("[BLE-A] client capability timeout, legacy fallback") + logger( + "[BLE-A] client capability timeout, legacy fallback " + + "device=${device.address}" + ) sendPendingAlbumArtIfAny() } }, CLIENT_CAPABILITY_WAIT_MS) @@ -466,15 +542,23 @@ class BleGattServerManager( ) == true -> { subscribedDevices.remove(device.address) notifyQueue.removeDevice(device.address) - lastAlbumArtKey = null - currentAlbumArtId = null - albumArtRequestsInFlight.clear() - albumArtRequestCompletedAtMs.clear() - albumArtUnavailableProtocolIds.clear() - pendingAlbumArt = null - resetClientCapabilities() - lastAutoPushSongKey = null - lastAutoPushPlaying = null + notifyFailureCountByAddress.remove(device.address) + lastNotifySuccessAtByAddress.remove(device.address) + lastNotifyFailureAtByAddress.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() } @@ -638,8 +722,12 @@ class BleGattServerManager( albumArtRequestsInFlight.clear() albumArtRequestCompletedAtMs.clear() pendingAlbumArt = null + pendingFullLyricsRequests.clear() + pendingLyricWindowRequests.clear() lyricsTransferCoordinator.reset() albumArtTransferCoordinator.reset() + connectionCommandCoordinator.clear() + multiControllerCommandGate.reset() albumArtTaskGeneration += 1 albumArtFastPathTask?.cancel(true) albumArtFastPathTask = null @@ -660,6 +748,9 @@ class BleGattServerManager( mtuByAddress.clear() recentConnectionCallbacks.clear() recentMtuCallbacks.clear() + notifyFailureCountByAddress.clear() + lastNotifySuccessAtByAddress.clear() + lastNotifyFailureAtByAddress.clear() statusCharacteristic = null mediaExecutionContext.close() playbackStateBuffer.shutdown() @@ -713,13 +804,14 @@ 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 + currentNotifyFailureCount >= NOTIFY_FAILURE_SUSPECT_THRESHOLD -> BleHealthState.SUSPECT subscribedWithoutSuccessOlderThan(SUSPECT_NO_SUCCESS_HEARTBEAT_MS) -> BleHealthState.SUSPECT subscribedCount > 0 && isSuccessHeartbeatStale( @@ -745,7 +837,7 @@ class BleGattServerManager( lastCommandSuccessAt = lastCommandSuccessAtMs, lastNotifySuccessAt = lastNotifySuccessAtMs, lastNotifyFailureAt = lastNotifyFailureAtMs, - notifyFailureCount = notifyFailureCount, + notifyFailureCount = currentNotifyFailureCount, lastRecoveryAt = lastRecoveryAt, recoveryCount = recoveryCount, reason = reason @@ -761,12 +853,30 @@ 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) + lastNotifySuccessAtByAddress.remove(address) + lastNotifyFailureAtByAddress.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 @@ -794,7 +904,39 @@ class BleGattServerManager( ) } - fun notifyFailureCount(): Int = notifyFailureCount + 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 + } + } + + @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 @@ -810,22 +952,38 @@ class BleGattServerManager( 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}" ) @@ -878,31 +1036,55 @@ 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 when (command) { "PLAY_PAUSE", "NEXT", "PREVIOUS", "VOLUME_UP", "VOLUME_DOWN" -> { - cancelHistoryTransfersForControl(command) + cancelHistoryTransfersForControl(sourceAddress, command) + if (command in MULTI_CONTROLLER_DEDUP_COMMANDS && + !multiControllerCommandGate.shouldExecute( + command, + sourceAddress, + SystemClock.elapsedRealtime() + ) + ) { + logger( + "[MultiController] duplicate control suppressed " + + "cmd=$command device=$sourceAddress" + ) + sendPlaybackState() + return + } if (command == "NEXT") { playbackStateReader.notifyManualNextHint(seq) } mediaCommandExecutor.execute(command, seq) + if (command in MULTI_CONTROLLER_DEDUP_COMMANDS) { + scheduleAuthoritativeStateBroadcast(command) + } } "SEEK_TO" -> { - cancelHistoryTransfersForControl(command) + 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) + cancelHistoryTransfersForControl(sourceAddress, command) val requestedVolume = request.optInt("volume") val volumeStartedAtMs = SystemClock.elapsedRealtime() logger("[CTRL-Sony] volume begin seq=${seq ?: "unknown"} cmd=SET_VOLUME") @@ -924,35 +1106,38 @@ class BleGattServerManager( ) } "GET_PLAYBACK_STATE" -> { - sendPlaybackState(includeAlbumArt = true) + sendPlaybackStateTo(sourceDevice, includeAlbumArt = true) } - "PING" -> sendPong(request) - "GET_VOLUME" -> mediaCommandExecutor.sendVolumeState() + "PING" -> sendPong(sourceDevice, request) + "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) + "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") } } - private fun cancelHistoryTransfersForControl(command: String) { - if (notifyQueue.hasJobTypeActiveOrQueued(PLAY_HISTORY_JOB_TYPE) || - notifyQueue.hasJobTypeActiveOrQueued(PLAY_STATS_JOB_TYPE) || - notifyQueue.hasJobTypeActiveOrQueued(LYRIC_SECONDARY_JOB_TYPE) + 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") @@ -962,22 +1147,28 @@ class BleGattServerManager( PLAY_STATS_JOB_TYPE, LYRIC_SECONDARY_JOB_TYPE ), - "control command" + "control command", + sourceAddress ) } } - private fun handleClientCapabilities(request: JSONObject) { - val capabilities = ConnectionCommandCoordinator.Capabilities( - protocolVersion = request.optInt("protocolVersion", 1).coerceAtLeast(1), - binaryAlbumArt = request.optBoolean("albumArtBinary", false), - fullLyricsZlib = request.optBoolean("fullLyricsZlib", false), - lyricWindow = request.optBoolean("lyricWindow", false), - ping = request.optBoolean("ping", false), - transferRetry = request.optBoolean("transferRetry", false), - negotiated = true - ) - connectionCommandCoordinator.accept(capabilities) + private fun scheduleAuthoritativeStateBroadcast(command: String) { + val delayMs = when (command) { + "PLAY_PAUSE" -> 100L + "SEEK_TO" -> 150L + "NEXT", "PREVIOUS" -> 220L + else -> 120L + } + albumArtHandler.postDelayed({ + if (!started || subscribedDevices.isEmpty()) return@postDelayed + sendPlaybackState(includeAlbumArt = command == "NEXT" || command == "PREVIOUS") + }, delayMs) + } + + private fun handleClientCapabilities(device: BluetoothDevice, request: JSONObject) { + val capabilities = parseClientCapabilities(request) + connectionCommandCoordinator.accept(device.address, capabilities) logger( "[BLE-A] client capability protocolVersion=${capabilities.protocolVersion} " + "albumArtBinary=${capabilities.binaryAlbumArt} " + @@ -985,31 +1176,63 @@ class BleGattServerManager( "lyricWindow=${capabilities.lyricWindow} ping=${capabilities.ping} " + "transferRetry=${capabilities.transferRetry}" ) - 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) - ) - } + 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) + ) sendPendingAlbumArtIfAny() } - private fun resetClientCapabilities() { - connectionCommandCoordinator.invalidate() + private fun parseClientCapabilities( + request: JSONObject + ): ConnectionCommandCoordinator.Capabilities { + return ConnectionCommandCoordinator.Capabilities( + protocolVersion = request.optInt("protocolVersion", 1).coerceAtLeast(1), + binaryAlbumArt = request.optBoolean("albumArtBinary", false), + fullLyricsZlib = request.optBoolean("fullLyricsZlib", false), + lyricWindow = request.optBoolean("lyricWindow", false), + ping = request.optBoolean("ping", false), + transferRetry = request.optBoolean("transferRetry", false), + negotiated = true + ) + } + + private fun resetClientCapabilities(address: String) { + connectionCommandCoordinator.invalidate(address) + lyricsTransferCoordinator.resetAddress(address) + albumArtTransferCoordinator.resetAddress(address) + pendingFullLyricsRequests.remove(address) + pendingLyricWindowRequests.remove(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() + lastAutoPushSongKey = null + lastAutoPushPlaying = null } - private fun sendPong(request: JSONObject) { - val device = subscribedDevices.values.firstOrNull() ?: return + private fun sendPong(device: BluetoothDevice, request: JSONObject) { sendShortJsonIfFits( device = device, type = "pong", @@ -1032,6 +1255,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() @@ -1053,10 +1302,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")} " + @@ -1092,7 +1345,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}" @@ -1110,9 +1363,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() @@ -1279,13 +1532,20 @@ 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 + } + private fun sendStatusMessageTo(device: BluetoothDevice, message: String): Boolean { + if (!subscribedDevices.containsKey(device.address)) return false val value = message.toByteArray(Charsets.UTF_8) val maximumPayload = maximumPayloadFor(device) if (value.size > maximumPayload) { @@ -1302,7 +1562,7 @@ class BleGattServerManager( messageType == "trackInfo" || messageType == "volumeState" || messageType == "currentWord") && - notifyQueue.hasLongJobActiveOrQueued() + notifyQueue.hasLongJobActiveOrQueued(device.address) ) { notifyQueue.setLatestInterleavedShort( device = device, @@ -1605,25 +1865,20 @@ 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 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( + JSONObject(compactState.toString()), + maximumPayloadFor(device) + ) + sendStatusMessageTo(device, fitted.toString()) || anySent } - val sent = sendStatusMessage(compactState.toString()) if (sent) { TrackCapabilityTracker.onPlaybackStateSent( trackId = buildAlbumArtProtocolId(source), @@ -1633,6 +1888,17 @@ class BleGattServerManager( return sent } + private fun sendCompactPlaybackStateTo( + device: BluetoothDevice, + source: JSONObject + ): Boolean { + val fitted = fitCompactPlaybackState( + compactPlaybackStatePayload(source), + maximumPayloadFor(device) + ) + return sendStatusMessageTo(device, fitted.toString()) + } + private fun compactPlaybackStatePayload(source: JSONObject): JSONObject { return JSONObject() .put("type", "playbackState") @@ -1661,10 +1927,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) { @@ -1672,10 +1943,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( @@ -1898,7 +2169,10 @@ class BleGattServerManager( } } - private fun enqueueAlbumArtOfferOrPending(pending: PendingAlbumArt) { + private fun enqueueAlbumArtOfferOrPending( + pending: PendingAlbumArt, + targetDevice: BluetoothDevice? = null + ) { if (!ALBUM_ART_ENABLED) { return } @@ -1906,26 +2180,16 @@ class BleGattServerManager( logger("[AlbumArt] offer skipped reason=empty protocol id") return } - if (!connectionCommandCoordinator.capabilities.negotiated && - 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 +2199,60 @@ class BleGattServerManager( albumArtState = "offer", logger = logger ) - albumArtRequestsInFlight.removeIf { - !it.startsWith("$protocolId|") - } - albumArtRequestCompletedAtMs.keys.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}") + notifyQueue.enqueueShort( + device = device, + type = "albumArtOffer", + value = offer, + delayAfterMs = SHORT_MESSAGE_DELAY_MS + ) + } } @Synchronized @@ -1968,6 +2260,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) } @@ -2383,7 +2678,7 @@ 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") @@ -2405,12 +2700,12 @@ class BleGattServerManager( return } if (quality == AlbumArtQuality.HQ) { - if (!connectionCommandCoordinator.capabilities.binaryAlbumArt) { + if (!connectionCommandCoordinator.capabilities(device.address).binaryAlbumArt) { logger("[AlbumArtHQ] request skipped reason=binary unsupported") return } } - val requestKey = "$protocolId|${quality.wireValue}" + val requestKey = "${device.address}|$protocolId|${quality.wireValue}" val forceRefresh = request.optBoolean("forceRefresh", false) val nowMs = SystemClock.elapsedRealtime() if (!AlbumArtRequestPolicy.shouldAllowCompletedRequest( @@ -2448,11 +2743,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)) @@ -2498,7 +2792,7 @@ class BleGattServerManager( ) val useBinaryAlbumArt = (quality == AlbumArtQuality.PREVIEW || quality == AlbumArtQuality.HQ) && - connectionCommandCoordinator.capabilities.binaryAlbumArt + connectionCommandCoordinator.capabilities(device.address).binaryAlbumArt val maximumPayload = if (useBinaryAlbumArt) { albumArtMaximumPayloadFor(device) } else { @@ -2541,6 +2835,7 @@ class BleGattServerManager( val albumPackets = preparedAlbumArt.packets if (useBinaryAlbumArt) { rememberAlbumArtBinaryTransfer( + device = device, protocolId = protocolId, quality = quality, packets = albumPackets.packets @@ -3378,9 +3673,9 @@ class BleGattServerManager( 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) @@ -3472,13 +3767,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") @@ -3490,13 +3785,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 { @@ -3505,15 +3801,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 } @@ -3527,7 +3823,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( @@ -3552,7 +3848,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( @@ -3567,7 +3863,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( @@ -3606,8 +3902,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() @@ -3627,7 +3923,7 @@ class BleGattServerManager( ) } - private fun sendPlayHistoryPage(request: JSONObject) { + private fun sendPlayHistoryPage(device: BluetoothDevice, request: JSONObject) { val requestId = request.optString("requestId").ifBlank { "history-${System.currentTimeMillis()}" } @@ -3640,7 +3936,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) @@ -3658,14 +3954,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) @@ -3683,17 +3979,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( @@ -3705,18 +4001,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") @@ -3726,7 +4023,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) } @@ -3755,7 +4057,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 @@ -3769,7 +4071,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( @@ -3791,7 +4093,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( @@ -3807,7 +4109,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( @@ -3846,8 +4148,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) @@ -3947,12 +4254,12 @@ class BleGattServerManager( }) } - private fun sendFullLyrics(request: JSONObject) { + private fun sendFullLyrics(device: BluetoothDevice, request: JSONObject) { if (request.optBoolean("forceRefresh", false)) { val started = playbackStateReader.manualRefreshCurrentLyric() logger("[LyricRetry] BLE force refresh requested started=$started") } - val device = subscribedDevices.values.firstOrNull() ?: run { + if (!subscribedDevices.containsKey(device.address)) { val requestedTrackId = request.optString("trackId") if (requestedTrackId.isNotBlank()) { lyricTrace( @@ -3962,12 +4269,12 @@ class BleGattServerManager( extra = mapOf("status" to "no_subscriber") ) } - logger("[FullLyrics] send skipped: no iPhone subscriber") + logger("[FullLyrics] send skipped: controller unsubscribed") return } val buildStartedAtMs = SystemClock.elapsedRealtime() val requestedTrackId = request.optString("trackId") - if (notifyQueue.hasJobTypeActiveOrQueued(FULL_LYRICS_JOB_TYPE)) { + if (notifyQueue.hasJobTypeActiveOrQueued(FULL_LYRICS_JOB_TYPE, device.address)) { logger( "[FullLyrics] request skipped reason=transfer_in_progress " + "trackId=$requestedTrackId" @@ -4055,6 +4362,7 @@ class BleGattServerManager( val diagnostic = playbackStateReader.lyricDiagnosticSnapshot() if (!readyGate.lyricsReady || candidateLines.isEmpty()) { rememberPendingFullLyricsRequest( + device = device, request = request, requestedTrackId = requestedTrackId, protocolTrackId = trackId, @@ -4076,9 +4384,10 @@ class BleGattServerManager( 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" @@ -4091,9 +4400,10 @@ 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" @@ -4131,11 +4441,16 @@ 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 negotiatedCapabilities = connectionCommandCoordinator.capabilities + val negotiatedCapabilities = connectionCommandCoordinator.capabilities(device.address) if (requestedFormat == FULL_LYRICS_ZLIB_FORMAT && negotiatedCapabilities.negotiated && negotiatedCapabilities.protocolVersion >= SERVER_PROTOCOL_VERSION && @@ -4154,7 +4469,12 @@ class BleGattServerManager( buildStartedAtMs = buildStartedAtMs ) ) { - clearMatchingPendingFullLyrics(requestedTrackId, trackId, runtimeGeneration) + clearMatchingPendingFullLyrics( + device.address, + requestedTrackId, + trackId, + runtimeGeneration + ) return } val packets = mutableListOf() @@ -4477,7 +4797,8 @@ class BleGattServerManager( 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) @@ -4564,10 +4885,10 @@ class BleGattServerManager( return hash } - private fun sendLyricWindow(request: JSONObject) { - val capabilities = connectionCommandCoordinator.capabilities + private fun sendLyricWindow(device: BluetoothDevice, request: JSONObject) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) if (!capabilities.negotiated || !capabilities.lyricWindow) return - val device = subscribedDevices.values.firstOrNull() ?: return + if (!subscribedDevices.containsKey(device.address)) return val source = playbackSourceForRequestedTrack(request.optString("trackId")) val trackId = buildAlbumArtProtocolId(source) val requestedTrackId = request.optString("trackId") @@ -4590,7 +4911,8 @@ class BleGattServerManager( ) ) { val reason = playbackStateReader.lyricUnavailableReason() - pendingLyricWindowRequest = PendingLyricWindowRequest( + pendingLyricWindowRequests[device.address] = PendingLyricWindowRequest( + ownerAddress = device.address, request = JSONObject(request.toString()), requestedTrackId = requestedTrackId, protocolTrackId = trackId, @@ -4602,11 +4924,12 @@ class BleGattServerManager( ) logger( "[LyricWindow] pending trackId=$trackId " + - "generation=${pendingLyricWindowRequest?.generation} reason=$reason" + "generation=${pendingLyricWindowRequests[device.address]?.generation} " + + "device=${device.address} reason=$reason" ) return } - pendingLyricWindowRequest = null + pendingLyricWindowRequests.remove(device.address) val positionMs = request.optLong("positionMs", source.optLong("position", 0L)) val currentIndex = findCurrentLyricIndex(lines, positionMs) val first = (currentIndex - 2).coerceAtLeast(0) @@ -4715,16 +5038,16 @@ class BleGattServerManager( ) } - private fun retryTransfer(request: JSONObject) { - val capabilities = connectionCommandCoordinator.capabilities + private fun retryTransfer(device: BluetoothDevice, request: JSONObject) { + val capabilities = connectionCommandCoordinator.capabilities(device.address) if (!capabilities.negotiated || !capabilities.transferRetry) return - val device = subscribedDevices.values.firstOrNull() ?: return + if (!subscribedDevices.containsKey(device.address)) return val transferId = request.optString("transferId") - albumArtTransferCoordinator.retained(transferId)?.let { albumTransfer -> + albumArtTransferCoordinator.retained(device.address, transferId)?.let { albumTransfer -> retryAlbumArtTransfer(device, request, albumTransfer) return } - val transfer = lyricsTransferCoordinator.retained(transferId) + val transfer = lyricsTransferCoordinator.retained(device.address, transferId) if (transfer == null || transfer.trackId != request.optString("trackId") || transfer.expiresAtMs < SystemClock.elapsedRealtime() || @@ -4773,6 +5096,7 @@ class BleGattServerManager( } private fun rememberAlbumArtBinaryTransfer( + device: BluetoothDevice, protocolId: String, quality: AlbumArtQuality, packets: List @@ -4790,7 +5114,8 @@ class BleGattServerManager( start = start, chunks = packets.filter { it.type == "albumArtBinaryChunk" }, end = end, - expiresAtMs = SystemClock.elapsedRealtime() + ALBUM_ART_RETRY_TTL_MS + expiresAtMs = SystemClock.elapsedRealtime() + ALBUM_ART_RETRY_TTL_MS, + ownerAddress = device.address )) } @@ -4900,9 +5225,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") @@ -5210,6 +5535,7 @@ class BleGattServerManager( } private fun rememberPendingFullLyricsRequest( + device: BluetoothDevice, request: JSONObject, requestedTrackId: String, protocolTrackId: String, @@ -5220,7 +5546,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, @@ -5247,11 +5574,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)) @@ -5259,26 +5587,30 @@ 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) } } @@ -5298,84 +5630,94 @@ class BleGattServerManager( // runtime cache instead of waiting for a later playback poll. scheduleCurrentWordPush(0L) } - val pending = pendingFullLyricsRequest ?: return - val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || - isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) - val generationMatches = pending.generation <= 0L || - snapshot.generation <= 0L || - pending.generation == snapshot.generation - if (!trackMatches || !generationMatches) { - pendingFullLyricsRequest = null - logger( - "[LyricsState] pending request dropped reason=stale " + - "readyTrackId=${snapshot.trackId} pendingTrackId=${pending.protocolTrackId} " + - "readyGeneration=${snapshot.generation} pendingGeneration=${pending.generation}" - ) - return - } - pendingFullLyricsRequest = null - lyricCommandExecutor.execute { - if (!started) { - return@execute + pendingFullLyricsRequests.entries.toList().forEach { (ownerAddress, pending) -> + val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || + isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) + val generationMatches = pending.generation <= 0L || + snapshot.generation <= 0L || + pending.generation == snapshot.generation + if (!trackMatches || !generationMatches) { + pendingFullLyricsRequests.remove(ownerAddress, pending) + logger( + "[LyricsState] pending request dropped reason=stale " + + "device=$ownerAddress readyTrackId=${snapshot.trackId} " + + "pendingTrackId=${pending.protocolTrackId}" + ) + return@forEach } - logger( - "[LyricsState] pending request flushed trackId=${pending.protocolTrackId} " + - "lines=${snapshot.lineCount} waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" - ) - logger( - "[Lyrics] pending flushed trackId=${pending.protocolTrackId} " + - "generation=${snapshot.generation}" - ) - lyricTrace( - stage = "pendingFlush", - trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, - songKey = snapshot.songKey, - generation = snapshot.generation, - extra = mapOf( - "protocolTrackId" to pending.protocolTrackId, - "waitMs" to (SystemClock.elapsedRealtime() - pending.createdAtMs).toString() + val device = subscribedDevices[ownerAddress] ?: run { + pendingFullLyricsRequests.remove(ownerAddress, pending) + return@forEach + } + pendingFullLyricsRequests.remove(ownerAddress, pending) + lyricCommandExecutor.execute { + if (!started || !subscribedDevices.containsKey(ownerAddress)) { + return@execute + } + logger( + "[LyricsState] pending request flushed trackId=${pending.protocolTrackId} " + + "device=$ownerAddress lines=${snapshot.lineCount} " + + "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" ) - ) - sendFullLyrics( - JSONObject(pending.request.toString()) - .put("_lyricsReadyFlush", true) - ) + lyricTrace( + stage = "pendingFlush", + trackId = pending.requestedTrackId.ifBlank { pending.protocolTrackId }, + songKey = snapshot.songKey, + generation = snapshot.generation, + extra = mapOf( + "protocolTrackId" to pending.protocolTrackId, + "waitMs" to (SystemClock.elapsedRealtime() - pending.createdAtMs).toString() + ) + ) + sendFullLyrics( + device, + JSONObject(pending.request.toString()).put("_lyricsReadyFlush", true) + ) + } } } private fun flushPendingLyricWindow(snapshot: LyricsReadyGateSnapshot) { - val pending = pendingLyricWindowRequest ?: return - val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || - isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) - val generationMatches = pending.generation <= 0L || - snapshot.generation <= 0L || - pending.generation == snapshot.generation - if (!trackMatches || !generationMatches) { - pendingLyricWindowRequest = null - logger( - "[LyricWindow] pending dropped reason=stale " + - "readyTrackId=${snapshot.trackId} pendingTrackId=${pending.protocolTrackId}" - ) - return - } - if (!snapshot.lyricsReady) { - if (snapshot.state.name == "FAILED") { - pendingLyricWindowRequest = null - subscribedDevices.values.firstOrNull()?.let { device -> - sendLyricWindowUnavailable(device, pending.protocolTrackId, snapshot.reason) + pendingLyricWindowRequests.entries.toList().forEach { (ownerAddress, pending) -> + val trackMatches = isSameProtocolTrackId(snapshot.trackId, pending.protocolTrackId) || + isSameProtocolTrackId(snapshot.trackId, pending.requestedTrackId) + val generationMatches = pending.generation <= 0L || + snapshot.generation <= 0L || + pending.generation == snapshot.generation + if (!trackMatches || !generationMatches) { + pendingLyricWindowRequests.remove(ownerAddress, pending) + logger( + "[LyricWindow] pending dropped reason=stale " + + "device=$ownerAddress readyTrackId=${snapshot.trackId} " + + "pendingTrackId=${pending.protocolTrackId}" + ) + return@forEach + } + val device = subscribedDevices[ownerAddress] ?: run { + pendingLyricWindowRequests.remove(ownerAddress, pending) + return@forEach + } + if (!snapshot.lyricsReady) { + if (snapshot.state.name == "FAILED") { + pendingLyricWindowRequests.remove(ownerAddress, pending) + sendLyricWindowUnavailable( + device, + pending.protocolTrackId, + snapshot.reason + ) } + return@forEach + } + pendingLyricWindowRequests.remove(ownerAddress, pending) + lyricCommandExecutor.execute { + if (!started || !subscribedDevices.containsKey(ownerAddress)) return@execute + logger( + "[LyricWindow] pending flushed trackId=${pending.protocolTrackId} " + + "device=$ownerAddress lines=${snapshot.lineCount} " + + "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" + ) + sendLyricWindow(device, JSONObject(pending.request.toString())) } - return - } - pendingLyricWindowRequest = null - lyricCommandExecutor.execute { - if (!started) return@execute - logger( - "[LyricWindow] pending flushed trackId=${pending.protocolTrackId} " + - "lines=${snapshot.lineCount} " + - "waitMs=${SystemClock.elapsedRealtime() - pending.createdAtMs}" - ) - sendLyricWindow(JSONObject(pending.request.toString())) } } @@ -5543,9 +5885,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 -> @@ -5670,6 +6019,11 @@ class BleGattServerManager( private const val CURRENT_WORD_TRACK_SWITCH_DELAY_MS = 450L private const val CURRENT_WORD_DRIFT_CORRECTION_MS = 500L private const val SERVER_PROTOCOL_VERSION = 2 + private val MULTI_CONTROLLER_DEDUP_COMMANDS = setOf( + "PLAY_PAUSE", + "NEXT", + "PREVIOUS" + ) private const val CLIENT_CAPABILITY_WAIT_MS = 250L private const val RECONNECT_SYNC_COOLDOWN_MS = 1_000L private const val RECONNECT_SYNC_CURRENT_WORD_DELAY_MS = 350L @@ -5798,6 +6152,7 @@ class BleGattServerManager( ) private data class PendingFullLyricsRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, @@ -5806,6 +6161,7 @@ class BleGattServerManager( ) private data class PendingLyricWindowRequest( + val ownerAddress: String, val request: JSONObject, val requestedTrackId: String, val protocolTrackId: String, 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 a07d7fd..add1177 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt @@ -39,14 +39,20 @@ class BleNotifyQueue( private val logger: (String) -> Unit, private val localOnlyLogger: (String) -> Unit, private val verboseLogger: (String) -> Unit, - private val onNotifySuccess: (type: String) -> Unit = {}, - private val onNotifyFailure: (type: String, status: Int, reason: String) -> Unit = { _, _, _ -> } + private val onNotifySuccess: (deviceAddress: String, type: String) -> Unit = { _, _ -> }, + private val onNotifyFailure: ( + deviceAddress: String, + type: String, + status: Int, + reason: String + ) -> Unit = { _, _, _, _ -> } ) { private val 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 @@ -177,7 +183,7 @@ class BleNotifyQueue( value: ByteArray, delayAfterMs: Long ) { - latestInterleavedPackets[type] = InterleavedPacket( + latestInterleavedPackets[interleavedPacketKey(device.address, type)] = InterleavedPacket( device = device, packet = Packet( type = type, @@ -192,30 +198,55 @@ 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) { - runOnQueueThread { cancelJobTypesOnQueue(types, reason) } + fun cancelJobTypes( + types: Set, + reason: String, + deviceAddress: String? = null + ) { + runOnQueueThread { cancelJobTypesOnQueue(types, reason, deviceAddress) } } @Synchronized - private fun cancelJobTypesOnQueue(types: Set, reason: String) { - val removedJobs = jobs.filter { it.type in types } - jobs.removeAll { it.type in types } + 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 - val activeJobRemoved = current != null && current.type in types - if (removedJobs.isNotEmpty() || activeJobRemoved) { - // Cancel stale packet/timeout work before terminal callbacks are - // posted. Removing callbacks afterwards would silently discard - // the deferred cleanup which releases manager transfer state. - handler.removeCallbacksAndMessages(null) + val activeJobRemoved = current != null && matches(current) + if (activeJobRemoved) { + // Never clear the HandlerThread wholesale here: another device + // may already have queued an enqueue/callback action. The stale + // sendNext runnables are harmless and the active timeout is the + // only callback that belongs to this cancelled notification. + cancelNotifyTimeout() } removedJobs.forEach { failJob(it, reason) } if (activeJobRemoved && current != null) { @@ -251,6 +282,14 @@ 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 @@ -270,12 +309,12 @@ class BleNotifyQueue( interleavedPacketInFlight = false interleavedPacketType = null if (status == BluetoothGatt.GATT_SUCCESS) { - dispatchNotifySuccess(type) + dispatchNotifySuccess(callbackAddress, type) recordAdaptiveSuccess(type, callbackAddress, callbackRttMs) } else { recordAdaptiveFailure(type, callbackAddress) logger("[BleNotifyQueue] interleaved notify failed type=$type status=$status") - dispatchNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(callbackAddress, type, status, "callback_failed") } handler.postDelayed({ sendNextPacket() }, interleavedDelayAfterMs) interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -289,11 +328,11 @@ class BleNotifyQueue( "[BleNotifyQueue] notify failed " + "type=$type status=$status" ) - dispatchNotifyFailure(type, status, "callback_failed") + dispatchNotifyFailure(callbackAddress, type, status, "callback_failed") abortActiveJob(job, "notify callback failed type=$type status=$status") return } else { - dispatchNotifySuccess(packet?.type ?: job.type) + dispatchNotifySuccess(callbackAddress, packet?.type ?: job.type) packet?.let { recordAdaptiveSuccess(it.type, callbackAddress, callbackRttMs) } @@ -316,14 +355,16 @@ class BleNotifyQueue( jobs.removeAll { it.device.address == address } latestInterleavedPackets.entries.removeAll { it.value.device.address == address } linkProfiles.remove(address) - val activeDeviceRemoved = activeJob?.device?.address == address - if (interleavedPacketInFlight) { + 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 (removedJobs.isNotEmpty() || activeDeviceRemoved) { - handler.removeCallbacksAndMessages(null) + if (activeDeviceRemoved) { + cancelNotifyTimeout() } removedJobs.forEach { failJob(it, "device disconnected") } if (activeDeviceRemoved) { @@ -351,6 +392,7 @@ class BleNotifyQueue( jobs.clear() latestInterleavedPackets.clear() linkProfiles.clear() + lastServedDeviceByPriority.clear() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -379,6 +421,7 @@ class BleNotifyQueue( jobs.clear() latestInterleavedPackets.clear() linkProfiles.clear() + lastServedDeviceByPriority.clear() interleavedPacketInFlight = false interleavedPacketType = null interleavedDelayAfterMs = SHORT_MESSAGE_DELAY_MS @@ -525,6 +568,7 @@ class BleNotifyQueue( ) if (!requested) { dispatchNotifyFailure( + interleaved.device.address, interleaved.packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -543,7 +587,9 @@ class BleNotifyQueue( } return } else { - latestInterleavedPackets[interleaved.packet.type] = interleaved + latestInterleavedPackets[ + interleavedPacketKey(interleaved.device.address, interleaved.packet.type) + ] = interleaved } } } @@ -642,6 +688,7 @@ class BleNotifyQueue( recordAdaptiveFailure(packet.type, job.device.address) logger("[BleNotifyQueue] notify request rejected type=${packet.type}") dispatchNotifyFailure( + job.device.address, packet.type, NOTIFY_REQUEST_REJECTED_STATUS, "request_rejected" @@ -675,6 +722,7 @@ class BleNotifyQueue( val address = activeNotifyDeviceAddress ?: job?.device?.address.orEmpty() recordAdaptiveFailure(type, address) dispatchNotifyFailure( + address, type, NOTIFY_CALLBACK_TIMEOUT_STATUS, "callback_timeout" @@ -737,11 +785,13 @@ class BleNotifyQueue( "volumeState", "currentWord" ) - val selectedKey = preferredTypes.firstOrNull { type -> - latestInterleavedPackets[type]?.device?.address == deviceAddress - } ?: latestInterleavedPackets.entries.firstOrNull { - it.value.device.address == deviceAddress - }?.key ?: return null + val selectedKey = preferredTypes + .map { type -> interleavedPacketKey(deviceAddress, type) } + .firstOrNull(latestInterleavedPackets::containsKey) + ?: latestInterleavedPackets.entries.firstOrNull { + it.value.device.address == deviceAddress + }?.key + ?: return null return latestInterleavedPackets.remove(selectedKey) } @@ -832,18 +882,28 @@ class BleNotifyQueue( ) } - private fun dispatchNotifySuccess(type: String) { - handler.post { onNotifySuccess(type) } + private fun dispatchNotifySuccess(deviceAddress: String, type: String) { + handler.post { onNotifySuccess(deviceAddress, type) } } - private fun dispatchNotifyFailure(type: String, status: Int, reason: String) { - handler.post { onNotifyFailure(type, status, reason) } + private fun dispatchNotifyFailure( + deviceAddress: String, + type: String, + status: Int, + reason: String + ) { + handler.post { onNotifyFailure(deviceAddress, type, status, reason) } } private fun pollNextJob(): SendJob? { val priority = jobs.minOfOrNull { it.priority.rank } ?: return null - val selected = jobs.firstOrNull { it.priority.rank == priority } ?: return null + val priorityValue = Priority.entries.first { it.rank == priority } + val previousAddress = lastServedDeviceByPriority[priorityValue] + val selected = jobs.firstOrNull { + it.priority.rank == priority && it.device.address != previousAddress + } ?: jobs.firstOrNull { it.priority.rank == priority } ?: return null jobs.remove(selected) + lastServedDeviceByPriority[priorityValue] = selected.device.address return selected } @@ -852,9 +912,19 @@ class BleNotifyQueue( return false } val waitingPriority = jobs.minOfOrNull { it.priority.rank } ?: return false - if (waitingPriority >= job.priority.rank) { + if (waitingPriority > job.priority.rank) { return false } + if (waitingPriority == job.priority.rank) { + val anotherDeviceWaiting = jobs.any { + it.priority == job.priority && it.device.address != job.device.address + } + return shouldYieldToPeerAtSamePriority( + priority = job.priority, + packetsSinceYield = job.packetsSinceYield, + anotherDeviceWaiting = anotherDeviceWaiting + ) + } return shouldYieldForPriorities( active = job.priority, waiting = Priority.entries.first { it.rank == waitingPriority }, @@ -1158,6 +1228,24 @@ class BleNotifyQueue( 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/MediaTransferCoordinators.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt index 47088da..fe96b88 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt @@ -3,6 +3,21 @@ 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. * @@ -12,24 +27,31 @@ import java.util.concurrent.ConcurrentHashMap internal class LyricsTransferCoordinator( val compressedCache: CompressedLyricsCache = CompressedLyricsCache() ) { - @Volatile - private var retainedTransfer: FullLyricsBinaryTransfer? = null + private val retainedTransfers = ConcurrentHashMap() fun retain(transfer: FullLyricsBinaryTransfer) { - retainedTransfer = transfer + retainedTransfers[transferKey(transfer.ownerAddress, transfer.transferId)] = transfer } - fun retained(transferId: String): FullLyricsBinaryTransfer? { - return retainedTransfer?.takeIf { it.transferId == transferId } + fun retained(ownerAddress: String, transferId: String): FullLyricsBinaryTransfer? { + return retainedTransfers[transferKey(ownerAddress, transferId)] } fun reset() { - retainedTransfer = null + retainedTransfers.clear() compressedCache.clear() } fun clearRetryState() { - retainedTransfer = null + retainedTransfers.clear() + } + + fun resetAddress(ownerAddress: String) { + retainedTransfers.entries.removeAll { it.value.ownerAddress == ownerAddress } + } + + private fun transferKey(ownerAddress: String, transferId: String): String { + return "$ownerAddress|$transferId" } } @@ -41,14 +63,24 @@ internal class AlbumArtTransferCoordinator { fun retain(transfer: AlbumArtBinaryTransfer, nowMs: Long = SystemClock.elapsedRealtime()) { retainedTransfers.entries.removeAll { it.value.expiresAtMs < nowMs } - retainedTransfers[transfer.transferId] = transfer + retainedTransfers[transferKey(transfer.ownerAddress, transfer.transferId)] = transfer } - fun retained(transferId: String): AlbumArtBinaryTransfer? = retainedTransfers[transferId] + 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" + } } /** @@ -67,33 +99,97 @@ internal class ConnectionCommandCoordinator { val negotiated: Boolean = false ) - @Volatile - var capabilities = Capabilities() - private set + private data class ClientSession( + val capabilities: Capabilities = Capabilities(), + val negotiationGeneration: Long = 0L, + val subscribedAtMs: Long = 0L + ) - @Volatile - var negotiationGeneration: Long = 0L - private set + 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 + } - fun beginNegotiation(): Long { - capabilities = Capabilities() - negotiationGeneration += 1 - return negotiationGeneration + @Synchronized + fun accept(address: String, requested: Capabilities) { + val previous = sessions[address] ?: ClientSession() + sessions[address] = previous.copy( + capabilities = requested.copy(negotiated = true) + ) } - fun accept(requested: Capabilities) { - 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 useLegacyIfCurrent(generation: Long): Boolean { - if (generation != negotiationGeneration || capabilities.negotiated) return false - capabilities = capabilities.copy(negotiated = true) + 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 invalidate() { - capabilities = Capabilities() - negotiationGeneration += 1 + fun reset() { + acceptedByCommand.clear() } } @@ -104,7 +200,8 @@ internal data class FullLyricsBinaryTransfer( val start: BleNotifyQueue.Packet, val chunks: List, val end: BleNotifyQueue.Packet, - val expiresAtMs: Long + val expiresAtMs: Long, + val ownerAddress: String = "" ) internal data class AlbumArtBinaryTransfer( @@ -114,7 +211,8 @@ internal data class AlbumArtBinaryTransfer( val start: BleNotifyQueue.Packet, val chunks: List, val end: BleNotifyQueue.Packet, - val expiresAtMs: Long + val expiresAtMs: Long, + val ownerAddress: String = "" ) internal enum class AlbumArtQuality(val wireValue: String) { 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..cd60ce6 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt @@ -787,7 +787,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 +833,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/QrcLyricModels.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt index fd7c72d..0075b81 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt @@ -103,7 +103,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, @@ -748,19 +748,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 ) 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 a4d84d8..b0a4286 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt @@ -24,6 +24,7 @@ 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 @@ -225,6 +226,7 @@ class PlayerAgentForegroundService : Service() { advertiserManager?.getAdvertisingState()?.name ?: "none" }, onAllClientsDisconnected = ::handleAllClientsDisconnected, + onControllerConnectionCountChanged = ::handleControllerConnectionCountChanged, onPlaybackUiState = ::publishPlaybackUiState ) if (manager.start()) { @@ -398,6 +400,25 @@ class PlayerAgentForegroundService : Service() { if (snapshot.subscribedCount > 0 && manager.notifyFailureCount() >= NOTIFY_FAILURE_RECOVERY_THRESHOLD ) { + val failingAddresses = manager.failingSubscriberAddresses( + NOTIFY_FAILURE_RECOVERY_THRESHOLD + ) + if (MultiControllerPolicy.shouldIsolateOnlyFailingControllers( + failingAddresses.size, + snapshot.subscribedCount + ) + ) { + 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 + } manager.clearStaleSubscribers("notify_failures") restartAdvertisingFromWatchdog("notify_failures") recoverBleStack("notify_failures", respectCooldown = true) @@ -547,6 +568,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, 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 c242954..d990f98 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleNotifyQueuePolicyTest.kt @@ -25,7 +25,7 @@ class BleNotifyQueuePolicyTest { } @Test - fun bulkYieldsToInteractiveEveryFourPacketsAndKeepsFifoPeers() { + fun bulkYieldsToInteractiveEveryFourPackets() { assertFalse( BleNotifyQueue.shouldYieldForPriorities( BleNotifyQueue.Priority.P2_BULK, @@ -49,6 +49,48 @@ 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)) diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt index 455cf2c..d6ffa8f 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt @@ -7,6 +7,36 @@ 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() @@ -19,13 +49,14 @@ class MediaTransferCoordinatorsTest { start = packet, chunks = listOf(packet), end = packet, - expiresAtMs = 2_000 + expiresAtMs = 2_000, + ownerAddress = "ios" ) lyrics.retain(lyricTransfer) artwork.reset() - assertEquals(lyricTransfer, lyrics.retained("lyrics-a")) - assertNull(lyrics.retained("art-a")) + assertEquals(lyricTransfer, lyrics.retained("ios", "lyrics-a")) + assertNull(lyrics.retained("android", "lyrics-a")) } @Test @@ -40,7 +71,8 @@ class MediaTransferCoordinatorsTest { start = packet, chunks = listOf(packet), end = packet, - expiresAtMs = 99 + expiresAtMs = 99, + ownerAddress = "ios" ), nowMs = 10 ) @@ -52,26 +84,57 @@ class MediaTransferCoordinatorsTest { start = packet, chunks = listOf(packet), end = packet, - expiresAtMs = 500 + expiresAtMs = 500, + ownerAddress = "android" ), nowMs = 100 ) - assertNull(coordinator.retained("old-transfer")) - assertEquals("current", coordinator.retained("current-transfer")?.trackId) + 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() - val second = coordinator.beginNegotiation() + val first = coordinator.beginNegotiation("ios", nowMs = 1_000L) + val second = coordinator.beginNegotiation("ios", nowMs = 1_100L) - assertFalse(coordinator.useLegacyIfCurrent(first)) - assertTrue(coordinator.useLegacyIfCurrent(second)) - assertTrue(coordinator.capabilities.negotiated) + assertFalse(coordinator.useLegacyIfCurrent("ios", first)) + assertTrue(coordinator.useLegacyIfCurrent("ios", second)) + assertTrue(coordinator.capabilities("ios").negotiated) coordinator.accept( + "ios", ConnectionCommandCoordinator.Capabilities( protocolVersion = 2, binaryAlbumArt = true, @@ -82,7 +145,62 @@ class MediaTransferCoordinatorsTest { negotiated = true ) ) - assertEquals(2, coordinator.capabilities.protocolVersion) - assertTrue(coordinator.capabilities.transferRetry) + assertEquals(2, coordinator.capabilities("ios").protocolVersion) + assertTrue(coordinator.capabilities("ios").transferRetry) + 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/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/README.md b/README.md index 4586544..fa8969c 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ flowchart LR The Sony device is the authoritative media source. `PlayerAgentApp` reads MediaSession, notifications and QQ Music's local QRC cache. The iOS and Android apps are interchangeable BLE controllers, caches and presentation layers; only -one controller should connect to a Sony device at a time. No external server is -required. +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 diff --git a/README.zh-CN.md b/README.zh-CN.md index b4a0d03..e796015 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,8 +78,9 @@ flowchart LR iOS --> Live["锁屏实时活动 / 灵动岛"] ``` -Sony 端是播放状态、歌词和封面的权威数据源,iPhone 与 Android App 是可替换的 -控制、缓存和展示端(同一时刻只建议连接一个控制端): +Sony 端是播放状态、歌词和封面的权威数据源,iPhone 与 Android App 是对等的 +控制、缓存和展示端;同一台 Sony 最多可同时连接两个控制端。查询和大文件传输只返回 +给发起设备,播放状态、歌曲信息、逐字进度、音量和封面 Offer 会同步给两个控制端: 1. Sony `PlayerAgentApp` 启动 BLE GATT Server,并监听 QQ 音乐状态与本地 QRC 文件; 2. iPhone `BLETestManager` 或 Android `ControllerConnectionService` 扫描并连接 diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index f1d2d44..785a341 100644 --- a/docs/BLE_PROTOCOL.md +++ b/docs/BLE_PROTOCOL.md @@ -33,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。 @@ -66,11 +66,22 @@ - `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/历史/日志。 - 队列的 enqueue、notify callback、超时、取消和抢占全部收敛到专用 `HandlerThread`;不要从 Binder/媒体线程直接修改队列状态。 -- P0 每包可抢占;P2 每 4 包为 P1 让路;P3 每包让路。同优先级 FIFO,切歌按任务 generation 取消旧歌词/封面。 +- P0 每包可抢占;P2 每 4 包为 P1 让路;P3 每包让路。同优先级的大传输按设备轮转,单台设备内部保持 FIFO;切歌按任务 generation 取消旧歌词/封面。 - iOS 控制命令在连接不健康时会丢弃,不缓存,不重连后补发。 - AlbumArt binary 使用 Sony 端 `ALBUM_ART_BINARY_MAGIC` 和 6 字节 header;iOS 在 `didUpdateValueFor` 中把非 JSON 二进制 chunk 转交给 `AlbumArtReceiver`。 diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 8c64ca6..094b974 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -28,13 +28,13 @@ ## 总体数据流 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` 可替代 iPhone 作为 Central,使用同一 BLE V2 - 协议更新 Compose 界面和 MediaStyle 通知;同一时刻只连接一个控制端。 +7. Android `ControllerConnectionService` 使用同一 BLE V2 协议更新 Compose 界面和 + MediaStyle 通知,可与 iPhone 同时在线并保持控制与媒体状态同步。 ## 关键状态 From fd90d78662262149dbe04014c618705d16ec9900 Mon Sep 17 00:00:00 2001 From: sqz Date: Sun, 9 Aug 2026 00:49:49 +0800 Subject: [PATCH 11/13] Improve iOS reconnect stability and Sony media sync --- .../BLEObservableStateModels.swift | 183 ++++ .../IOSBleFeasibility/BLETestManager.swift | 970 +++++++++++++++--- .../IOSBleFeasibility/ContentView.swift | 427 +++----- .../NowPlayingDiagnosticSnapshot.swift | 16 + .../NowPlayingDiagnosticView.swift | 27 + .../IOSBleFeasibility/PreferencesStore.swift | 61 +- .../IOSBleFeasibility/PreferencesView.swift | 16 +- .../PerformanceStabilityTests.swift | 162 ++- .../playeragent/ble/BleGattServerManager.kt | 187 +++- .../ble/BleSubscribedLinkPolicy.kt | 33 +- .../playeragent/ble/CurrentWordPushEngine.kt | 4 + .../ble/MediaTransferCoordinators.kt | 1 + .../media/CurrentTrackRuntimeCache.kt | 88 +- .../playeragent/media/PlaybackStateReader.kt | 19 +- .../playeragent/media/QrcLyricModels.kt | 2 + .../service/PlayerAgentForegroundService.kt | 36 +- .../ble/BleGattServerManagerPolicyTest.kt | 56 +- .../ble/CurrentWordPushEngineTest.kt | 27 + .../ble/MediaTransferCoordinatorsTest.kt | 2 + .../media/PlaybackPositionAnchorPolicyTest.kt | 47 + README.md | 2 + README.zh-CN.md | 2 + docs/BLE_PROTOCOL.md | 18 +- docs/PROJECT_OVERVIEW.md | 1 + docs/RECONNECT_HEALTH_ARCHITECTURE.md | 8 +- tools/deploy/ios_deploy.sh | 23 +- 26 files changed, 1893 insertions(+), 525 deletions(-) create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/PlaybackPositionAnchorPolicyTest.kt diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift index db0a9b4..7b42a9d 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift @@ -85,6 +85,189 @@ enum PlaybackClockPolicy { } } +struct MonotonicClockSyncSnapshot: Equatable { + let localMinusServerMs: Double + let bestRoundTripMs: Int64 + let offsetJitterMs: Int64 + let sampleCount: Int + let isConfident: Bool +} + +struct MonotonicClockSynchronizer { + private struct Sample { + let localMinusServerMs: Double + let roundTripMs: Int64 + let receivedAtLocalMs: Int64 + } + + private var samples: [Sample] = [] + private(set) var snapshot: MonotonicClockSyncSnapshot? + + mutating func reset() { + samples.removeAll(keepingCapacity: true) + snapshot = nil + } + + @discardableResult + mutating func record( + clientSendElapsedMs: Int64, + serverReceiveElapsedMs: Int64, + serverSendElapsedMs: Int64, + clientReceiveElapsedMs: Int64 + ) -> MonotonicClockSyncSnapshot? { + guard clientSendElapsedMs > 0, + serverReceiveElapsedMs > 0, + serverSendElapsedMs >= serverReceiveElapsedMs, + clientReceiveElapsedMs >= clientSendElapsedMs else { + return snapshot + } + let serverProcessingMs = serverSendElapsedMs - serverReceiveElapsedMs + let roundTripMs = clientReceiveElapsedMs - clientSendElapsedMs - serverProcessingMs + guard roundTripMs >= 0, roundTripMs <= Self.maximumAcceptedRoundTripMs else { + return snapshot + } + let localMinusServerMs = ( + Double(clientSendElapsedMs - serverReceiveElapsedMs) + + Double(clientReceiveElapsedMs - serverSendElapsedMs) + ) / 2.0 + samples.removeAll { + clientReceiveElapsedMs - $0.receivedAtLocalMs > Self.sampleLifetimeMs + } + samples.append( + Sample( + localMinusServerMs: localMinusServerMs, + roundTripMs: roundTripMs, + receivedAtLocalMs: clientReceiveElapsedMs + ) + ) + if samples.count > Self.maximumSamples { + samples.removeFirst(samples.count - Self.maximumSamples) + } + + let best = Array(samples.sorted { $0.roundTripMs < $1.roundTripMs }.prefix(3)) + guard let minimumRoundTrip = best.map(\.roundTripMs).min() else { + snapshot = nil + return nil + } + let sortedOffsets = best.map(\.localMinusServerMs).sorted() + let offset: Double + if sortedOffsets.count == 2 { + offset = (sortedOffsets[0] + sortedOffsets[1]) / 2.0 + } else { + offset = sortedOffsets[sortedOffsets.count / 2] + } + let jitter = Int64( + ((sortedOffsets.last ?? offset) - (sortedOffsets.first ?? offset)).rounded() + ) + let value = MonotonicClockSyncSnapshot( + localMinusServerMs: offset, + bestRoundTripMs: minimumRoundTrip, + offsetJitterMs: max(jitter, 0), + sampleCount: samples.count, + isConfident: samples.count >= 3 && + minimumRoundTrip <= Self.confidentRoundTripMs && + jitter <= Self.confidentOffsetJitterMs + ) + snapshot = value + return value + } + + func transportAgeMs( + serverSampleElapsedMs: Int64, + localReceiveElapsedMs: Int64 + ) -> Int64? { + guard serverSampleElapsedMs > 0, + let snapshot, + snapshot.isConfident else { + return nil + } + let mappedLocalSample = Double(serverSampleElapsedMs) + snapshot.localMinusServerMs + return Int64((Double(localReceiveElapsedMs) - mappedLocalSample).rounded()) + } + + private static let maximumAcceptedRoundTripMs: Int64 = 1_500 + private static let confidentRoundTripMs: Int64 = 300 + private static let confidentOffsetJitterMs: Int64 = 100 + private static let sampleLifetimeMs: Int64 = 5 * 60 * 1_000 + private static let maximumSamples = 12 +} + +enum RemotePlaybackAnchorResolution: Equatable { + case unavailable + case stale(transportAgeMs: Int64) + case resolved(positionMs: Int64, transportAgeMs: Int64) +} + +enum RemotePlaybackAnchorPolicy { + static let staleTransportAgeMs: Int64 = 1_500 + + static func resolve( + remotePositionMs: Int64, + serverSampleElapsedMs: Int64, + localReceiveElapsedMs: Int64, + playbackSpeed: Double, + isPlaying: Bool, + durationMs: Int64, + synchronizer: MonotonicClockSynchronizer + ) -> RemotePlaybackAnchorResolution { + guard let measuredAge = synchronizer.transportAgeMs( + serverSampleElapsedMs: serverSampleElapsedMs, + localReceiveElapsedMs: localReceiveElapsedMs + ) else { + return .unavailable + } + return resolve( + remotePositionMs: remotePositionMs, + measuredTransportAgeMs: measuredAge, + playbackSpeed: playbackSpeed, + isPlaying: isPlaying, + durationMs: durationMs + ) + } + + static func resolve( + remotePositionMs: Int64, + measuredTransportAgeMs measuredAge: Int64, + playbackSpeed: Double, + isPlaying: Bool, + durationMs: Int64 + ) -> RemotePlaybackAnchorResolution { + guard measuredAge >= -75 else { return .unavailable } + let transportAgeMs = max(measuredAge, 0) + guard transportAgeMs <= staleTransportAgeMs else { + return .stale(transportAgeMs: transportAgeMs) + } + let normalizedSpeed = playbackSpeed.isFinite && playbackSpeed > 0 + ? playbackSpeed + : 1.0 + let advanceMs = isPlaying + ? Int64((Double(transportAgeMs) * normalizedSpeed).rounded()) + : 0 + let projected = remotePositionMs + advanceMs + let position = durationMs > 0 + ? min(max(projected, 0), durationMs) + : max(projected, 0) + return .resolved(positionMs: position, transportAgeMs: transportAgeMs) + } + + static func smoothedPosition( + currentPositionMs: Int64, + targetPositionMs: Int64, + force: Bool = false + ) -> Int64 { + guard !force else { return targetPositionMs } + let delta = targetPositionMs - currentPositionMs + switch abs(delta) { + case 0..<80: + return currentPositionMs + case 80...400: + return currentPositionMs + delta / 2 + default: + return targetPositionMs + } + } +} + struct CurrentWordOrderingFence { private(set) var generation: Int64 = -1 private(set) var sequence: Int64 = -1 diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index e79b09b..f01ec1d 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -33,18 +33,36 @@ private let CONNECTION_DISPLAY_CONNECTED_MIN_HOLD_MS: Int64 = 5_000 private let CONNECTION_DISPLAY_DISCONNECTED_CONFIRM_MS: Int64 = 1_000 private let COMMAND_WRITE_CALLBACK_TIMEOUT_MS: Int64 = 2_500 private let COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS: Int64 = 3_000 -private let COMMAND_WRITE_LATE_CALLBACK_FENCE_MS: Int64 = 1_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 CLOCK_SYNC_BOOTSTRAP_SAMPLE_COUNT = 5 +private let CLOCK_SYNC_REFRESH_SAMPLE_COUNT = 3 +private let CLOCK_SYNC_SAMPLE_SPACING_MS: Int64 = 150 +private let CLOCK_SYNC_REFRESH_INTERVAL_MS: Int64 = 120_000 +private let CLOCK_SYNC_PROBE_TTL_MS: Int64 = 5_000 + +enum CommandWriteTimeoutAction: Equatable { + case suspendUntilForeground + case extendWithoutAdvancingQueue + case reconnect +} -enum CommandWriteSoftRecoveryPolicy { - static func shouldRetainPendingCommand(_ command: String) -> Bool { - // Pending commands have not reached CoreBluetooth yet. Dropping one here can - // strand higher-level deduplication state (for example lyric secondary modes), - // so the late-callback fence must retain every queued command. - true +enum CommandWriteTimeoutPolicy { + static func action( + appIsActive: Bool, + transportReady: Bool, + timeoutCountAfterIncrement: Int, + reconnectThreshold: Int + ) -> CommandWriteTimeoutAction { + guard appIsActive else { return .suspendUntilForeground } + guard transportReady else { return .reconnect } + return timeoutCountAfterIncrement < reconnectThreshold + ? .extendWithoutAdvancingQueue + : .reconnect } } @@ -117,6 +135,12 @@ private struct LyricSecondaryTransfer { var lines: [Int: LyricSecondaryLineParts] } +private struct ClockSyncProbe { + let sequence: UInt64 + let clientSendElapsedMs: Int64 + let clientSendDate: Date +} + final class BLETestManager: NSObject, ObservableObject { private static let logTimestampFormatter: DateFormatter = { let formatter = DateFormatter() @@ -165,6 +189,8 @@ final class BLETestManager: NSObject, ObservableObject { @Published private(set) var mediaFieldDumpCopyStatus = "" @Published private(set) var isMediaFieldDumpReceiving = false @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() @@ -210,6 +236,11 @@ final class BLETestManager: NSObject, ObservableObject { @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 connectionStateModel = ObservableStateSlice(BLEConnectionViewState()) let playbackStateModel = ObservableStateSlice(BLEPlaybackViewState()) @@ -247,8 +278,6 @@ final class BLETestManager: NSObject, ObservableObject { private var commandWriteInflight: [CommandWriteInfo] = [] private var pendingCommandWrites: [PendingCommandWrite] = [] private var commandWriteTimeoutWorkItem: DispatchWorkItem? - private var commandWriteRecoveryWorkItem: DispatchWorkItem? - private var commandWriteRecoveryUntilMs: Int64 = 0 private var consecutiveCommandWriteTimeouts = 0 private var volumeWriteInFlightSeq: UInt64? private var lastVolumeSendAtMs: Int64 = 0 @@ -266,6 +295,14 @@ 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 = "" @@ -328,7 +365,9 @@ final class BLETestManager: NSObject, ObservableObject { private var trackInfoExpectedChunks = 0 private var trackInfoChunks: [Int: Data] = [:] 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 @@ -379,9 +418,18 @@ final class BLETestManager: NSObject, ObservableObject { private var reconnectStateSyncRequestedFullLyricsTrackIDs: Set = [] private var connectionReadyAt: Date? private var lastHealthProbeAt: Date? + private var healthProbeCommandSeq: UInt64? private var healthProbeStartedAt: Date? private var healthPingClockProbeStartedAt: Date? private var sonyClockOffsetMs: Int64? + private var serverSupportsClockSyncV1 = false + private var clockSynchronizer = MonotonicClockSynchronizer() + private var sonyUnixMinusElapsedMs: Int64? + private var clockSyncProbes: [String: ClockSyncProbe] = [:] + private var clockSyncBootstrapWorkItems: [DispatchWorkItem] = [] + private var clockSyncRefreshWorkItem: DispatchWorkItem? + private var remotePlaybackSpeed = 1.0 + private var lastStaleRemoteAnchorLogAtMs: Int64 = 0 private var healthProbeFailureCount = 0 private var serverProtocolVersion = 1 private var serverSupportsFullLyricsZlib = false @@ -561,6 +609,7 @@ final class BLETestManager: NSObject, ObservableObject { private func syncPreferencesStateFromStore() { appExperienceMode = preferences.appExperienceMode autoReconnectEnabled = preferences.autoReconnectEnabled + automaticLyricSyncEnabled = preferences.automaticLyricSyncEnabled karaokeOffsetMs = Int64(preferences.lyricOffsetMs) } @@ -1143,6 +1192,8 @@ final class BLETestManager: NSObject, ObservableObject { healthCheckWorkItem?.cancel() healthProbeTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem?.cancel() + foregroundInflightSettleWorkItem?.cancel() NotificationCenter.default.removeObserver(self) } @@ -1480,7 +1531,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 @@ -1491,7 +1550,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() { @@ -1553,7 +1622,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( @@ -1612,7 +1798,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))" ) @@ -1771,7 +1960,12 @@ final class BLETestManager: NSObject, ObservableObject { let startMs = currentTimeMs() var payload = extra payload["cmd"] = cmd - payload["time"] = startMs + // Capability payloads sit close to the common 182-byte ATT limit. + // Their wall-clock field was never consumed by Sony, so omit it to + // leave room for additive capability flags without fragmenting writes. + if cmd != "CLIENT_CAPABILITIES" { + payload["time"] = startMs + } payload["seq"] = seq let connected = sonyPeripheral?.state == .connected @@ -1829,7 +2023,10 @@ final class BLETestManager: NSObject, ObservableObject { } private func enqueueCommandWrite(_ request: PendingCommandWrite) { - if request.isControl, + if foregroundValidationPending, + foregroundValidationCommandSeq == request.seq { + pendingCommandWrites.insert(request, at: 0) + } else if request.isControl, let firstBackgroundIndex = pendingCommandWrites.firstIndex(where: { !$0.isControl }) { pendingCommandWrites.insert(request, at: firstBackgroundIndex) } else { @@ -1845,9 +2042,11 @@ final class BLETestManager: NSObject, ObservableObject { } private func flushCommandWriteQueue() { - let nowMs = currentTimeMs() - guard nowMs >= commandWriteRecoveryUntilMs else { - scheduleCommandWriteRecoveryFlush() + // 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, @@ -1894,12 +2093,30 @@ 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 } @@ -1923,68 +2140,43 @@ final class BLETestManager: NSObject, ObservableObject { let recentNotifyAgeMs = lastStatusNotifyAt.map { Int64(now.timeIntervalSince($0) * 1_000) } ?? Int64.max - guard recentNotifyAgeMs <= COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS, - sonyPeripheral?.state == .connected, - sonyCommandCharacteristic != nil else { - performHardReconnect( - reason: "command write callback timeout without live notify cmd=\(cmd) seq=\(seq)", - manual: false + let transportReady = sonyPeripheral?.state == .connected && + sonyCommandCharacteristic != nil + let nextTimeoutCount = consecutiveCommandWriteTimeouts + 1 + let action = CommandWriteTimeoutPolicy.action( + appIsActive: appLifecycleState == "active", + transportReady: transportReady, + timeoutCountAfterIncrement: nextTimeoutCount, + reconnectThreshold: COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT + ) + switch action { + case .suspendUntilForeground: + ctrlLog( + "[CTRL-iOS] write timeout ignored during lifecycle suspension " + + "seq=\(seq) cmd=\(cmd) appState=\(appLifecycleState)" ) - return - } - - consecutiveCommandWriteTimeouts += 1 - guard consecutiveCommandWriteTimeouts < COMMAND_WRITE_TIMEOUTS_BEFORE_RECONNECT else { + case .extendWithoutAdvancingQueue: + consecutiveCommandWriteTimeouts = nextTimeoutCount + // Never pop the in-flight request here. CoreBluetooth can deliver + // its callback late; advancing the queue would then attribute that + // callback to a different command. + setConnectionHealth( + .suspect, + reason: "write callback delayed recentNotifyAgeMs=\(recentNotifyAgeMs)" + ) + ctrlLog( + "[CTRL-iOS] write timeout extended seq=\(seq) cmd=\(cmd) " + + "count=\(consecutiveCommandWriteTimeouts) " + + "recentNotify=\(recentNotifyAgeMs <= COMMAND_WRITE_RECENT_NOTIFY_GRACE_MS)" + ) + scheduleCommandWriteTimeout(seq: seq, cmd: cmd) + case .reconnect: + consecutiveCommandWriteTimeouts = nextTimeoutCount performHardReconnect( - reason: "command write callback timeout x\(consecutiveCommandWriteTimeouts) cmd=\(cmd) seq=\(seq)", + reason: "command write callback timeout x\(nextTimeoutCount) cmd=\(cmd) seq=\(seq)", manual: false ) - return - } - - if commandWriteInflight.first?.seq == seq { - commandWriteInflight.removeFirst() } - if volumeWriteInFlightSeq == seq { - volumeWriteInFlightSeq = nil - } - let pendingBefore = pendingCommandWrites.count - pendingCommandWrites.removeAll(where: { - !CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand($0.cmd) - }) - let retainedBackground = pendingCommandWrites.filter { !$0.isControl }.count - let droppedPending = pendingBefore - pendingCommandWrites.count - commandWriteRecoveryUntilMs = currentTimeMs() + COMMAND_WRITE_LATE_CALLBACK_FENCE_MS - setConnectionHealth( - .suspect, - reason: "write callback soft recovery recentNotifyAgeMs=\(recentNotifyAgeMs)" - ) - ctrlLog( - "[CTRL-iOS] write soft recovery seq=\(seq) cmd=\(cmd) " + - "recentNotifyAgeMs=\(recentNotifyAgeMs) " + - "retainedBackground=\(retainedBackground) " + - "droppedPending=\(droppedPending)" - ) - scheduleCommandWriteRecoveryFlush() - } - - private func scheduleCommandWriteRecoveryFlush() { - guard commandWriteRecoveryUntilMs > 0 else { return } - commandWriteRecoveryWorkItem?.cancel() - let delayMs = max(commandWriteRecoveryUntilMs - currentTimeMs(), 0) - let item = DispatchWorkItem { [weak self] in - guard let self else { return } - self.commandWriteRecoveryWorkItem = nil - self.commandWriteRecoveryUntilMs = 0 - self.ctrlLog("[CTRL-iOS] write soft recovery fence ended") - self.flushCommandWriteQueue() - self.flushPendingVolumeIfPossible() - } - commandWriteRecoveryWorkItem = item - DispatchQueue.main.asyncAfter( - deadline: .now() + Double(delayMs) / 1_000.0, - execute: item - ) } func seek(to position: Int64) { @@ -1997,7 +2189,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) @@ -2084,7 +2276,7 @@ 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)") @@ -2501,6 +2693,169 @@ final class BLETestManager: NSObject, ObservableObject { Int64(Date().timeIntervalSince1970 * 1_000) } + private func monotonicTimeMs() -> Int64 { + Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded()) + } + + private func resetClockSync(reason: String) { + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + clockSyncRefreshWorkItem?.cancel() + clockSyncRefreshWorkItem = nil + clockSyncProbes.removeAll() + clockSynchronizer.reset() + sonyUnixMinusElapsedMs = nil + sonyClockOffsetMs = nil + lyricAutomaticCompensationMs = 0 + lyricClockBestRoundTripMs = 0 + lyricClockOffsetJitterMs = 0 + lyricClockSampleCount = 0 + lyricClockSyncConfident = false + lastStaleRemoteAnchorLogAtMs = 0 + log("[ClockSync] reset reason=\(reason)") + } + + private func pauseClockSyncScheduling(reason: String) { + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + clockSyncRefreshWorkItem?.cancel() + clockSyncRefreshWorkItem = nil + clockSyncProbes.removeAll() + log("[ClockSync] scheduling paused reason=\(reason)") + } + + private func scheduleClockSyncBootstrap(reason: String) { + guard appLifecycleState == "active", serverSupportsClockSyncV1 else { return } + scheduleClockSyncProbes( + count: CLOCK_SYNC_BOOTSTRAP_SAMPLE_COUNT, + reason: reason + ) + scheduleClockSyncRefresh() + } + + private func scheduleClockSyncProbes(count: Int, reason: String) { + guard appLifecycleState == "active", count > 0 else { return } + clockSyncBootstrapWorkItems.forEach { $0.cancel() } + clockSyncBootstrapWorkItems.removeAll() + for index in 0.. Bool { + let sequence = Self.int64Value(object["seq"]) + guard sequence > 0, + let probe = clockSyncProbes.removeValue(forKey: String(sequence)) else { + return false + } + let receivedAtElapsedMs = monotonicTimeMs() + let receivedAt = Date() + let echoedClientElapsedMs = Self.int64Value(object["clientSendElapsedMs"]) + let serverReceiveElapsedMs = Self.int64Value(object["serverReceiveElapsedMs"]) + let serverSendElapsedMs = Self.int64Value(object["serverSendElapsedMs"]) + let sonyTimeMs = Self.int64Value(object["time"]) + if sonyTimeMs > 0 { + let midpointMs = Int64( + (probe.clientSendDate.timeIntervalSince1970 + + receivedAt.timeIntervalSince1970) * 500 + ) + sonyClockOffsetMs = sonyTimeMs - midpointMs + } + guard echoedClientElapsedMs == probe.clientSendElapsedMs, + serverReceiveElapsedMs > 0, + serverSendElapsedMs >= serverReceiveElapsedMs else { + log("[ClockSync] legacy pong seq=\(sequence) clockOffsetMs=\(sonyClockOffsetMs ?? 0)") + return true + } + if sonyTimeMs > 0 { + sonyUnixMinusElapsedMs = sonyTimeMs - serverSendElapsedMs + } + let snapshot = clockSynchronizer.record( + clientSendElapsedMs: probe.clientSendElapsedMs, + serverReceiveElapsedMs: serverReceiveElapsedMs, + serverSendElapsedMs: serverSendElapsedMs, + clientReceiveElapsedMs: receivedAtElapsedMs + ) + if let snapshot { + lyricClockBestRoundTripMs = snapshot.bestRoundTripMs + lyricClockOffsetJitterMs = snapshot.offsetJitterMs + lyricClockSampleCount = snapshot.sampleCount + lyricClockSyncConfident = snapshot.isConfident + log( + "[ClockSync] pong seq=\(sequence) bestRttMs=\(snapshot.bestRoundTripMs) " + + "offsetMs=\(Int64(snapshot.localMinusServerMs.rounded())) " + + "jitterMs=\(snapshot.offsetJitterMs) samples=\(snapshot.sampleCount) " + + "confident=\(snapshot.isConfident)" + ) + } + return true + } + private func isInStartupLoadWindow() -> Bool { guard firstConnectionReadyAtMs > 0 else { return false } return currentTimeMs() - firstConnectionReadyAtMs < 3_000 @@ -2663,6 +3018,7 @@ final class BLETestManager: NSObject, ObservableObject { healthProbeTimeoutWorkItem = nil subscribeNotifyTimeoutWorkItem?.cancel() subscribeNotifyTimeoutWorkItem = nil + healthProbeCommandSeq = nil healthProbeStartedAt = nil healthProbeFailureCount = 0 connectionHealthProbeInFlight = false @@ -2677,6 +3033,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() @@ -2684,6 +3045,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() } @@ -2696,6 +3061,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 @@ -2739,8 +3109,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 } @@ -2754,21 +3128,33 @@ final class BLETestManager: NSObject, ObservableObject { return } lastHealthProbeAt = now - healthProbeStartedAt = now - healthPingClockProbeStartedAt = serverSupportsPing ? now : nil - 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 @@ -2790,6 +3176,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)") @@ -2805,6 +3193,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() @@ -2816,9 +3205,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 { @@ -3063,6 +3458,7 @@ 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 @@ -3070,10 +3466,15 @@ final class BLETestManager: NSObject, ObservableObject { firstConnectionReadyAtMs = 0 commandWriteTimeoutWorkItem?.cancel() commandWriteTimeoutWorkItem = nil - commandWriteRecoveryWorkItem?.cancel() - commandWriteRecoveryWorkItem = nil - commandWriteRecoveryUntilMs = 0 consecutiveCommandWriteTimeouts = 0 + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = nil + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + foregroundValidationPending = false + foregroundValidationWaitingForInflight = false + foregroundValidationCommandSeq = nil + foregroundValidationStartedAt = nil coreBluetoothRestoreInProgress = false commandWriteInflight.removeAll() pendingCommandWrites.removeAll() @@ -3124,21 +3525,59 @@ final class BLETestManager: NSObject, ObservableObject { } 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 @@ -3200,10 +3639,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( @@ -3221,6 +3662,12 @@ 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 { @@ -3228,12 +3675,46 @@ final class BLETestManager: NSObject, ObservableObject { } } + private func pauseConnectionWatchdogsForLifecycle(reason: String) { + healthCheckWorkItem?.cancel() + healthCheckWorkItem = nil + healthProbeTimeoutWorkItem?.cancel() + healthProbeTimeoutWorkItem = nil + subscribeNotifyTimeoutWorkItem?.cancel() + subscribeNotifyTimeoutWorkItem = nil + commandWriteTimeoutWorkItem?.cancel() + commandWriteTimeoutWorkItem = nil + foregroundValidationTimeoutWorkItem?.cancel() + foregroundValidationTimeoutWorkItem = nil + foregroundInflightSettleWorkItem?.cancel() + foregroundInflightSettleWorkItem = nil + foregroundValidationPending = false + foregroundValidationWaitingForInflight = false + foregroundValidationCommandSeq = nil + foregroundValidationStartedAt = nil + healthProbeCommandSeq = nil + healthProbeStartedAt = nil + healthPingClockProbeStartedAt = nil + healthProbeFailureCount = 0 + consecutiveCommandWriteTimeouts = 0 + pauseClockSyncScheduling(reason: reason) + updateConnectionHealthDebugFields() + ctrlLog( + "[APP-LIFECYCLE] BLE watchdogs paused reason=\(reason) " + + "inflight=\(commandWriteInflight.count) pending=\(pendingCommandWrites.count)" + ) + } + private func handleAppForegroundReconnectCheck() { let connected = sonyPeripheral?.state == .connected let ready = connected && sonyCommandCharacteristic != nil && sonyStatusCharacteristic != nil + let suspendedMs = backgroundEnteredAt.map { + max(Int64(Date().timeIntervalSince($0) * 1_000), 0) + } ?? 0 + backgroundEnteredAt = nil log( "[BLE-Reconnect] app foreground check connected=\(connected) " + - "ready=\(ready) health=\(connectionHealthState)" + "ready=\(ready) health=\(connectionHealthState) suspendedMs=\(suspendedMs)" ) guard centralManager.state == .poweredOn else { return } if connected, coreBluetoothRestoreInProgress { @@ -3268,16 +3749,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) { @@ -3737,8 +4318,10 @@ extension BLETestManager: CBCentralManagerDelegate { lastNotifySubscribedAt = nil lastStatusNotifyAt = nil lastPlaybackStateAt = nil + healthProbeCommandSeq = nil healthProbeStartedAt = nil healthPingClockProbeStartedAt = nil + resetClockSync(reason: "didConnect") resetCurrentWordFence() reconnectStateSyncWindowUntilMs = 0 reconnectStateSyncPlaybackLogged = false @@ -3927,14 +4510,6 @@ extension BLETestManager: CBPeripheralDelegate { } } else { let errorText = error?.localizedDescription ?? "nil" - if error == nil, didWriteMs <= commandWriteRecoveryUntilMs { - consecutiveCommandWriteTimeouts = 0 - ctrlLog( - "[CTRL-iOS] ignored late didWrite during recovery fence " + - "timeMs=\(didWriteMs)" - ) - return - } ctrlLog( "[CTRL-iOS] didWrite seq=unknown cmd=unknown " + "timeMs=\(didWriteMs) costMs=unknown error=\(errorText)" @@ -3962,7 +4537,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() + } } } @@ -4013,10 +4596,17 @@ extension BLETestManager: CBPeripheralDelegate { serverSupportsFullLyricsZlib = false serverSupportsLyricWindow = false serverSupportsPing = false + serverSupportsClockSyncV1 = false serverSupportsTransferRetry = false + 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 { @@ -4038,25 +4628,45 @@ extension BLETestManager: CBPeripheralDelegate { "fullLyricsZlib": true, "lyricWindow": true, "ping": true, + "clockSyncV1": true, "transferRetry": true ] ) 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() } // 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 - 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.0) { [weak self] in - self?.setAutoReconnectState(.connected) + guard let self, + self.connectionAttemptId == expectedAttemptId, + self.lifecycleGeneration == expectedLifecycleGeneration, + self.sonyCharacteristicsReady else { return } + self.setAutoReconnectState(.connected) } } } @@ -4119,19 +4729,25 @@ extension BLETestManager: CBPeripheralDelegate { self.serverSupportsFullLyricsZlib = object["fullLyricsZlib"] as? Bool ?? false self.serverSupportsLyricWindow = object["lyricWindow"] as? Bool ?? false self.serverSupportsPing = object["ping"] as? Bool ?? false + self.serverSupportsClockSyncV1 = object["clockSyncV1"] as? Bool ?? false self.serverSupportsTransferRetry = object["transferRetry"] as? Bool ?? false self.log( "[BLE-iOS] capabilities ack protocolVersion=\(self.serverProtocolVersion) " + "fullLyricsZlib=\(self.serverSupportsFullLyricsZlib) " + "lyricWindow=\(self.serverSupportsLyricWindow) " + "ping=\(self.serverSupportsPing) " + + "clockSyncV1=\(self.serverSupportsClockSyncV1) " + "transferRetry=\(self.serverSupportsTransferRetry)" ) + if self.serverSupportsClockSyncV1 { + self.scheduleClockSyncBootstrap(reason: "capability ack") + } if self.serverSupportsLyricWindow, !self.currentTrackID.isEmpty { self.requestLyricWindow(trackID: self.currentTrackID) } case "pong": + let handledClockSync = self.handleClockSyncPong(object) if let startedAt = self.healthPingClockProbeStartedAt { let receivedAt = Date() let rttMs = Int64(receivedAt.timeIntervalSince(startedAt) * 1_000) @@ -4149,7 +4765,7 @@ extension BLETestManager: CBPeripheralDelegate { "rttMs=\(rttMs) " + "clockOffsetMs=\(self.sonyClockOffsetMs ?? 0)" ) - } else { + } else if !handledClockSync { self.log("[BLE-Health] pong seq=\(object["seq"] ?? "")") } @@ -4160,6 +4776,10 @@ 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 { @@ -4204,11 +4824,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 " + @@ -6219,8 +6851,40 @@ extension BLETestManager: CBPeripheralDelegate { return } - currentWordLineIndex = lineIndex - currentWordIndex = wordIndex + let anchorResolution = resolveRemotePlaybackAnchor( + object: object, + remotePositionMs: remotePositionMs, + timestampMs: timestampMs, + isPlaying: isPlaying + ) + guard let calibratedPositionMs = calibratedRemotePosition( + from: anchorResolution, + remotePositionMs: remotePositionMs, + source: "currentWord" + ) else { + currentWordDropCount += 1 + return + } + let transportAgeMs: Int64 + if case let .resolved(_, ageMs) = anchorResolution { + transportAgeMs = ageMs + } else { + transportAgeMs = 0 + } + var effectiveLineIndex = lineIndex + var effectiveWordIndex = wordIndex + var locallyResolvedLyric: String? + if transportAgeMs > 300, + let local = locallyResolvedWord( + positionMs: karaokePositionMs(rawPositionMs: calibratedPositionMs) + ) { + effectiveLineIndex = local.lineIndex + effectiveWordIndex = local.wordIndex + locallyResolvedLyric = local.text + } + + currentWordLineIndex = effectiveLineIndex + currentWordIndex = effectiveWordIndex currentWordPushCount += 1 if lastCurrentWordReceivedAtMs > 0 { currentWordIntervalTotalMs += max(nowMs - lastCurrentWordReceivedAtMs, 0) @@ -6233,45 +6897,45 @@ extension BLETestManager: CBPeripheralDelegate { } } lastCurrentWordReceivedAtMs = nowMs - if timestampMs > 0, let sonyClockOffsetMs { - currentWordLastLatencyMs = max( - nowMs - (timestampMs - sonyClockOffsetMs), - 0 - ) - } else { - currentWordLastLatencyMs = 0 - } - let shouldLogDiagnostic = lineIndex != lastCurrentWordLoggedLineIndex || + currentWordLastLatencyMs = transportAgeMs + let shouldLogDiagnostic = effectiveLineIndex != lastCurrentWordLoggedLineIndex || nowMs - lastCurrentWordDiagnosticLogAtMs >= 5_000 if shouldLogDiagnostic { - lastCurrentWordLoggedLineIndex = lineIndex + lastCurrentWordLoggedLineIndex = effectiveLineIndex lastCurrentWordDiagnosticLogAtMs = nowMs log( "[LyricTrace-iOS] id=\(trackID) stage=currentWordAccepted " + "generation=\(generation) seq=\(sequence) " + - "line=\(lineIndex) word=\(wordIndex) position=\(remotePositionMs) " + + "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 = fullLyrics.first(where: { $0.index == 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() } if shouldLogDiagnostic { log( - "[Lyrics-iOS] currentWord line=\(lineIndex) word=\(wordIndex) " + - "position=\(remotePositionMs) latencyMs=\(currentWordLastLatencyMs) " + + "[Lyrics-iOS] currentWord line=\(effectiveLineIndex) " + + "word=\(effectiveWordIndex) position=\(calibratedPositionMs) " + + "latencyMs=\(currentWordLastLatencyMs) " + "count=\(currentWordPushCount) " + "avgIntervalMs=\(currentWordAverageUpdateIntervalMs)" ) @@ -6279,7 +6943,7 @@ extension BLETestManager: CBPeripheralDelegate { if isInReconnectStateSyncWindow() { log( "[Reconnect] currentWord accepted after reconnect " + - "line=\(lineIndex) word=\(wordIndex)" + "line=\(effectiveLineIndex) word=\(effectiveWordIndex)" ) } @@ -6358,8 +7022,11 @@ extension BLETestManager: CBPeripheralDelegate { 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 @@ -6582,6 +7249,9 @@ extension BLETestManager: CBPeripheralDelegate { if let number = value as? NSNumber { return number.int64Value } + if let string = value as? String { + return Int64(string) ?? 0 + } return 0 } @@ -6599,6 +7269,9 @@ extension BLETestManager: CBPeripheralDelegate { if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { + return Int(string) ?? 0 + } return 0 } @@ -6606,6 +7279,9 @@ extension BLETestManager: CBPeripheralDelegate { if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { + return Double(string) ?? 0 + } return 0 } diff --git a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift index 70331e7..d555ffa 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift @@ -14,6 +14,8 @@ struct ContentView: View { @State private var showNowPlayingDiagnostic = false @State private var showSystemHealthOverview = false @State private var showPreferences = false + @State private var isVolumeFeedbackVisible = false + @State private var volumeFeedbackGeneration = 0 init() { let manager = BLETestManager() @@ -201,33 +203,81 @@ struct ContentView: View { private var darkSystemHeader: some View { HStack(spacing: 12) { Button { - bleManager.scanSonyFromMenu() + handleConnectionChipTap() } 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) + HStack(spacing: 7) { + connectionStatusIndicator - Text(systemState.connection.title) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .foregroundStyle(systemState.connection.color.opacity(0.98)) + Text(systemState.connection.compactTitle) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(connectionChipForegroundColor) .lineLimit(1) + .contentTransition(.opacity) + } + .padding(.horizontal, 11) + .frame(height: 34) + .background(connectionChipBackground, in: Capsule()) + .overlay { + Capsule() + .strokeBorder(connectionChipBorderColor, lineWidth: 1) } .contentShape(Rectangle()) + .padding(.vertical, 5) } .buttonStyle(PressScaleButtonStyle(pressedScale: 0.98)) - .accessibilityLabel("连接状态,点按扫描或重连") + .accessibilityLabel(systemState.connection.accessibilityLabel) + .accessibilityHint( + isConnected + ? "点按查看连接详情" + : "点按扫描并连接 Sony" + ) Spacer() darkMenuButton } + .animation(.easeInOut(duration: 0.18), value: systemState.connection) + } + + @ViewBuilder + private var connectionStatusIndicator: some View { + if systemState.connection.showsProgressIndicator { + ProgressView() + .controlSize(.mini) + .tint(systemState.connection.color) + .frame(width: 12, height: 12) + } else if systemState.connection == .disconnected { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(systemState.connection.color) + .frame(width: 12, height: 12) + } else { + Circle() + .fill(systemState.connection.color) + .frame(width: 7, height: 7) + .shadow(color: systemState.connection.color.opacity(0.48), radius: 4) + .frame(width: 12, height: 12) + } + } + + private var connectionChipForegroundColor: Color { + isConnected ? .white.opacity(0.88) : systemState.connection.color.opacity(0.96) + } + + private var connectionChipBackground: Color { + isConnected ? .white.opacity(0.055) : systemState.connection.color.opacity(0.12) + } + + private var connectionChipBorderColor: Color { + isConnected ? .white.opacity(0.07) : systemState.connection.color.opacity(0.20) + } + + private func handleConnectionChipTap() { + if isConnected { + showPreferences = true + } else { + bleManager.scanSonyFromMenu() + } } private var darkMenuButton: some View { @@ -484,54 +534,107 @@ struct ContentView: View { } private var darkVolumeControl: some View { - HStack(spacing: 16) { + HStack(spacing: 12) { Image(systemName: volumeIcon) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white.opacity(0.92)) - .frame(width: 26) + .frame(width: 24) - Slider( - value: Binding( - get: { - Double( - bleManager.isVolumeSeeking - ? bleManager.volumeSeekValue - : bleManager.volumeCurrent - ) - }, - set: { value in - bleManager.updateVolumeSeekValue(value) - } - ), - in: 0...Double(max(bleManager.volumeMax, 1)), - step: 1, - onEditingChanged: { editing in - if editing { - bleManager.beginVolumeSeeking() - } else { - bleManager.finishVolumeSeeking() + GeometryReader { proxy in + ZStack(alignment: .leading) { + Slider( + value: volumeBinding, + in: 0...Double(max(bleManager.volumeMax, 1)), + step: 1, + onEditingChanged: handleVolumeEditingChanged + ) + .tint(uiState.playback.accentColor) + .disabled(!isVolumeControlAvailable) + + if isVolumeFeedbackVisible, isVolumeControlAvailable { + 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: volumeFeedbackXPosition(in: proxy.size.width), + y: -7 + ) + .transition(.opacity.combined(with: .scale(scale: 0.94))) + .allowsHitTesting(false) } } - ) - .tint(uiState.playback.accentColor) - .disabled(!isConnected || bleManager.volumeMax <= 0) + } + .frame(height: 32) - 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)) + .padding(.horizontal, 14) + .frame(height: 44) + .background(Color.black.opacity(0.16), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) + RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(.white.opacity(0.07), lineWidth: 1) } .disabled(!isConnected) .opacity(isConnected ? 1 : 0.48) .animation(.easeInOut(duration: 0.16), value: displayedVolume) + .animation(.easeInOut(duration: 0.16), value: isVolumeFeedbackVisible) + .accessibilityElement(children: .combine) + .accessibilityLabel("音量") + .accessibilityValue(volumeAccessibilityValue) + } + + private var volumeBinding: Binding { + Binding( + get: { Double(displayedVolume) }, + set: { bleManager.updateVolumeSeekValue($0) } + ) + } + + private var isVolumeControlAvailable: Bool { + isConnected && bleManager.volumeMax > 0 + } + + private var volumeAccessibilityValue: String { + guard bleManager.volumeMax > 0 else { return "尚未同步" } + return "\(displayedVolume),最大 \(bleManager.volumeMax)" + } + + private func handleVolumeEditingChanged(_ editing: Bool) { + volumeFeedbackGeneration += 1 + let generation = volumeFeedbackGeneration + + if editing { + withAnimation(.easeOut(duration: 0.12)) { + isVolumeFeedbackVisible = true + } + bleManager.beginVolumeSeeking() + return + } + + bleManager.finishVolumeSeeking() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { + guard generation == volumeFeedbackGeneration else { return } + withAnimation(.easeInOut(duration: 0.16)) { + isVolumeFeedbackVisible = false + } + } + } + + private func volumeFeedbackXPosition(in width: CGFloat) -> CGFloat { + let horizontalInset = CGFloat(18) + let usableWidth = max(width - horizontalInset * 2, 0) + let progress = CGFloat(CompactVolumePresentation.normalizedProgress( + current: displayedVolume, + maximum: bleManager.volumeMax + )) + return min(max(horizontalInset + usableWidth * progress, 30), max(width - 30, 30)) } private var systemState: DarkControlSystemState { @@ -587,115 +690,6 @@ struct ContentView: View { .accessibilityLabel(title) } - private var connectionSection: 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) - } - .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) - } - .accessibilityLabel("连接状态,点按扫描或重连") - - Spacer() - - 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() - - Picker("歌词显示", selection: lyricDisplayModeBinding) { - ForEach(LyricDisplayMode.allCases) { mode in - Text(mode.menuTitle).tag(mode) - } - } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 19, weight: .bold)) - .frame(width: 42, height: 42) - } - .buttonStyle(PressScaleButtonStyle(pressedScale: 0.96)) - .background(.white.opacity(0.075), in: Circle()) - .overlay { - Circle().stroke(.white.opacity(0.075), lineWidth: 1) - } - .accessibilityLabel("更多操作") - } - .foregroundStyle(.white) - .animation(.easeInOut(duration: 0.2), value: bleManager.connectionDisplayState) - } - private var nowPlayingSection: some View { VStack(spacing: 9) { albumArtView @@ -955,83 +949,6 @@ struct ContentView: View { .animation(.spring(response: 0.28, dampingFraction: 0.72), value: bleManager.isPlaying) } - private var volumeSection: some View { - HStack(spacing: 12) { - Image(systemName: volumeIcon) - .font(.headline) - .frame(width: 24) - - Slider( - value: Binding( - get: { - Double( - bleManager.isVolumeSeeking - ? bleManager.volumeSeekValue - : bleManager.volumeCurrent - ) - }, - set: { value in - bleManager.updateVolumeSeekValue(value) - } - ), - in: 0...Double(max(bleManager.volumeMax, 1)), - step: 1, - onEditingChanged: { editing in - if editing { - bleManager.beginVolumeSeeking() - } else { - bleManager.finishVolumeSeeking() - } - } - ) - .tint(.white.opacity(0.92)) - .disabled(!isConnected || bleManager.volumeMax <= 0) - - Text("\(displayedVolume) / \(bleManager.volumeMax)") - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.white.opacity(0.76)) - .frame(width: 58, alignment: .trailing) - .contentTransition(.numericText()) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .foregroundStyle(.white) - .background(.white.opacity(0.070), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 1) - } - .disabled(!isConnected) - .opacity(isConnected ? 1 : 0.52) - .animation(.easeInOut(duration: 0.16), value: displayedVolume) - } - - private var connectionColor: Color { - switch bleManager.connectionDisplayState { - case "connected": - return .green - case "reconnecting": - return .orange - case "disconnected": - return .secondary - default: - return .secondary - } - } - - private var connectionStatusTitle: String { - switch bleManager.connectionDisplayState { - case "connected": - return "Sony 已连接" - case "reconnecting": - return "正在重连" - case "disconnected": - return "未连接" - default: - return "未连接" - } - } - private var isConnected: Bool { bleManager.connectionDisplayState == "connected" } @@ -1305,35 +1222,39 @@ private struct DarkControlUIState { let playback: DarkPlaybackVisualState } -private enum DarkControlConnectionState: String { +enum DarkControlConnectionState: String { case connected case reconnecting case connecting case disconnected - var title: String { + var compactTitle: String { switch self { case .connected: - return "已连接" + return "Sony" case .connecting: return "连接中" case .reconnecting: return "重连中" case .disconnected: - return "未连接" + return "连接" } } - var detail: String { + var showsProgressIndicator: Bool { + self == .connecting || self == .reconnecting + } + + var accessibilityLabel: String { switch self { case .connected: - return "设备连接正常" + return "Sony 已连接" case .connecting: return "正在连接 Sony" case .reconnecting: - return "正在恢复连接" + return "正在重新连接 Sony" case .disconnected: - return "请检查设备连接" + return "Sony 未连接" } } @@ -1344,23 +1265,17 @@ private enum DarkControlConnectionState: String { case .connecting: return .orange case .reconnecting: - return .purple + return .orange case .disconnected: - return .gray + return .orange } } +} - var icon: String { - switch self { - case .connected: - return "checkmark.circle" - case .connecting: - return "dot.radiowaves.left.and.right" - case .reconnecting: - return "arrow.clockwise.circle" - case .disconnected: - return "link.slash" - } +struct CompactVolumePresentation { + static func normalizedProgress(current: Int, maximum: Int) -> Double { + guard maximum > 0 else { return 0 } + return min(max(Double(current) / Double(maximum), 0), 1) } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticSnapshot.swift index 95b6874..44a8641 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)") diff --git a/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift b/IOSBleFeasibility/IOSBleFeasibility/NowPlayingDiagnosticView.swift index 4676b79..9e2b005 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" + ) } } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift index ec50a4f..befef6b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesStore.swift @@ -69,7 +69,9 @@ 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" @@ -86,6 +88,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) } } @@ -110,6 +122,7 @@ final class PreferencesStore: ObservableObject { private init(defaults: UserDefaults = .standard) { self.defaults = defaults + Self.migrateAutomaticLyricSyncIfNeeded(defaults: defaults) appExperienceMode = Self.loadAppExperienceMode(defaults: defaults) autoReconnectEnabled = Self.loadBool( defaults: defaults, @@ -119,7 +132,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( @@ -143,7 +161,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( @@ -160,7 +183,8 @@ final class PreferencesStore: ObservableObject { func resetToDefaults() { appExperienceMode = .defaultMode autoReconnectEnabled = true - lyricOffsetMs = 600 + automaticLyricSyncEnabled = true + lyricOffsetMs = 0 lyricDisplayMode = .originalTranslation artworkEnhancementEnabled = true artworkDisplaySize = .defaultOption @@ -173,6 +197,33 @@ final class PreferencesStore: ObservableObject { return raw.flatMap(AppExperienceMode.init(rawValue:)) ?? .defaultMode } + 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 @@ -252,7 +303,9 @@ final class PreferencesStore: ObservableObject { private func logLoaded() { AppLogStore.shared.append( "[Preferences] loaded mode=\(appExperienceMode.rawValue) " + - "autoReconnect=\(autoReconnectEnabled) lyricOffsetMs=\(lyricOffsetMs) " + + "autoReconnect=\(autoReconnectEnabled) " + + "automaticLyricSync=\(automaticLyricSyncEnabled) " + + "lyricOffsetMs=\(lyricOffsetMs) " + "lyricDisplayMode=\(lyricDisplayMode.rawValue) " + "artworkEnhancement=\(artworkEnhancementEnabled) " + "artworkDisplaySize=\(artworkDisplaySize.rawValue) " + diff --git a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift index 18d4a89..873c5ad 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/PreferencesView.swift @@ -123,8 +123,15 @@ struct PreferencesView: View { private var lyricSection: some View { PreferencesCard(title: "歌词", systemImage: "text.quote") { VStack(alignment: .leading, spacing: 10) { + Toggle("自动同步歌词时间", isOn: automaticLyricSyncBinding) + .tint(.green) + + Text("自动补偿 Sony 与 iPhone 时钟差及蓝牙传输延迟") + .font(.caption) + .foregroundStyle(.white.opacity(0.58)) + HStack { - Text("歌词偏移校准") + Text("人工微调") .font(.subheadline.weight(.semibold)) Spacer() Text(offsetLabel(Int64(preferences.lyricOffsetMs))) @@ -255,6 +262,13 @@ struct PreferencesView: View { ) } + private var automaticLyricSyncBinding: Binding { + Binding( + get: { preferences.automaticLyricSyncEnabled }, + set: { bleManager.setAutomaticLyricSyncEnabled($0) } + ) + } + private var lyricDisplayModeBinding: Binding { Binding( get: { preferences.lyricDisplayMode }, diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index ee48dcb..cdd847f 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -4,19 +4,42 @@ import XCTest @testable import sonyMusic final class PerformanceStabilityTests: XCTestCase { - func testSoftWriteRecoveryRetainsQueuedMediaRequests() { - XCTAssertTrue( - CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand( - "GET_LYRIC_SECONDARY" - ) + func testWriteTimeoutDoesNotAdvanceQueueAndSuspendsInBackground() { + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: false, + transportReady: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .suspendUntilForeground ) - XCTAssertTrue( - CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand( - "ALBUM_ART_REQUEST" - ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .extendWithoutAdvancingQueue ) - XCTAssertTrue( - CommandWriteSoftRecoveryPolicy.shouldRetainPendingCommand("PING") + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: true, + timeoutCountAfterIncrement: 2, + reconnectThreshold: 2 + ), + .reconnect + ) + XCTAssertEqual( + CommandWriteTimeoutPolicy.action( + appIsActive: true, + transportReady: false, + timeoutCountAfterIncrement: 1, + reconnectThreshold: 2 + ), + .reconnect ) } @@ -34,6 +57,123 @@ final class PerformanceStabilityTests: XCTestCase { XCTAssertTrue(fence.shouldAccept(generation: 1, sequence: 1, positionMs: 20)) } + func testMonotonicClockSyncAndAutomaticPlaybackCompensation() { + var synchronizer = MonotonicClockSynchronizer() + XCTAssertEqual( + synchronizer.record( + clientSendElapsedMs: 1_000, + serverReceiveElapsedMs: 920, + serverSendElapsedMs: 922, + clientReceiveElapsedMs: 1_042 + )?.isConfident, + false + ) + _ = synchronizer.record( + clientSendElapsedMs: 2_000, + serverReceiveElapsedMs: 1_922, + serverSendElapsedMs: 1_924, + clientReceiveElapsedMs: 2_046 + ) + let snapshot = synchronizer.record( + clientSendElapsedMs: 3_000, + serverReceiveElapsedMs: 2_919, + serverSendElapsedMs: 2_921, + clientReceiveElapsedMs: 3_041 + ) + XCTAssertEqual(snapshot?.isConfident, true) + XCTAssertEqual(snapshot?.bestRoundTripMs, 39) + XCTAssertEqual(Int64(snapshot?.localMinusServerMs.rounded() ?? 0), 100) + + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + serverSampleElapsedMs: 4_000, + localReceiveElapsedMs: 4_220, + playbackSpeed: 1.0, + isPlaying: true, + durationMs: 60_000, + synchronizer: synchronizer + ), + .resolved(positionMs: 10_120, transportAgeMs: 120) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + measuredTransportAgeMs: 2_000, + playbackSpeed: 1.0, + isPlaying: true, + durationMs: 60_000 + ), + .stale(transportAgeMs: 2_000) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.resolve( + remotePositionMs: 10_000, + measuredTransportAgeMs: 200, + playbackSpeed: 1.0, + isPlaying: false, + durationMs: 60_000 + ), + .resolved(positionMs: 10_000, transportAgeMs: 200) + ) + XCTAssertEqual( + RemotePlaybackAnchorPolicy.smoothedPosition( + currentPositionMs: 10_000, + targetPositionMs: 10_200 + ), + 10_100 + ) + } + + func testAutomaticLyricSyncMigratesOnlyLegacyDefaultOffset() { + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 600, + migrationCompleted: false + ), + 0 + ) + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 300, + migrationCompleted: false + ), + 300 + ) + XCTAssertEqual( + PreferencesStore.migratedLegacyLyricOffset( + storedOffset: 600, + migrationCompleted: true + ), + 600 + ) + } + + func testCompactConnectionAndVolumePresentation() { + XCTAssertEqual( + DarkControlConnectionState.connected.compactTitle, + "Sony" + ) + XCTAssertEqual( + DarkControlConnectionState.disconnected.compactTitle, + "连接" + ) + XCTAssertTrue(DarkControlConnectionState.connecting.showsProgressIndicator) + XCTAssertTrue(DarkControlConnectionState.reconnecting.showsProgressIndicator) + XCTAssertFalse(DarkControlConnectionState.connected.showsProgressIndicator) + + XCTAssertEqual( + CompactVolumePresentation.normalizedProgress(current: 8, maximum: 16), + 0.5, + accuracy: 0.001 + ) + XCTAssertEqual( + CompactVolumePresentation.normalizedProgress(current: 20, maximum: 15), + 1, + accuracy: 0.001 + ) + } + func testA1AndA2DispatchAndOutOfOrderAssembly() { let a1 = packet(magic: 0xA1, kind: 3, index: 0, total: 1, payload: Data([9])) let decodedA1 = BLEBinaryChunkCodec.decode(a1, expectedMagic: 0xA1) 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 f69ebbe..42c8004 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleGattServerManager.kt @@ -127,7 +127,13 @@ class BleGattServerManager( 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, @@ -200,8 +206,11 @@ class BleGattServerManager( @Volatile private var lastNotifyFailureAtMs = 0L 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 @@ -263,8 +272,11 @@ class BleGattServerManager( 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) @@ -286,8 +298,11 @@ class BleGattServerManager( 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() @@ -414,7 +429,7 @@ class BleGattServerManager( ) if (ok) { notifyQueue.onCommandResponseSent() - recordCommandSuccess(command) + recordCommandSuccess(command, device?.address) } } val dispatchExecutor = when (command) { @@ -513,6 +528,10 @@ class BleGattServerManager( subscribedDevices[device.address] = device notifyFailureCountByAddress[device.address] = 0 lastSubscribedAtMs = SystemClock.elapsedRealtime() + 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, @@ -543,8 +562,11 @@ class BleGattServerManager( subscribedDevices.remove(device.address) notifyQueue.removeDevice(device.address) 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 @@ -749,8 +771,11 @@ class BleGattServerManager( recentConnectionCallbacks.clear() recentMtuCallbacks.clear() notifyFailureCountByAddress.clear() + subscribedAtByAddress.clear() + lastCommandSuccessAtByAddress.clear() lastNotifySuccessAtByAddress.clear() lastNotifyFailureAtByAddress.clear() + lastLinkProbeAtByAddress.clear() statusCharacteristic = null mediaExecutionContext.close() playbackStateBuffer.shutdown() @@ -812,12 +837,8 @@ class BleGattServerManager( !started || serverState == ServerState.STARTING -> BleHealthState.STARTING serverState != ServerState.READY -> BleHealthState.STARTING currentNotifyFailureCount >= NOTIFY_FAILURE_SUSPECT_THRESHOLD -> BleHealthState.SUSPECT - subscribedWithoutSuccessOlderThan(SUSPECT_NO_SUCCESS_HEARTBEAT_MS) -> + 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 @@ -859,8 +880,11 @@ class BleGattServerManager( 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) @@ -882,26 +906,18 @@ class BleGattServerManager( return true } - fun hasSuccessHeartbeatOlderThan(ageMs: Long): Boolean { - return BleSubscribedLinkPolicy.isActivityStale( - subscribed = subscribedDevices.isNotEmpty(), - subscribedAtMs = lastSubscribedAtMs, - lastCommandSuccessAtMs = lastCommandSuccessAtMs, - lastNotifySuccessAtMs = lastNotifySuccessAtMs, - nowMs = SystemClock.elapsedRealtime(), - maxAgeMs = ageMs - ) - } - - fun subscribedWithoutSuccessOlderThan(ageMs: Long): Boolean { - return BleSubscribedLinkPolicy.isWithoutSuccessStale( - subscribed = subscribedDevices.isNotEmpty(), - subscribedAtMs = lastSubscribedAtMs, - lastCommandSuccessAtMs = lastCommandSuccessAtMs, - lastNotifySuccessAtMs = lastNotifySuccessAtMs, - nowMs = SystemClock.elapsedRealtime(), - maxAgeMs = ageMs - ) + fun hasAnyStaleSubscriber(ageMs: Long): Boolean { + val nowMs = SystemClock.elapsedRealtime() + return subscribedDevices.keys.any { address -> + BleSubscribedLinkPolicy.isActivityStale( + subscribed = true, + subscribedAtMs = subscribedAtByAddress[address] ?: lastSubscribedAtMs, + lastCommandSuccessAtMs = lastCommandSuccessAtByAddress[address] ?: 0L, + lastNotifySuccessAtMs = lastNotifySuccessAtByAddress[address] ?: 0L, + nowMs = nowMs, + maxAgeMs = ageMs + ) + } } fun notifyFailureCount(): Int = notifyFailureCountByAddress.values.maxOrNull() ?: 0 @@ -913,6 +929,42 @@ class BleGattServerManager( } } + /** + * 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 @@ -947,8 +999,11 @@ 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") } @@ -993,17 +1048,6 @@ class BleGattServerManager( return lastNotifySuccessAtMs } - private fun isSuccessHeartbeatStale(nowMs: Long, maxAgeMs: Long): Boolean { - return BleSubscribedLinkPolicy.isActivityStale( - subscribed = subscribedDevices.isNotEmpty(), - subscribedAtMs = lastSubscribedAtMs, - lastCommandSuccessAtMs = lastCommandSuccessAtMs, - lastNotifySuccessAtMs = lastNotifySuccessAtMs, - nowMs = nowMs, - maxAgeMs = maxAgeMs - ) - } - private fun logDisconnectDiagnostics(address: String) { val snapshot = notifyQueue.snapshot() logger( @@ -1043,6 +1087,7 @@ class BleGattServerManager( seq: String? = null ) { val sourceAddress = sourceDevice.address + val commandReceivedElapsedMs = SystemClock.elapsedRealtime() when (command) { "PLAY_PAUSE", "NEXT", @@ -1108,7 +1153,7 @@ class BleGattServerManager( "GET_PLAYBACK_STATE" -> { sendPlaybackStateTo(sourceDevice, includeAlbumArt = true) } - "PING" -> sendPong(sourceDevice, request) + "PING" -> sendPong(sourceDevice, request, commandReceivedElapsedMs) "GET_VOLUME" -> sendStatusMessageTo( sourceDevice, mediaCommandExecutor.createVolumeState().toString() @@ -1174,6 +1219,7 @@ class BleGattServerManager( "albumArtBinary=${capabilities.binaryAlbumArt} " + "fullLyricsZlib=${capabilities.fullLyricsZlib} " + "lyricWindow=${capabilities.lyricWindow} ping=${capabilities.ping} " + + "clockSyncV1=${capabilities.clockSyncV1} " + "transferRetry=${capabilities.transferRetry}" ) sendShortJsonIfFits( @@ -1186,6 +1232,7 @@ class BleGattServerManager( .put("fullLyricsZlib", true) .put("lyricWindow", true) .put("ping", true) + .put("clockSyncV1", true) .put("transferRetry", true) ) sendPendingAlbumArtIfAny() @@ -1200,6 +1247,7 @@ class BleGattServerManager( fullLyricsZlib = request.optBoolean("fullLyricsZlib", false), lyricWindow = request.optBoolean("lyricWindow", false), ping = request.optBoolean("ping", false), + clockSyncV1 = request.optBoolean("clockSyncV1", false), transferRetry = request.optBoolean("transferRetry", false), negotiated = true ) @@ -1232,14 +1280,28 @@ class BleGattServerManager( lastAutoPushPlaying = null } - private fun sendPong(device: BluetoothDevice, request: JSONObject) { + 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 ) } @@ -1845,7 +1907,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 @@ -1863,10 +1925,12 @@ class BleGattServerManager( } private fun sendCompactPlaybackState(source: JSONObject): Boolean { - val compactState = compactPlaybackStatePayload(source) - val payloadSizeBeforeFit = compactState.toString().toByteArray(Charsets.UTF_8).size 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")} " + @@ -1874,7 +1938,12 @@ class BleGattServerManager( ) val sent = devices.fold(false) { anySent, device -> val fitted = fitCompactPlaybackState( - JSONObject(compactState.toString()), + compactPlaybackStatePayload( + source, + includeClockSync = connectionCommandCoordinator + .capabilities(device.address) + .clockSyncV1 + ), maximumPayloadFor(device) ) sendStatusMessageTo(device, fitted.toString()) || anySent @@ -1893,19 +1962,33 @@ class BleGattServerManager( source: JSONObject ): Boolean { val fitted = fitCompactPlaybackState( - compactPlaybackStatePayload(source), + compactPlaybackStatePayload( + source, + includeClockSync = connectionCommandCoordinator + .capabilities(device.address) + .clockSyncV1 + ), maximumPayloadFor(device) ) return sendStatusMessageTo(device, fitted.toString()) } - private fun compactPlaybackStatePayload(source: JSONObject): JSONObject { - return JSONObject() + 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( diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt index 35a3296..fb62178 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleSubscribedLinkPolicy.kt @@ -2,9 +2,9 @@ package com.example.playeragent.ble /** * Detects a subscribed GATT link that has stopped producing any observable - * activity. This must not depend on queued outbound work: some vendor stacks - * miss the disconnect callback and otherwise leave an idle ghost subscriber - * blocking advertising indefinitely. + * 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( @@ -45,4 +45,31 @@ internal object BleSubscribedLinkPolicy { 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/CurrentWordPushEngine.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt index 83c118a..f757461 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/CurrentWordPushEngine.kt @@ -17,6 +17,7 @@ class CurrentWordPushEngine( private val logger: (String) -> Unit, private val sendStatusMessage: (String) -> Boolean, private val normalizeTrackId: (String) -> String = { it }, + private val includeClockSyncFields: () -> Boolean = { false }, private val expectedGeneration: () -> Long = { CurrentTrackRuntimeCache.currentGeneration() }, private val currentWordState: () -> CurrentWordState? = { CurrentTrackRuntimeCache.currentWordState() @@ -142,6 +143,9 @@ class CurrentWordPushEngine( .put("position", state.positionMs) .put("timestamp", state.timestampMs) .put("version", state.version) + if (includeClockSyncFields()) { + payload.put("sampleMono", state.sampleElapsedMs) + } val sent = sendStatusMessage(payload.toString()) synchronized(lock) { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt index fe96b88..a34e822 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/MediaTransferCoordinators.kt @@ -95,6 +95,7 @@ internal class ConnectionCommandCoordinator { val fullLyricsZlib: Boolean = false, val lyricWindow: Boolean = false, val ping: Boolean = false, + val clockSyncV1: Boolean = false, val transferRetry: Boolean = false, val negotiated: Boolean = false ) diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt index 9017b0d..44504b6 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentTrackRuntimeCache.kt @@ -46,12 +46,38 @@ data class CurrentWordState( val hasWordTiming: Boolean, val positionMs: Long, val timestampMs: Long, - val version: Int = 1 + val sampleElapsedMs: Long = 0L, + val playbackSpeed: Float = 1f, + val version: Int = 2 ) { val wordKey: String get() = "$trackId|$trackGeneration|$lineIndex|$wordIndex|$wordStartMs" } +internal object PlaybackPositionAnchorPolicy { + fun projectedPositionMs( + positionMs: Long, + positionAnchorElapsedMs: Long, + nowElapsedMs: Long, + durationMs: Long, + isPlaying: Boolean, + playbackSpeed: Float + ): Long { + val elapsedMs = if (isPlaying && positionAnchorElapsedMs > 0L) { + (nowElapsedMs - positionAnchorElapsedMs).coerceAtLeast(0L) + } else { + 0L + } + val speed = playbackSpeed.takeIf { it.isFinite() && it > 0f } ?: 1f + val projected = positionMs + (elapsedMs * speed).toLong() + return if (durationMs > 0L) { + projected.coerceIn(0L, durationMs) + } else { + projected.coerceAtLeast(0L) + } + } +} + object CurrentTrackRuntimeCache { private val lock = Any() @@ -72,8 +98,10 @@ object CurrentTrackRuntimeCache { artist: String, album: String, positionMs: Long, + positionSampleElapsedMs: Long, durationMs: Long, isPlaying: Boolean, + playbackSpeed: Float, currentLine: String, lyricSource: String, lastPlaybackState: JSONObject, @@ -110,8 +138,10 @@ object CurrentTrackRuntimeCache { artist = artist, album = album, positionMs = positionMs, + positionAnchorElapsedMs = positionSampleElapsedMs, durationMs = durationMs, isPlaying = isPlaying, + playbackSpeed = playbackSpeed, currentLine = currentLine, currentWord = findCurrentWord(base.lyricLines, positionMs), lyricSource = lyricSource, @@ -162,7 +192,7 @@ object CurrentTrackRuntimeCache { logger: ((String) -> Unit)? = null ) { val runtimeLines = lines.map { it.toRuntimeLine() } - mutate(logger) { previous, now, _ -> + mutate(logger) { previous, now, startedAt -> if (previous == null || previous.songKey != songKey) { previous } else { @@ -181,6 +211,7 @@ object CurrentTrackRuntimeCache { translationLines = runtimeLines.map { it.translation }, romanizationLines = runtimeLines.map { it.romanization }, positionMs = positionMs, + positionAnchorElapsedMs = startedAt, currentLine = currentLine, currentWord = currentWord, lastUpdatedAtMs = now @@ -195,12 +226,13 @@ object CurrentTrackRuntimeCache { currentLine: String, logger: ((String) -> Unit)? = null ) { - mutate(logger) { previous, now, _ -> + mutate(logger) { previous, now, startedAt -> if (previous == null || previous.songKey != songKey) { previous } else { previous.copy( positionMs = positionMs, + positionAnchorElapsedMs = startedAt, currentLine = currentLine, currentWord = findCurrentWord(previous.lyricLines, positionMs), lastUpdatedAtMs = now @@ -290,7 +322,11 @@ object CurrentTrackRuntimeCache { ): PlaybackStateSnapshot? { synchronized(lock) { val track = current ?: return null - val wordState = findCurrentWordStateLocked(track, System.currentTimeMillis()) + val wordState = findCurrentWordStateLocked( + track = track, + timestampMs = System.currentTimeMillis(), + elapsedRealtimeMs = SystemClock.elapsedRealtime() + ) val snapshot = PlaybackStateSnapshot( trackId = track.trackId, title = track.title, @@ -314,7 +350,9 @@ object CurrentTrackRuntimeCache { albumArtState = track.albumArtState, volume = volume, connectionState = connectionState, - lastUpdatedAtMs = track.lastUpdatedAtMs + // Playback diffs compare position samples, so this must use the + // monotonic position anchor rather than metadata/artwork update time. + lastUpdatedAtMs = track.positionAnchorElapsedMs ) lastSnapshot = snapshot PlaybackStateDiffEngine.recordSnapshotBuilt() @@ -322,22 +360,30 @@ object CurrentTrackRuntimeCache { } } - fun currentWordState(timestampMs: Long = System.currentTimeMillis()): CurrentWordState? { + fun currentWordState( + timestampMs: Long = System.currentTimeMillis(), + elapsedRealtimeMs: Long = SystemClock.elapsedRealtime() + ): CurrentWordState? { synchronized(lock) { val track = current ?: return null - return findCurrentWordStateLocked(track, timestampMs) + return findCurrentWordStateLocked(track, timestampMs, elapsedRealtimeMs) } } /** Delay to the next word/line boundary, capped for periodic drift correction. */ fun nextCurrentWordBoundaryDelayMs( timestampMs: Long = System.currentTimeMillis(), + elapsedRealtimeMs: Long = SystemClock.elapsedRealtime(), maximumDriftCorrectionMs: Long = 500L ): Long? { synchronized(lock) { val track = current ?: return null if (!track.isPlaying || track.lyricLines.isEmpty()) return null - val state = findCurrentWordStateLocked(track, timestampMs) ?: return maximumDriftCorrectionMs + val state = findCurrentWordStateLocked( + track, + timestampMs, + elapsedRealtimeMs + ) ?: return maximumDriftCorrectionMs val line = track.lyricLines.getOrNull(state.lineIndex) val candidates = mutableListOf() if (line != null && state.wordIndex >= 0) { @@ -445,22 +491,20 @@ object CurrentTrackRuntimeCache { private fun findCurrentWordStateLocked( track: CurrentTrackSnapshot, - timestampMs: Long + timestampMs: Long, + elapsedRealtimeMs: Long ): CurrentWordState? { if (track.trackId.isBlank() || track.lyricLines.isEmpty()) { return null } - val elapsedMs = if (track.isPlaying && track.lastUpdatedAtMs > 0L) { - (timestampMs - track.lastUpdatedAtMs).coerceAtLeast(0L) - } else { - 0L - } - val rawPosition = track.positionMs + elapsedMs - val position = if (track.durationMs > 0L) { - rawPosition.coerceIn(0L, track.durationMs) - } else { - rawPosition.coerceAtLeast(0L) - } + val position = PlaybackPositionAnchorPolicy.projectedPositionMs( + positionMs = track.positionMs, + positionAnchorElapsedMs = track.positionAnchorElapsedMs, + nowElapsedMs = elapsedRealtimeMs, + durationMs = track.durationMs, + isPlaying = track.isPlaying, + playbackSpeed = track.playbackSpeed + ) val indexed = findCurrentWordIndexed(track.lyricLines, position) ?: findCurrentLineIndexed(track.lyricLines, position) ?: return null @@ -474,7 +518,9 @@ object CurrentTrackRuntimeCache { wordEndMs = indexed.word.startMs + indexed.word.durationMs.coerceAtLeast(0L), hasWordTiming = indexed.hasWordTiming, positionMs = position, - timestampMs = timestampMs + timestampMs = timestampMs, + sampleElapsedMs = elapsedRealtimeMs, + playbackSpeed = track.playbackSpeed ) } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt index 6646dc1..cde18ba 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/PlaybackStateReader.kt @@ -114,7 +114,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() @@ -293,6 +298,9 @@ class PlaybackStateReader( .put("artist", artist) .put("album", album) .put("position", position) + .put("positionSampleElapsedMs", positionSampleElapsedMs) + .put("positionSampleUnixMs", positionSampleUnixMs) + .put("speed", playbackSpeed.toDouble()) .put("duration", duration) .put("lyric", lyric) .put("lyricStatus", lyricStatus) @@ -305,8 +313,10 @@ class PlaybackStateReader( artist = artist, album = album, positionMs = position, + positionSampleElapsedMs = positionSampleElapsedMs, durationMs = duration, isPlaying = playing, + playbackSpeed = playbackSpeed, currentLine = lyric, lyricSource = diagnostic.source, lastPlaybackState = response, @@ -821,7 +831,10 @@ class PlaybackStateReader( return 0L } - private fun calculatePosition(playbackState: PlaybackState?): Long { + private fun calculatePosition( + playbackState: PlaybackState?, + nowElapsedMs: Long = SystemClock.elapsedRealtime() + ): Long { if (playbackState == null) { return 0L } @@ -831,7 +844,7 @@ class PlaybackStateReader( return basePosition } - val elapsedSinceUpdate = SystemClock.elapsedRealtime() - playbackState.lastPositionUpdateTime + val elapsedSinceUpdate = nowElapsedMs - playbackState.lastPositionUpdateTime val adjustedPosition = basePosition + (elapsedSinceUpdate * playbackState.playbackSpeed).toLong() return adjustedPosition.coerceAtLeast(0L) } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt index 0075b81..ccfed51 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricModels.kt @@ -187,8 +187,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(), 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 b0a4286..70e0b08 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/service/PlayerAgentForegroundService.kt @@ -403,11 +403,7 @@ class PlayerAgentForegroundService : Service() { val failingAddresses = manager.failingSubscriberAddresses( NOTIFY_FAILURE_RECOVERY_THRESHOLD ) - if (MultiControllerPolicy.shouldIsolateOnlyFailingControllers( - failingAddresses.size, - snapshot.subscribedCount - ) - ) { + if (failingAddresses.isNotEmpty()) { val isolated = manager.disconnectSubscribers( failingAddresses, "notify_failures" @@ -419,24 +415,25 @@ class PlayerAgentForegroundService : Service() { publishBleHealthSnapshot("unhealthy controller isolated") return } - manager.clearStaleSubscribers("notify_failures") - restartAdvertisingFromWatchdog("notify_failures") - recoverBleStack("notify_failures", respectCooldown = true) - 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") @@ -847,7 +844,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/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt index 1d8c955..1fbc5f1 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleGattServerManagerPolicyTest.kt @@ -64,7 +64,7 @@ class BleGattServerManagerPolicyTest { } @Test - fun subscribedLinkStalenessDoesNotDependOnQueuedOutboundWork() { + fun subscribedLinkSilenceRequestsRateLimitedProbeInsteadOfRecovery() { assertFalse( BleSubscribedLinkPolicy.isActivityStale( subscribed = true, @@ -105,5 +105,59 @@ class BleGattServerManagerPolicyTest { 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/CurrentWordPushEngineTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt index 11a9a93..ea4e0df 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/CurrentWordPushEngineTest.kt @@ -70,6 +70,33 @@ class CurrentWordPushEngineTest { assertEquals(2, sent) } + @Test + fun clockSyncPayloadAddsMonotonicSampleOnlyWhenNegotiated() { + var nowMs = 1_000L + val payloads = mutableListOf() + val engine = CurrentWordPushEngine( + logger = {}, + sendStatusMessage = { + payloads += JSONObject(it) + true + }, + includeClockSyncFields = { true }, + expectedGeneration = { 7L }, + currentWordState = { + word(positionMs = 1_000L, wordIndex = 1).copy(sampleElapsedMs = 88_000L) + }, + elapsedRealtime = { nowMs } + ) + + assertNull(engine.pushCurrentWord()) + nowMs += 500L + assertTrue(engine.pushCurrentWord() != null) + + assertEquals(88_000L, payloads.single().getLong("sampleMono")) + assertTrue(payloads.single().has("timestamp")) + assertTrue(payloads.single().toString().toByteArray().size <= 182) + } + private fun word(positionMs: Long, wordIndex: Int): CurrentWordState { return CurrentWordState( trackId = "track-1", diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt index d6ffa8f..489decf 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/ble/MediaTransferCoordinatorsTest.kt @@ -141,12 +141,14 @@ class MediaTransferCoordinatorsTest { 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) } 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/README.md b/README.md index fa8969c..18df55b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index e796015..6ec1ae1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,6 +57,7 @@ Sony 的 Android Walkman 可以安装 QQ 音乐,但它的播放状态、QRC 尽可能及时和稳定地展示当前歌曲: - **QQ 音乐逐字歌词**:解析本地加密 QRC 歌词,支持逐字时间、翻译和罗马音数据; +- **自动歌词校时**:使用 Sony/iPhone 单调时钟映射补偿 BLE 排队和传输延迟,并保留人工微调; - **快速封面显示**:优先发送小型预览图,再在后台升级高清封面; - **完整播放控制**:支持播放、暂停、上一首、下一首、进度和音量; - **双原生控制端**:iPhone 使用 SwiftUI,Android 使用 Jetpack Compose,均支持 @@ -113,6 +114,7 @@ Sony 端是播放状态、歌词和封面的权威数据源,iPhone 与 Android - 控制响应、播放状态和当前词可以抢占完整歌词、高清封面等大任务; - 播放中和暂停中采用不同的静默探测周期,避免正常状态下频繁重连; - 新协议使用轻量 PING/PONG,旧 Sony 版本自动回退到原有状态请求; +- iPhone 在连接后主动进行低 RTT 多样本校时,播放位置按消息采样时刻自动追平,严重积压的旧歌词不会覆盖当前进度; - 连续探测失败后才执行硬重连,明确断开事件仍会立即处理。 ## 兼容性 diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index 785a341..d1646f5 100644 --- a/docs/BLE_PROTOCOL.md +++ b/docs/BLE_PROTOCOL.md @@ -87,7 +87,7 @@ ## 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` 别名。 @@ -95,6 +95,17 @@ - FullLyrics 重传只校验歌词自身的 `trackId + generation + transferId`,不得依赖当前封面 ID/状态。 6. 协商失败、正文超限、metadata 超 MTU 或旧端连接时自动回退 legacy,不改变 QRC 解密和 secondary 协议。 +## 自动歌词时钟同步 + +- `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` 与歌词、封面等普通状态更新时间分离;非播放状态更新不得重置播放位置锚点。 + ## 自适应发送 - 每次连接创建独立 `BleLinkProfile`,记录设备、连接代次、MTU、EWMA notify RTT、失败率和 JSON/二进制间隔;重连或 MTU 改变时重置。 @@ -106,8 +117,9 @@ ## iOS 写回调容错 -- CoreBluetooth 偶发漏掉单次 `didWrite`,但 Sony 已处理命令且 status notify 仍持续时,iOS 先进入 1 秒 late-callback fence,丢弃待处理的后台写并保留控制写。 -- 后续真实写成功会清零计数;连续两次写回调超时才 hard reconnect。没有活动 notify 的超时仍按断链处理。 +- iOS 进入 inactive/background 时暂停健康探测、订阅超时、写回调超时和时钟同步调度;回到前台先用 `PING`(旧协议用 `GET_PLAYBACK_STATE`)验证链路,收到任意有效 notify 后才同步播放状态、音量和歌词。 +- CoreBluetooth 偶发漏掉单次 `didWrite` 时,第一次超时只标记 suspect 并延长等待,不弹出 in-flight 请求、不推进下一条写,避免迟到回调串到下一条命令;连续两次超时才 hard reconnect。 +- Sony 端 45 秒无业务流量只会按设备发送 `{"type":"link"}` 小探针,不再重建共享 GATT。探针的真实 notify 失败累计到阈值后,仅隔离失败地址,其他控制器继续工作。 - 恢复连接的 smoke 参数通过一次性 App 容器标记传递,避免 CoreBluetooth 后台恢复抢先启动导致测试序列丢失。 ## 不允许随便修改的点 diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 094b974..e4a8db7 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -39,6 +39,7 @@ ## 关键状态 - Sony 是播放、历史、歌词和封面来源的权威端。 +- iOS 与 Sony 可协商 `clockSyncV1`,使用单调时钟和播放位置采样时间动态补偿 BLE 歌词延迟;严重积压的旧锚点不得覆盖当前进度。 - iPhone 是同步、缓存和展示端。 - Android Controller 是与 iPhone 对等的同步、缓存和展示端,不包含 Live Activity。 - iOS `BLETestManager` 仍是连接/协议分发中心,但设置已拆到 `PreferencesStore`,封面接收已拆到 `AlbumArtReceiver`。 diff --git a/docs/RECONNECT_HEALTH_ARCHITECTURE.md b/docs/RECONNECT_HEALTH_ARCHITECTURE.md index 8db550d..18f3898 100644 --- a/docs/RECONNECT_HEALTH_ARCHITECTURE.md +++ b/docs/RECONNECT_HEALTH_ARCHITECTURE.md @@ -25,8 +25,9 @@ 5. Health timer 检查静默时间:播放中 15 秒、暂停中 30 秒才探测。 6. V2 发送轻量 `PING/PONG`;旧 Sony 回退 `GET_PLAYBACK_STATE`。连续两次探测失败才 hard reconnect,明确断开回调仍立即处理。 7. `connectionDisplayState` 给 UI,`connectionHealthState` 给诊断和控制保护。 -8. CoreBluetooth 状态恢复优先复用已连接 Sony 和已恢复 characteristic。恢复未完成时标为 `suspect`,前台生命周期检查必须等待 notify 确认,不能以初始 `disconnected` 强制断开有效连接。 -9. 单次 `didWrite` 回调超时但 3 秒内仍收到 status notify 时只做软恢复;连续两次写回调超时才 hard reconnect。 +8. CoreBluetooth 状态恢复优先复用已连接 Sony 和已恢复 characteristic。进入 inactive/background 后暂停 health、订阅/写超时和时钟同步;回到前台先用单次探针验证,收到有效 notify 后才恢复状态、音量和歌词同步。 +9. 单次 `didWrite` 回调超时只标记 suspect 并延长等待,不移除 in-flight 请求、不推进写队列;连续两次写回调超时才 hard reconnect。 +10. Sony 端业务静默只触发按地址的轻量 notify 探针;只有真实 notify 失败达到阈值才隔离该地址,禁止用静默时间直接重建共享 GATT。 ## 关键状态 @@ -59,7 +60,8 @@ - 一直显示重连:看 `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 soft recovery`;仍有 notify 时不应紧跟 hard reconnect。 +- 写回调偶发丢失:看 `[CTRL-iOS] write timeout extended`;第一轮超时后 in-flight 序号应保持不变。 +- 前后台恢复:看 `BLE watchdogs paused`、`foreground validation queued/success`,验证成功前不应出现状态/音量/歌词同步突发。 - 胶囊闪烁:区分 `connectionDisplayState` 和 `connectionHealthState`,日常模式不应展示技术细节。 ## 修改后必须跑哪些 smoke test 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 From 72f781e804fb718baa2ba78341342b2ca2f9f366 Mon Sep 17 00:00:00 2001 From: sqz Date: Sun, 23 Aug 2026 19:30:52 +0800 Subject: [PATCH 12/13] Complete BLE V3 integration and app updates Add V3 capability negotiation, structured media status, reconnect improvements, updated Sony and iOS experiences, expanded tests, localization, architecture docs, and FlowTest artifacts. --- AGENTS.md | 23 + ..._12\346\250\241\345\235\227_2560x1440.zip" | Bin 0 -> 2218004 bytes .../01_quality_command_center.html" | 6 + .../01_quality_command_center.png" | Bin 0 -> 212419 bytes .../02_service_catalog.html" | 6 + .../02_service_catalog.png" | Bin 0 -> 183554 bytes .../03_protocol_workbench.html" | 15 + .../03_protocol_workbench.png" | Bin 0 -> 164134 bytes .../04_workflow_studio_build.html" | 19 + .../04_workflow_studio_build.png" | Bin 0 -> 202817 bytes .../05_workflow_impact_run.html" | 5 + .../05_workflow_impact_run.png" | Bin 0 -> 211867 bytes .../06_performance_lab.html" | 8 + .../06_performance_lab.png" | Bin 0 -> 190355 bytes .../07_contract_hub.html" | 5 + .../07_contract_hub.png" | Bin 0 -> 191564 bytes .../08_change_impact_analysis.html" | 7 + .../08_change_impact_analysis.png" | Bin 0 -> 201222 bytes .../09_quality_insights.html" | 6 + .../09_quality_insights.png" | Bin 0 -> 189965 bytes .../10_ai_change_set_review.html" | 5 + .../10_ai_change_set_review.png" | Bin 0 -> 197925 bytes .../11_execution_fabric.html" | 5 + .../11_execution_fabric.png" | Bin 0 -> 187017 bytes .../12_environment_lab.html" | 5 + .../12_environment_lab.png" | Bin 0 -> 193737 bytes .../README.md" | 39 + .../project.pbxproj | 21 + .../IOSBleFeasibility/AppExperienceMode.swift | 8 +- .../IOSBleFeasibility/AppLogStore.swift | 54 + .../ArtworkEnhancementManager.swift | 8 +- .../IOSBleFeasibility/ArtworkImageCache.swift | 2 +- .../BLEObservableStateModels.swift | 411 ++++ .../IOSBleFeasibility/BLETestManager.swift | 1513 ++++++++++-- .../IOSBleFeasibility/ContentView.swift | 2053 +++++++++-------- .../IOSBleFeasibility/DebugToolsView.swift | 24 +- .../FullLyricsCacheStore.swift | 255 ++ .../IOSBleFeasibility/FullLyricsView.swift | 85 +- .../IOSBleFeasibilityApp.swift | 7 + .../IOSBleFeasibility/Info.plist | 4 +- .../IOSBleFeasibility/InfoPlist.xcstrings | 27 + .../LiveActivityArtworkStore.swift | 259 ++- .../LiveActivityManager.swift | 46 +- .../IOSBleFeasibility/Localizable.xcstrings | 195 ++ .../IOSBleFeasibility/LyricDiagnostic.swift | 8 +- .../IOSBleFeasibility/LyricDisplayMode.swift | 16 +- .../NextLiveActivityIntent.swift | 2 +- .../NowPlayingDiagnosticSnapshot.swift | 8 +- .../NowPlayingDiagnosticView.swift | 10 +- .../PlayPauseLiveActivityIntent.swift | 4 +- .../PlaybackHistoryView.swift | 16 +- .../IOSBleFeasibility/PreferencesStore.swift | 61 +- .../IOSBleFeasibility/PreferencesView.swift | 77 +- .../PreviousLiveActivityIntent.swift | 2 +- .../IOSBleFeasibility/SelfHealingEngine.swift | 24 +- .../SonyMusicActivityAttributes.swift | 39 +- .../PerformanceStabilityTests.swift | 1512 ++++++++++++ .../SonyMusicLiveActivityWidget.swift | 119 +- PlayerAgentApp/build.gradle | 1 + PlayerAgentApp/src/main/AndroidManifest.xml | 3 +- .../com/example/playeragent/MainActivity.kt | 264 ++- .../playeragent/ble/BleAdvertiserManager.kt | 9 +- .../playeragent/ble/BleGattServerManager.kt | 538 ++++- .../ble/BleV3SessionCoordinator.kt | 125 + .../ble/MediaTransferCoordinators.kt | 9 +- .../playeragent/ble/PlaybackStateBuffer.kt | 6 +- .../history/PlaybackHistoryMonitor.kt | 34 +- .../example/playeragent/logging/LogBuffer.kt | 2 +- .../example/playeragent/media/LyricManager.kt | 65 +- .../playeragent/media/LyricRecoveryEngine.kt | 9 +- .../playeragent/media/PlaybackStateReader.kt | 4 +- .../media/PlayerAgentExecutionHub.kt | 53 + .../playeragent/media/QrcAliasCacheManager.kt | 8 +- .../media/QrcCurrentTrackMatchPolicy.kt | 122 + .../playeragent/media/QrcDirectoryWatcher.kt | 101 +- .../media/QrcIncrementalPrebuildManager.kt | 90 +- .../playeragent/media/QrcLyricCacheManager.kt | 60 +- .../playeragent/media/QrcLyricManager.kt | 173 +- .../playeragent/media/QrcLyricModels.kt | 89 +- .../media/QrcPersistentIndexManager.kt | 112 +- .../service/PlayerAgentForegroundService.kt | 25 +- .../playeragent/ui/PlayerLogAdapter.kt | 38 + .../playeragent/ui/SonyPlayerUiState.kt | 56 + .../ble/BleV3SessionCoordinatorTest.kt | 101 + .../media/LyricIncrementalIdentityTest.kt | 24 + .../media/PlayerAgentExecutionHubTest.kt | 34 + .../media/QrcCurrentTrackMatchPolicyTest.kt | 109 + .../service/SonyPlayerUiStateTest.kt | 38 + docs/BLE_PROTOCOL.md | 23 + docs/IOS_ARCHITECTURE.md | 49 +- docs/LYRICS_ARCHITECTURE.md | 3 +- docs/RECONNECT_HEALTH_ARCHITECTURE.md | 2 +- image.png | Bin 0 -> 1717783 bytes 93 files changed, 7598 insertions(+), 1805 deletions(-) create mode 100644 AGENTS.md create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440.zip" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/01_quality_command_center.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/02_service_catalog.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/03_protocol_workbench.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/04_workflow_studio_build.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/05_workflow_impact_run.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/06_performance_lab.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/07_contract_hub.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/08_change_impact_analysis.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/09_quality_insights.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/10_ai_change_set_review.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/11_execution_fabric.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.html" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/12_environment_lab.png" create mode 100644 "FlowTest_V3_UI_HD_12\346\250\241\345\235\227_2560x1440/README.md" create mode 100644 IOSBleFeasibility/IOSBleFeasibility/FullLyricsCacheStore.swift create mode 100644 IOSBleFeasibility/IOSBleFeasibility/InfoPlist.xcstrings create mode 100644 IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleV3SessionCoordinator.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/media/PlayerAgentExecutionHub.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicy.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ui/PlayerLogAdapter.kt create mode 100644 PlayerAgentApp/src/main/java/com/example/playeragent/ui/SonyPlayerUiState.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/ble/BleV3SessionCoordinatorTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/PlayerAgentExecutionHubTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcCurrentTrackMatchPolicyTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/service/SonyPlayerUiStateTest.kt create mode 100644 image.png 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/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 0000000000000000000000000000000000000000..0a0f3e46c6a2f9a7cf2a35b356285718be273af4 GIT binary patch literal 2218004 zcmV)8K*qmNO9KQH000080O52BU6+hhL^%@x09-%-03HAU05CCMadlyAX>@sCV{dJ3 zVQyq!V`Xl1WpXZPbZuaxFji%=BO< z9L&HogS{BFRbQZ-?;zqUiX z!A{Z>6@`8acS+>>;a+dYa~u+^xnYoWTs-vrgWmaP!z4tvaS)@8AN~m4-ZUl1i#5H9?Exh=Pb^F`LM?9F>S`BkVX-6_PaCN1_H@ic_r7A05Ywh@E(0 z(1X6?VQ|>BA{;m+Vb-wk4T6rBkYU`jNsy3eZGcBT5ys`}0iBbcq-9_CxSWO{q!R7z zK<8W@g^B|!K(xnU*AE9_x=dM{t&)9CcQL|_H;#K^W!b(L5FB*|^fid}lqjnX8MM!- zmTtRl`<(8mn(JQ3mpzC)XAM5tq`a7-ki`XqneIh8P73^LVe1H0b!w& z9PcT>(tYNeotc}gu^k`b0inTkJ_@nGKmsQPo6PDYq1YYmh((hyX4oVkfq7^p6coCS zW`Q^}HOJ6RvxZ)@b;XRR+f7P7$c&jq5*tU(WRCBYoJJ%XsTvZq{2XeyMFN}H!47Z% zupFvR_!|u@{FJD*C8ezh?XDoJ7x=_zN1hZkASz``V*=h8dGUk_-2p|z3KG)cVK?qY zWJGZCl-d^E=mKk*d56e7)yEd`C(X9Y)pyw4#s0YHsj|=zlG9yfSS!(y9pWdrIPxse zbbR76{0GH{p)*P66Ks?DiiPt>jwv}3Z1NtCg6tVl3`EHG^!~ zNiX}*B!f>-vNK7@e$rtZ*P~{b8@p>@(<#F!n01*e2@92G5ZkQ~aqL>Iolow}G$)-s zoZ|{EHFJ{efR9xDt(ZUz!ysgUcG!Jpb34n%^ZMrY=FIVAOSWhfa8h^5l9k(6Z@_e&EixGUI6B|n2m_E9$L(B-k_rSDf;Y}> z(jc;HG*QeV9b01%%V;p^jUv+7i}0u#V3`+YVAjGlIPCG>Vv9-9EL1EwD05`La6X#Mc3u#@oBgZh`-Ia z<5>jl@5XWBxd)w;$@JJ}cPx_Z0VY>y2xkVq2R&ej5(&Q>jR?<2}jV5asxL7m| zOtbwGiy)d!osKp^-GEmAsuh<~aV2jV;327|ug!#)ay@FyX~%eM&6z>B09);OVVf-? z<%*?j%*2ji*=d8!mrE^N!8TZtgti!sqmf^2L1{J3=m@38^$pA-PQ}+COP8><#;nT? zqhTimGxs=YLfoTiLLU0G%+N$H!4rQv2D4mTg)61zHj#+kkc!vr>Wp$pUg(sv8i}-| zd4d4?8A%+A6nkNG2iAt`D^DwAup2Cr6uI)Utiqh7y*djkHHagF^28;O6kVmpvCP5> z>Q*(!fP) zSy0-X<<2EuhFw~=tE84`YnDP+vPGTb!PL?3u2lh0U$xR6F82J!K9J@sWOmNtN`OL?jDlh_z@$kLFQ8WsX*g)|<{;Fh zOr$Nt5Gm7(?<~h@8E(_&!FZUbCB;)BVNQ8F^)X>)>#l$$*&z01i^JmFT5m-mdY8r zX*QZ41!+u)e1v|NQjE!5h~*JFl`(s76#MhUCTqbNK5O6wqj7T9`9Kp1F$!9NC?zg& zJWweF)XuD;LVh(P4@?iPM|>xPH|A18`VjGYZ8?pe6PAnTg_*Ik5*}m`qn(CyBs(kO zn-TBY&n{y8Eb+@XaPC=)OROd~RU-+-%2=5b$k|C}mHks*kV=`KM%HowUQ1vNH&^1n z7+Z?1oWXxBZ$MM+f&(eFT!9|OKpyFbCEtZJ443mn26 z^!Te{4s$L148mtL?9yzOu0_uTO6OFDX5S`dP;d9J1e&(ouy~=}7Ue1mj3UoJ8LKQaG&F z$udKsYG|Uz0YX!ZWG}KQ5v9rV9lLT!T96fWhdX80&|R~M_-4%yNPH&ot?8-8E@$Hf z;hAK2nc*DIb(hk68vU|j8Sgo}v2b>Qrr}BvAB5!)<@1>;ur+5DwXLC}F$!A3iTseh zTEm#zzYE>Vn`OH|b@I*9bWPC<%^h0GDQ|{2+gnSKWYIW$V*Mh&p0$3FU*V#6o%-vT zrv1qFaU5T2r6qt?e;w>Hd&uq~l^@ZYLaqMQ^^5SVe-#wEP2z-qD>madc3S-(+`O^b z_!jXPL~o7(r^;S9VFFJs(ntNj9fQ3j2WTT44nd6hb6nYcZu9!hTa67CjDMSx(0J8; zmv3D|mt#Pmm3wXmyIvFqLvVnV``!TVvPcEDuRZhZs$*{8c=hG~B#iD*M_Re#2AF&V zyS{SAD>%V()ytag=~Est!_;l6qoU!S(Qv1TbtK6FW+8S8`p+_ zv(3P?$RLPMz}DCf%@^s`^~>8=Z*JXKb@Xk5hu_QJNVK|QBLZPA@vy(Th5KWm?hW1e zH6Yrq`X;?YSPm8Z(qF58dDfo4XTj)Ft9Iox&;Ov0GxUPOJZYCXrv<7ZnO3WR`ydAP zfQo%fKUu)*uMe@8uN#j>Wuf^^z*a%p^aZ=6*UCd4x#5ev;X`Sio*p$`>yUQ>GGuGRm` zYrp;xI(p;L@fRN=2z38-_g;qg@bTokH%>ly?c}rHPrvz9x7)3c0;vv4Z{@Z6M{oar z`X7&ZpN0w9X$PI~Uj1d&a`?}f-;&NZgeM*4?aS`BWgEC7&fNrfwLa>f{^rB!8*fg( zd29O0>(hsCPQQNs_?4HB9({WJ?$1xY`1tgthsUped35(4tp4=34^IF7pQ#%D>BZys z-k$#AHM+ddK0A8lcd)|g3m;7%{&xN1sA2DBSLm8{mMV>2Jp_%t|IzWI`&xLDDdZxoNFDVJ^ax$aXb|NZ{S=f5iI^NHeslzJn2HLk2qcBu{?S2!Te&RRKG zZh3J7HDUMQ5Ur!P`jUb;+SJ`xrGj3+`I8iT>f_KG7_gLYpFjHb$4BpcggVAN|5+$3 zj|aq|R^0$?OxjjoH0B9VLQ8s<0F5;?{pye08dCAmpT9y9I(qa;_Vjy5n(-v`1A_ge zArf=Vav4m$YdkqmW(ocG*Pl$kec|+z*G@jahi*mTE&$+M{8=c~LfTW@>%Y;u(p-sd znJPMd@9XL3f11Ak`1tX6-%FIMKE88+DE7_YNO_(FBGbtW|91LIaEW`zAK$MBin?R) zi$zsWW4AZ8(SF@!s+g2r!hz>Ia-V|@hZ&ABCtu#5z6#2KmnA!6cNYN{9^F0p?oA{r z=;+fAI6RcyCEgw6)6*+bEwt;Ay{E(dOD%d277}nINkR)CgV54Dxg3uGOSdqQ zs7!5(qJcymzRI9IRc#vrk`?$Ws7R62wq_tz6xxP_R8wuM3epUrZAw(xZB3%WEEa^w zildrULFAGkhQS1ZnVBFa7erJIE{U!&NkmOyqKLY}WD!+~2_tG6lLp!|-GG&p>@o`X zPyyWfCvW`*I08qm;|DKgCdPgqKmHKjP5ER*z4rygf55`& z%P*e(;(f69vc)!f{9?B$bMr=5LF`gUtFHhtQ_<<&cTc{1X?|eIE^eh(C;39_ zeX{#`Sv*Jqp?Jl%b^|Tv)%D>xh70p_QkC%Oh~PV3FhE&|`2Qv#53*=}Qnye%Q+l#N zl)jmVlQZY1;bn`k8GtCRIK3=BSvjer_3#DCsmHrB1>~V?Bk~f@#{P-|N>b%Oo+HS7 z0WHdd#d?5S80O@a!W<1}58s)7^M~nMuOB^nefsJL+%=0)JG~}a?q4_p23k7*TGPVQa3iQT>J5jxtd!-=4)k{uLZjy zt)48DS<7TLmlBzwStju!j=Y4pTV-*#$S91?(6F3IyPSd-OFIwn!C1D#5lZVvXu}^v z4o}M+^R1{jb$)dhMZ}r8Lb}Rrh^zrRdGOWr;oBLcH?5T0FH#setY=E~Fo&3HDdI9a z(kw1F$C|{M>C?MH;W$|+t|%=nE{7^n`rh)^1UG8kZKYIILPd>i8NVP}r{KN6% z-<{n1B^P?JoS@RBt)`fK*Q_NyL2fb6?!I&M&X)}k&yUn$Z^a=}gm|9b z78DHF@ z6aWAK2ms-93SC?`^HD&TVRSvc|0>B7az^8fJpSJI$=T!k!&cMx-rOG^ry-4o88nSDmWIKhmqbZA zy{FomqU(n)_0H#MvX1@fy}%6Yz`fzj`C+z-==C&+*wxKA3w`F&bW?C@7#&9Caa2X6 zx>`=;xxUHN!xi;?6osa(u83+u7kP?9{5A5!lzDlW#8c?IUdA_LH>I)PVCIlU;XEFN zIa_8^kHtAl59f3>RbgBq9*{YkYF7DrDfo00rQqmt0i7#;3}gpZK(NQnI7;S8u}s~n zSIM7?T}-JTuCuAyT6Pr1jHaVGeg?K4sJiL1`S70U+FlS0@7ca-1;N8=+4D5?58=sU z&Be$}$_tn%%FQ^Y7a8+5^*U7*xQJK|JxAG!dblKYjQdlF-?D7QF%Ju6Q=M|T4z3o4 zyPI%vmQrH)Lb$k#L3Man`lKVVX%y3qDQxfs+sOK&>88zWdEuOtjQu1p_QI*R!s+aU zseIVbKTCln2l8}csD`cwK!5^c=>}ImRd2>?Fc2`&O)+|&z`|i^SrUal0WNjb2x=)< zLWsm@tMWD(DkuVaLG#LxbjgnR^X_%&rb&IHp&+*@ldM2CDiB?*N-Ne?t8K_{DfQT?aMF4wHr3MQ}3cYMa^VM~WvJ2tt4F4U`6 z^`?#J^=kFNa4pU!@>e+|rKXrTRC8f%!1;KUhG2@=xP`Vtfz)+X2jWuIe8-+lI^GMZTWW-6w`tfQS7shb zJevBOEwz+D_0{l641ng`hW#Mb5NY{lg?fGK(&UNF*KCnSkcXgVq&t; zHePz}FCG;FHAP6!R(GgxiWst0sS<`5J8U zB~9b9jTw%snm6O+I%gH3<4?e`@V;}_o>hhj-KaB}Oj@`HtVFA=1iZEFIh8vIV+07J zT@k{>m2pl$u^5Y+Cg=b-t5j5R?7(zO>XiAd;Kl;RDgZXos76cL1Q6AnwPT^dDpvs# zu@wuPCUvu>St5^;9D4k}$tK|c3&k6u+!P{|4MwY60>>Y+##oMRI&RAq@;)V8C}pae z%y(N1Xf*@H_fFJ`lpasNf;09hbxnE08yL~u6Py$qQmM_3Y+V&sh%(Pc_k?EuTI zuykf)8(bMaertXM2R6;?bMF?TK`%P4SU0*bpvk3WR!#+AnrVQ1A?wU#g)V3{5f{N| z!(7|q+|{vZ1}Op8UJ&Zr2nCg(T!wMjc91QHze*GETUoY|k-F@{xN7#g(N>;Yk2M2P z$CVp$qr^L}Foe>=$A)d|{o`Y}k6HAVW5?S7+K<**9tPK=0?ADIW{+H!UxH6=!4QEA zq7Zt(5O+^h8~#DrVa+U1(VDZI=IgA^hbA?Yn%tT506@4E9w=jw-hloj7R{(9r z1Mq4FlTuwx4H88Au&K+U#=*6uMB9^Ag5OAf_`2nd(!q}h<(_yn@*aqkii`+mQ4bGS zNfz?j2Eb{5Z^Ehe5SdXvg=5a&I9PUMuL&hL{4ep7nv&jwQ$FTDyxclF)V)WgAKc z#%;hTtqB`K)7vQ7DC!g)m~1^1Y+)K$Wcv*i!7LAr4oS#3#_C_Y;?gLtRFpAYvUd8~ z4|oylp)wa8v$eZr2HOSS>TL_#d=Uj!Y-D2->N~Di5R|supa>Mt;YAXpSgq4l)TTgK zjTs$7Xx!bvGUBvs4YG8Nx`$l5f+Ssz5>WF{fF>d=8WZv)!ZJf2yaY@9@fy@}cNMOY zntM!RUQa6C(W_g^B~_tQD{Ca$9n}*Uuy0A?c%-;Y(sNiF?XJA2kil;7NFrn9yP}Fo zo%Y&1taMx)n>QzJI7u}yIv8srtw1mQv|bEFcxW!a+AU;S3}830jf5I36Kuy4BaM@| zn<_Uke+$!h^WnZ~6TFNLS zm6x^K>&p=7eZZOf)LwbX3Aq?2=M^Ij1co_yB~=ByzUdY{0vblWeIU&@SbfeCXyAnq zjDlcGhlxp%D4@3wX@t`h@im1?%)7SqkyAaElP*HtA4BY2P3S3cO!vR#44>@Bx-`o z=a6d@VlyONG`hN|w&N|RqEwpIZOdlPUaHB@l_1^9lWtJ5YqmK^<(U+c3g0i8nbHgt zqsNh%Pf)H@sxv zSw1f;m6ez9Ad6TndeV{dtVnD|cHzC+h4^{mS8d?dvzCCleQ2r!66DHQ6BFp=NoO1V zBQ7YotWG04Ie@4oaKe+l@GqsV;TgB!UpJg%sx29i(a9C?Fb49-C|S2oE-i@GLB9r? zofD~MiX@$<>AGpT4lHm9bKvn;OC7dq_$`3X>Di@vma&Ux0*!Mj$8su!&`7aMMmfRF z*iYhhRq~RRNWAO}+2CG7PfCMz*YX34D8zC;9NtqWSRcV>;@I#Ws8rPgd?phU-kELy zG{$Esi!ar`U~EbQ*x5~4GrE7jC*Kd_jDi2qZsWF)86WorH{9-6ACJP=XIE3L7**L9 zj>vZ?u60c;uTr^F?4kmrT zw ztv{>iYz>aaA!x%-REPBK8phWCW9Z)8EH414Q*D-ITZS!>`&i0pZiZU#t!rqy>fC&A zbRe#09UX`(TzJ=Mc0@7lCtgIe?9reo0SsnGpqFJMzlYRp!ka>a+0&x~Xq!C+fxcx~ zF5YA^abssN`|j(PC%s?s${2vDvc*qwXgR>9*$>vBDET#coGh0x|MtP3oP2ch;`N(- z$3GM3GuyY{@tf!5I0Lt{ch8gfB21Hb37Wrm-@xrPCxp__VPT=&_C_Hu)-e5Geo-kc76(J`a0cTn;xS>rt^MLa%r5Nyf`^N+glpX zm*8OgJ<=|PU=agd<2$sSr8h5*-#&eP`f}gV-!i)Fmk<$9{1&rg>HhLf8HBk!q|yG0 z9<2eo_jD82fRL{JrMN@b3>D(i-(YrJ(~Ivs7(E(vu6$PcAN28;VJyv4blGxRU^?2w z9n8+IGe8eS?%Vjy1K#XtNyBR0bhT`ETF<%@@D__rGqk@Vj69=k`AD|NNic z{pzRC2ie?4ig)b>v)_IC%kPsfKl|JF|MDvWk?wzf@=59N;qj~g`s}OEKmF>9-@N;e zpO44mo>BPW1&;Db_hVZT#P3X8damRva){<`l-JeD{aC0yGKI#Y)$IKjpTGa?XGE9_ zd6Og&@C|-GhPTIbMcojcB(#0~%fEZ~r+)n9=_ccNbq$V4|rYzg5Jf0O8gW&?>A-Q`1$Vjk zF>}8$-C{@T*n)g?>;!4!5~sDg>Kawsmz8&Mpnaj&fgPS@I$n0IxG0WlEa4J_doFz3 z0rzsXI~Kpi?YT0r;0sKbr|`3wmA(WX@fEvQFO`oB5zLj1Z+=)kzl3O$URSTDr9u_& zKR^LjZ{LNelDpt>s+Kc;$ws^$aDMOylhN5FXKSR`M+Yu`^!bO{@Qa3dy7GqSG&rY2 zOhSrQL;J9Lw$%Dr)g_YZQL8&P@DINwO8A`uM4rb_{m!SK+H9cL?m zU8fWP6r=%3jEu~I?KC+DzKdJAgQ8)?ozJ6z3Pf0KB3CL5J0REaM>hxLN_#Yb8K6Pq z4F_GaiP9p2h8{B2Xb*=iJ%pOl9uQlA@L&!gFo~{thailiVtMu2MD@G9HUngngmDf6 zREDYvA~T1o>JZh2R~@8hnnOn+x&g0>Nes;#S`IN)W$0+coR~w?AeN&HCmITSXlcmI zZbn4mz0HW;V8n4aBQP^(WFi<*O-FEITbvWsGB_)$ZE#*xQ{&92mc_XNdM+E#5-HsF z#VOD`$qTn4l8B)f`dAq&qtqL#m0ue9W%E6UB4ZT*~%j zX^YeBTu2t@2H=SiMfRjc9j)#gSl~3!Z=U=Cgeua%j?R~to6F48ogIt+Q^`AKB0zOy z%g@pWpedbw+&=Cp^RO1ieDXju77urgTqZkvjQ(W3ib4+%EO{EnG$IJY*d69j_L$@3 zY84{?C`(38*^d}6_U@99-KQOm-$i)vxF<6mE~vz|P9_@FWk|A~R|0z>ArxVlH$-XgY# znidVtVXD;5!NJlaTY&Pyq5;k`97;~oBwtErGcz+vvtO89i;LM}da{9a6N`&23diVi zy-$T>2_yg%s{i${u$Hs4x8Ct)TGu$clNm;1&i5KI9E|@umL)sQF#d65$%$b+`$x;D z-3tt?f3&!~eBzJ!kCw(KkK6xwWrN3$jQ`Q#&-w_H{U0r2q~9>e{@I0tlk$A{cm*z987Zv-4!*x$?6S)9k2lq*i%@AGq+!Cx@3@4K!~ zoC$VB6EIx~Eu~C`B~QH!>yDGq(<5{Kn1)mBw^Yg3mX(AjD~qQ~Vf9+-jE4hmmQe~T z-U-)>Y;Ccj7xd)&;CBCeAe>b1{McO0Oc;qN=X-tdNmA0&D}34x8#l0LeS;Xr`~>IB zm2!I$eWXP7U!7||A;e@C6P(pf`H&)*fc&b~Ly(6{%<7G+++xPu*Z&@?5y6K^HBbNoH3sk< zKE5%qC6HCJ4(y~YC~s@>A&Kk-X2czCe~vsjA6$D%5V^atGBMv30*i3l#JYCTQjfFz z-^(mtsTislXsQ_K80cweFcXE4dki}^yz#_T5^q=2e8mWOk`xhy50Uw}k8x=3a=wTo z@aB=R|FEi|q37!DP~i*_tJZKwkIijg>+$jT`ad;gO^Rq#faLKb5tfs2nkI$tQwt@k z;V(VU74PjM^ES%U^v4675d>i^PPCPkg%hJ<-g7@X1ax=QlhaaMuXB_vG+QI;u9H@L zkMTnzGtME`Jfb$)Th9$MOR~%13oi+2);$H;zZ3Q^_57kA=X6raXiJVxE+{YOwEDuv z;EfL{=drc(i>?ji*w3%3@^Sz}O|ZPFMV-4u89V#LERSct^#lpjBz<|`=wbjrsLRQL zPiTWzxBUd>D8etkL_5pso|gi;l6rGXz&)=Bz(q)qG?Z6&?ljeru!6|TNC&9#2GQezIZ1rPua{spR zsaAx<1+%9};xR}PTjZou0^`{zVEe8HcmaR`v{*A#)tH4G4>r%yTM5IN#wJ#MYe%nQ zIN#U|g@(!%r6tAmm&e4#nAlm3>R(fm$GU9Bp#F@ZhvZKKB+Tv>Jy%+{m;_~ zqg0Ergs{*7`bhGq=y>-tDP?*6;{4>5&RMfzgL6UGm8lYZdN^kR+vVr(eEH0e+D2Nt zq3-QH_Yo~nAGbAqUS5&O3GK*U-vyjrTAYiys+;v77U^ao1F!lyr@xWJ!|qSPI;aq^ z19CsK>^*cj@^BK$5Yv%?fB4W=Da6V@JtrTGd{S?$>pxD2p(M4nX#2&^B)L-a*!495 zv4}7fwM!pIspcX7(#a77^aR${1#yn|5G^AFamG(ccwF>{G2!jE%h3wN}avmuRr*WSa zAl&Kl%~k(cTjV50r72=M*nCQ<4OB6gcQW*gu1gRYs;y$TvU5)?$9+b%JX{CcM-=E8 ze^HnBDQ+n8D^u1sP?ONHe@?0g*^i4Sl9PoMI&@u>r-$npDUm|T)qq+ICGAn$jfj*EmhC)w$n)XAjR;4CPH+Egvy2iMTUFQi(Nu{2O0K zgs%H$#IzK2UbaZP0d*;4j~^NO$hO88k(_uqbktBQ3StP6Q+Yt&${?|KnD7JyB2%YT zZ3fkd7y+^{>~7)^hFQ7G@*{j;qpNhrJt85^z>3NNfjSvB9 z#qvhk=}&Fh7s=7;4OQ-oZ9DL3jk|~X>aw3*jUyx+24hy& zpo3=oDispQ7(M)m{9uVISJ%GgI7?rrU!GPR`YO{1j2KncDk~!n{hYJ6@QI@8&M$2V-x}ZSZ*I#l znf1(1*^&JcZ}=D{`{>LnFzr@#v3HxEh*Vy0r3R}#cI>S?WlT~+MMYW8%vb=Pbuj>T z($G(iZ4s0rAB-0cxOXpS{fRzpHu}>Gg8QqyijpZ&yF7|=th1$zQRlazo@c!83*k> z(fPZt;7rk#Ar0|%+zmKNl8*dfM;9FJ!(%Z$=b97YDJ*b-@zrz=zM*5l-D6lNvKJdDMvoh0wq;m7=L9CwKz_m2BJ0ciow{8M@3iCoj5*$k=WKj>3q0y1Yxc zl<98f~_RQVv#8n)Vqrc@T`It#iiDZ%{CiR$nv?_-2YSIHhvqi{R z5{sZWiq*NZaNXZeixh^PT&prfohL3;(5#-gqwfVnE9;4&Sr48sTc7E!vuNiCgepjTFm_%=mvE*56WJN3vGT zf0L1o#1pfkG$#l|`tY1V|9;N31zQ-i^^Z@{K`HOXLEMu>)>e|l@%uuZIOmxodT47| zLAa5oLfPfoS&{efI)@V81HMH3*>FT=NMZ|8a>4xF4@48Sl+%*a+fLd&xnBFqaY?vw zSmV@TU&)x9zp(?Ix5<&^q)Y#f6D2N+#7%l`+5l^CTP6jcTNAk z)0qRWPDC>?6(>!-d~#{nq7MondOI$4PSv8Wqp`n%TrlR4wWYEdOzM^TeO)J9;JznH znaDVsZr|SWb34y&nD=j~C)CAMVom(wLyPe`_HFTwo8NH+K4j5h4s*HRBdtk65?Jex zK)O?UVnUl~9~tA>P2VN<3)Q&_ay>T~w9I(20K*heW#j9WV5s01)IPpyPTcw>?ACKL z0)Bo{?-Yj{Ni(L^m{c2h6ynTRphPyPIHye!ybwky(B2ZrqL)lLlp=Hr3vaJZ^x@%| z*eg>2^iEDssz#A26Uko4;>B+%Qg%#)^X-}2i^n@TIaRQvO=RgI@wL#%>_SE*{v$Z{ z>wK&W*Z}?mqdHuFAE}_cP?_xehM2S*B^K%IsLeomtPu6In36Yq2J=Bp{b!Vli>)>`jzH^S zfjOV?h?3nNbb#HzRg=)vB1Eb%yVFu2StwIWTen|`x@{D07RLN`1c)}qjFw3?XkF$dvw&?`1WF-eEjzue7#Vh zi20G#QNYPc)cJV1heZydhAl#3`5A?L;v75 zIrrm#yOnn5<+e3``GQ%en%8{8E~*$dCZdaFsQY?-y4lBCbz56g7mrwO2Dbp7XO|1Z zrUL&lCZksoY0X5$v3+8RQ3=s4c3p(CDP0V#cC)T=-`oJq70r~?nF-1BSt4}84Da8y zHs{2EYMJ>0%vN`s?n_IH6~7D%xxktV^GX9X2s2y71x*`&gu;q{?fGna#wMpL*~fmF zP({@TcIRIO(miW;F09e_V6Klta@RP{VUL3ZTJgUEHC|grx(nn@+Q*JiE27NTFB9VO zIsenajW@!sSJQ27RX|ed)XJYTJp-j4z^Oq%ZXvtZ9A{`41zT1@NiBF+7s4WPy~;;( z-vpPTWUe0Coqi+g96zuzH?PaS&uQ7e^J_4OeDYvOl#G0vbH5gKINMgYSMWWwDknO7 zX~3EOY=1Gp^IOY3a!#;g#l~|ecwpM+UTY1=N`)AVk1Z)J4GmF(^yjR?mD;WhR2i9A z7(Wo=g=8JaId%mR?seBF;-mIv$eGQnN06~n&8;qkYNWj58*xc#ilkfK%%tZB$T{-F z-q&gx2F|;~Eh|_;F{;%Ek_`Owlg};4eb^M`Bqk?2c5>X?qgsVMgbt8$esd{SEsInj zJ6DDMY_;jKp>VGTe(nYx**FQ>RfT7+-KLHYia!2_OYs|#(e(0jYmv9uoGPX!Dj_53 z-k3hEH4v~pDHY2Z4y6!yUr|+dDrYtq`DG?0t8!*uJX$?V?vtF_Cxww&g&QZcZmTDRrAGy{M*i^>kz2S2#`nm&@9#aW*epUTx#spCUat z0>9qZl(#wvX9b6ar==}yP>ORreNKRonQs5}G@wvfjaJCJ8@&G4b6Wn}7{As+$6FpsenucV-<68aR~6 zvl4%$mK0HKdGg%G)2go%E)zbVxbwi(^)hSC>+-LAovYekFHsuUXGeJQECE zS)3;QMZT2jsZxf%M%Vv{6Xo64xmaXMY4(E07KM2fuz6hcl_MBG+BAnYrogUIqWb5< zT9kfwaU_=~nJrueza}O@8k)UUSLx`h-3wu_kNs0=AHBoj2t=z9%kl~OQKuW#$H{@N z4Gy-*0%iv2=oCEZ9R|LdZ>Hc1{5llI#ARm9q@7H@xlT;JBkH2b=80R(#LSa;$LSg_ z3y_!`AN%M6o1f4g>Fzk*PEM_@jgfK+Xm0Fh^}IYK7@{K=p82(;0hluQTrUSh0)Uq3OG$hPJwZ?XB1>?6xxoi=kZQt$;|pW_Bk} zW)m+-jAnM+qG7|!G$ZZIj*G{J-5g0#Z0n3)O-vTQFWPG%JLm%Nn1YvhQ!nA5+HO-= zaQ5T1GA-7=&TCv|raoxpV0x2^qTA+TdKs;0%m(%?PC*MMGnWJb4>4gupkz``OjV0? z1vh_~?Yn1(>vZ{AP^bR48CFE-j3y24K8c}jDH*kJJ>`Ys*7V%ixls*2Jy!>e(xNq{ z`E%%9a#5r`4Kr5x4c$8u8QoW}4G6UUD;qR0l+~T8kH(UIzDr8<3OO~j($_EdROa~^ zkAMvv-eZkK^4bgmu3Vx)wnh;OutAu?O)uDFh2z>wAZ$~?f~%Pr1?I|Y+xpcXV`q%G zx|GJuyze4KDSM(;syniy5#T$w*wo{*=U3kI-+m3gxj2ZRX=_rKl$2q7r6TJ)!1q%` zeSf;CH}AJZVlzO7hGE1zEV{;O5x$@jPRX+k4R6{`o0->D-l=oG?_aYl*HG7tAfEzv z$LJ6}H)`_SI>okE;v-GbP}i4@A3mQt{lr=2#1KwZWi$57P4MoU3D{mRDJB7AZ+0|Q z*%EHi8!PXwolAt#h1LNFEDD{WYClFJTEQEO}50sUiTGx6M*U-n;jk; zzQF%wygOU<{d-nsL70tGa#9R25s9E#=U76_S{?aA{0 zFaXIYca&unfk_%VKW^SwMm%FvGkyFE^07gWN`^aRf7Oo!><7aakiXp(o* z<=szzr1(I7cP%@DMGK7LoJEN}UNh9P7&HbW5Vz z{iC*VY%MA6V~4w@d@T!pk!O{ZvAzm{9tg`t^V%kpi1_x8C>zZWq(F}I`IjzLkJe(%do9tba$gS zTUuV9XVdL%^qgk(40%%#B>BhhV!#5g{rY@oX}*hR+3q8@H*vhiEdSL8ar~$3sEA-V z!tj7q;UF+(4>e`$hdK*fG}t?TBXqLZ2yVMVP->MUT1j6v?Pj8!kMYb_V;D^q+g$et zB~dhsXL@U_SwNLEDUiFW6%Ka${6$9n25Ga!87F~jtt(|6jgX&7&wCk8sy7rsd+UZ1 z6pGd0;>K*8`hq&b2z-r0wbi+isXVi{V@~PBc0F!?m!&oV)rx#=S<%{XeRF?zEj6dB ztEH#rrK`Q$YS1mjbvIW9Myyn;C)F)4O3Z^z-lNuJLLsuU1Fe@PYjYI{Yfk5UorWvy zDjp;7aZsbKFCo;nYkJTu%Fr(er_n}q4#QfS-7l4I6x8Eu7|N^JJBsjC*C2|ICuR(( z;kBR1=IBnl$aw{@u$Yt7k`t~FbMhdc_0-&yg5__J&t5%%raCCEVrYWL6q1|&VF5n4 zY^V-DIuWU+C)Ctb&C=obluX$vmfH$1DJ#ntOI-Ym%3S`_T#X|Z<3xrqsPCXFaoe1C zJGi02mSWpV#YrBMJ#dtXDHKHBV1pqa?X%{d$Imc)lV`c1@{RD9f_Y>{3&_l5-Ns)0 zs?jFaD>NC921!b;+G=x8vuOsvLZk^2d=!2xjV8FKM5TFN7>dlj+$ug_g88E zLP3PzISC~zYZ@1F1bk72Ss7XdU-gVFVs|mFaHVwnU?ddQDJcbq7JHgH31GM5MjDU_ zm(mCqIxYQjbpL*kF66$y(GWWmKChkZ>FIWHRH5WpxHmQQ&C}xHROj^9X}hKD z6L#ROa_9=~>6ypHT4o*kthP$EE83sd?A09_jZY_u?dG&pLZeF9>k`#thB1C~Gs#fF zP+(!RP<4N%ff!2Xa6^6vvDp96Oy;tE);+(M2ML-h1&qj^AnHQpWKo-6B{?$e#p?5m zFbh=N+b4N=mY@;@XEi;9yF*iN1U7_yvYHx;t6Y);xujJ^WV3TqLRD?c4hQ4s>F73y z1GhN|e!A)OwxJx#cZ`eySEd`p+Eh^U`Q z=Cc&cTr!BUICIJe!XO)gY!U_E;7Y6DDFVHoh7{@W4n326#`ZlF23x#|Xt{;EH0FcR zU8wS5!q}cjPwgaefdDG@vaNIet1!%@Lqfz@dk{EI-O>mHA!*2 z1G6%w%A~jM6qp3rVqzw%yDN0Fy#nqPcCfH<*&e_itYea8SDy*`!qx}@3!Fy(J{v&! zSqjYUA=mD&_vM((x7KGbJU)pkj#lmLq?9bR_5(md)Fa*f<Wr*_o3%_~xR8vIR#Qjk@%P~G zbl;layIL3-@NMCeC*?QD$XY@6V&cN&u$H^o6f80{vM)@x-?TQF`kAQ6_?d8EcR_Ez z`=PD37*IT8N|(5q=uU{vdxi@ceUJrxP~7Nv^6lxa=r$D;`unTr6<{rRpWh|O_OX!n z&i0mjlBI+%OGSp_r|fM*byaQioRZvp7fp~(_XDQUoij zq_A|=X@iJ(op^nNnF%SeFbA)aZE3_gRc9ad9;Ir51Tk$C+2~{t8bEfx>t-J=5QC$& z)0jczrSMM0)Okolh@bM;(l757hx(A4=J8Q!$tmI&L8fL{RFIbMou&qFJ~TKv!DR6u zJ&o>NYcu6}0Cc{Dt#A2<$$=s9Ir6jptS^#**Mh3zCjEidn8W9r)Qfw`jR~_vSZW^N z&8oJ9AiPJ*qzinacfT*%L+q!TGgA_ddVB8O_C#tn$KCbQ_L3DLWgW~V9;fW>0p7BH zFCNjtko??rIfd$l<(*V>a{GU3JE!)(Be|(skiosz@hozg3gJmgq8@)NR!x%;?|of` zzTW7G<{YA{^cjLy{}Kmtavpx@AB5>tnVzM$zW@#0QGp5f5UJk^2q^dB{>}nS=F)(C zz9{9D@k#sUR-3nf+hVHXnwsY3=G9oX>D}$GbGkB4R4~BCMyg_)EqAV4-|kUO!b-;osUn+ivgTp z?xD+TK3loigh-{Z5GDg$kN0?pfppe#YN4^*6BezFuck%7^;xsqNkpQhyPmc4rj8_FZhpX!f~S;b9>DGCzR?+`MN@MNU}2fB*WJLaTE8MMPM)@o5LrH_J>Ki%BkNhkpb(&vrEo=%UWE@l+i)sTo##v7_A;3+q z^U8uxGN8aW(yZ*zK?0NQyF}Y80vd z1wr=vzNNX(ZX6z%047F#;iikV5pc>!vyjG=oh_O+Di=PR%X&{5-^$}M;;^LQys}}r zVGkFKg@V*|O3K$bpiv23{`MAE%t|i?oOUPqU@P0*r6~{d8{=*HARMn}HGS<>mI1-p z@mqx>sKdzsI$P4I5=B3tlAluBk*7L71-O`9(NtYf5wa_suP~#-#49-4WJd7K}`N)p~aa zi9<^w-{P_mr}#N>DIhA{qlFEEHo!jCB37)PiF0h)6q0P1j2rMF> zt!PCDX15L}y+i{oi;9?((%IASTy!5KuP8qc7adFNyA$D5a3woW3I}L?6thL2%65e2 zPESuR0Qu%q^w(~0gbl*`oe)z-Okb4uST`N8MM#$iw({q<#IsXZlEI9_Ql9&=;_{=X zPf!Qui}QneBGLfnLj}5`qXN4UZT{!g*Ae~)Qo>NlB;q`NCeGI*+L=&A9qhWB8 z^&0i7)W@W3bmB{5`ntnMNRQW1KY5wA<6sEB$kYDhG96WEIhJ?yI&<$6@m_Ms#I04u zmJ=q#MN4<*k0SA&g!K2bIaO7EHDxySay>Q_PTwn)cj2D2yFyquKWTZaVxWX#u^Auy zj51WuQ89=$^4cd_*xV%5OOfG3#hs?blKN1#(X#yfrBGg4y5zpM6FzTJGrJ1s6t)eI z`spEcY}m(T`~K$O%a~kL0+J$&nSp~Y)~z5v+X`scx8;nKjbc^SH1s^!NzZvD<7_Dl zVOxCiD&)QR*rc92a?vttmy?TUsbOQfZ3>)QSZHivRfCK~JwLc+t~e2V@|#b~XPvXf zK!ba-ev)U^+mU#WR^(yy4I6kC1)&zC`%_0O{(4*o_SW3g7l~b-hYNPrtvWQ6i8J*g z??1$NT0CAP0E3R2s(28-XWxn(eJiHJ63WV>8(4aJ7&y|~M_*t%q>dTcyGzh+JKmml z8@1jeerBVS2~|~e8txAPb+`MZB_wP~$+4-^S-sSMYwdEAiAp~%$giCB`1CYkTX}Jl zBRF)*=LDH*bXWh|$iDxwNuIK7zA^wlL`B&)IcBHVXZc{Dro$pTMdVccSm5+#I=M}c3%X$TC+7v7ixnq8F#!zwJa@IbFfb!4i-Zh-Z z_9zsBj2`@757*4?1%gWYoQi)Qs`Wh7@9eB5Rc}vmR2WNmzYOdL1hIl%$c(8tXspI9J(m ze$Mmo7*rnNO0?f1PWVHYH>;DXOb;7Y)li8e-A-?Q5WmdxWxrF3U!ngPG`{?jygl*z zxi1-IW}2N%A+z92WnfVUL7Otfm2swZovLr;)WbZ?#Fpi3;+KBz4Q6F@tDD%Dntikx z@5D4Ue_?@<@eindVMLt(aPpKXWg+4;g0E8P!ne0VS+%^P1kmJPUME188&S9Uu#zT} zQO84n;f;~YaF!YB>nau%`z+Ea*nieG?<|t@Ta!MGJ}8l)%P0O6&B)Bq7TGku+$wBt zNlcOmfo!qce*UKDr0aIrqWxN85vO`P{9t}O*^mPJ92!6inzmzoAnu^-mro6;hXW<^ zAC!CnwoTc4(Q2OB+GPGKVq%S*Vpu+q_+J|_K4ptVB&O?S6=ip_Z;#i6rE;1~M13<1 z4D41vKupI{%QKwplviEo8x9OSI{+X{0-O-jt+K|}F5Dgk_cdB^iImIbUi}r9(M`qz z1kzoFw3IIu(Z6(}PJ@9vbN5qN2f~@#oNI<@oLca>dZz|W`XB6H?F5YzuC-M_nr1_d(_E;>FIwMZA_i*flvQnhdSk#_%wQLc-yyjCZr2V z0nJ5RD9@VQ`i6UASW;)7GXhx5D6)+#m<`kUT%r;K(rbf)bk$vi=%PjM&+V}ku6v*TERWzFf4R*`$?hYdrxr{k+Q%-jzyE-s$(sb8HzG9#1uYt|`CT5mSN_QgfA0#<&5 z7?(xfj|$TL$SM{ZcPGaI03NxBF2_2k@h&Q;_rz1*Oo9sG|3vm(zJV}w4>n}{n$|#1 zPG&eU)o7iRNJm{=mzJn;MsH4=qG-AW5CTYE^2?UO5s$u6dq78hGH zmgYywv){tttAkHD}4Vab9@{>f0_GWfn8|T_xsN~r^n8^%- znX&Kw^2|yWoX^8MwRuX$9jKvMng8w+j%>?9t{>{x*6QlgQb>)KR6{lM(l`OF9f z;O6d{*Zbg|D?AYyoR$r_Eko5)>3v*wb8#H#>xtkF{szwgsbLr!V;q_6ScP9k!Rz#AICl*(6VGqX0!fOQTw-eB^HHTQKTl|M2Qb z{ex3|>_aU)W0JwlTvD-sr>Pz_JzYY{t*dAkMG7Rg>+lF=SPHZ)K`2*YlOtev2?GOvEtHdOFfafvCek!s=0 zvQ0#RPmuZnL6d3kBa$lFq2=>OyL>d& z-jgS-y*1Irp~pv9;8Zf6F>-9|mZm#&{zw!Eu6%_;wAtBlhxL*5zaV-hgkwOp&ykCR znqvmNq<(dsWps2L3cGiszTM4CpXG1h=C?Z?eZJ&5)G@5v=zlRZ&t<499!S%A7iGE@ zAgy>`?K-kJ`qLeu|H_SRCuk(#Nv5r5^U;MpLnKZA)aTDVcSjfC;K=lRsFH>T@aTJT z1#1TL4eRvORLabGc4u5j?)~-tZu@d&rXTvIhvnXoT9|Nu_HwN;ysZ>5_=N(DnuNo( zFK*iW+`n^iR(UrzwF-fIDm&0$r>?@L?`P}ywhx3Qd>28nz0K%r=BkR6I`H+vB&En* zw2I@!yPQdN4VzMy*6Y0%dZT5iQK{}n`#Hq@LF>-G9OVRj;(?l;vn(uI}oX&7=qo z&xWlP?xM4Ea)@3KUC(N@jdCd{rgLBCKG&2Lw88yfE!jI^jz0`d7>Z38p`M%Zz*IBC zn%!(We3aq}{;}D^%`@RZ3Ory9)-H6Uq7Ut`hCcbD1T-sU7ldB=To!R(s zXT5kd@>_*zPJQ=nv=Hd}{NxVnlU}}=pS|ep?}AaWi(JzyS7tdQ#@+J8PT;)pT!6%y=O^g=^ zzL#6k&npU9*{v9wE~G5$=v-J2uF!0$TBT*e#X&H$0`kdz!4~!R@+pzXO{OM=(C}G| zq}6v40oRD1P&A6u_H@z`^<^A^4py>XsuSe^xc(XS%PjXx=PiLij*oB$5FRKwNcq#M zYBMy;{IsY?N6bZuwN+!2z;KGdRA=XUY~w-t8H@M0v&}Y;S3D16u(kIIj;?}|Rxryb zPdiDB+PIPKsiup~cb8-}OSa4=58ru6ZC5b&ZD3+->ivd$o9#?ZKza?dR>b4d#nV&L zxhePYX_!4YUNIpq?(TdmA>Er0b$Ow6LCqS@o8i8BJE>)KUkQO|Ryj5W--+aYb{jay z>IaeM`sV`w0xrwhqCjb-!8EhyC2rQq~GYULC1yA4$1H6-7OxM(fChw45#dW z56bX%Nj#PMluaR6qBUY?(@}B}5S;|ndk)3H9$_2G_>&O%M)ZD;GV>buhw{&Q@B0vY zzq$6QNHSfTkfLItZSR%ShLRd>6_x%oN~l(Alf}XW9h~%0B<;t%J4%{H0bTh;W3r0! z+o*6Rg&B4s$-PjhX#VHAj-6g5!zf7GpRo>${+!v9Eyeh$QZGw#;WsvP zvn=@y@KY@{iN(bsNkhZ-3G70)wrjt&aJ9?pb6lfJB%5O;xUgj}#ojMJExw$QOUq7M^S$x-V94$~c1ylv6~kt(QY9zDG~PEwh0BPp ztzqBapOu&s9FJbx3O#UlR+wI~w)c6j0Cuh6LAoA!2(c&-@VeUV0&FRN|0pj%e}G1i zyGxYK*-`+*3~wue^7X)qLu13o(dbGZmt)bJod$9y%Kyyc^3ye#qg7z~(k$El_Zts0 zSSz%BRIDjLT48c}vyDSK6KSs0fL)nRb>f}-qpvr07TOZx2&O4z_Jgzd-*}vlj+fE& z{3Y@hI6mbI*MXvbD8~qx?6FJ1gGDU=RNmd+x%)?x<7kVEXB;n5LJvF|o0ymZszVPl z8uxTx=37FIc%|OOwF#5c30@u+4ir9EWR?e*6XnE}D)*kY?7#LBIPHTC=i6w%fAu>z zq4nn46f+aNe2841c!nd;H!Lh&u76@O&=vGWKxwJmaeO=t_>NYO zTGV1Dv(1l=?L}U`rD7&j=q(4GAj3tkEBO9&sJDu;z}i0p0PzK#6EXDCH=-_gsxJ~* zQxu+V<7WEERRk6dI0Ep{`voJ|kM17WhV+APVi9Fgikk@#Bn8yah-VY^l~$85J8T!( zlQK-<0-ok!6LG0IrKP3VS(r{3vpHKTUL)rtk&WTuN_(R6obpaU4dRxb?11a3^xs{4 zc30JMey1I>vL;9dW-exiKwgbRdFWYOUB9*5W_(;iORG(GP-7lYG(9vPb&ERuT`g~5 zW)vArdBo-Ft9B1XTEdWYmZ+OE@Lfwfo(sX~%@_MOf3NKe$A+{y-Io;RfQ_gkl(k4hx)y7BbD_MUa+oJeSZ5NY)?W*K8@+(-LuTG}XaUCscAUB0 z(P*v*+LvEJ)bI~1-Ct7HIf=Cy?rk>&M`V~-Gmghc7zx_K$wZ8f*L(%DLfmiX*)&@k zJy=uk>ikuEehu0{${nvrGg~E)Z1LgY&oo48222dfMip< z&h=Kyg(ZLVs8Iqep$g^Z1>0=zM0d+2hTK% zpwaqB&-(l57=Kd{IBs;@Ay|R@YF)K4?Rr38EzL<6uZkKCp4#Kv-f&$i7Gi{vai7bJxwwG*cjH$XcVzA1lWeo=^=-B? z(Wy0?sb{~#Rny?`s)T7(<*392{4ff)^YrvsW|r!;O-|P?3{n!3>MpG6E)4H_8L!c@ z;$p}X{&$>H*{so26Kxj{U;})#`~nhw`CYSCm^5T36<%?DiW`x(@Zc|I^uv4C!l^l; zs9nhHTkrF0oQ=P>EZ#)&6e1DR&I3jtIx|#eyz0-v=Q%ZeFKE56 zoh?uMFcG$rMOPtQiRUyzpoON@wEc!L*7wP!oCIq>^P=I@xiFKgr{GS(EB zKWw{;H4<06L*Xed=so=B|MqYXFG|To8>#E|v!_!AYHHx~s0$hwE`qw~X#MVLSR_u# zhSUJNx7)NWaSA%V4eAu@cG~eE58%s_k+5K%}55Y4?H$xGgLysw`PJ zZ0M?q-lM*t5lLr=)#?xZRs0-(0LHA@jGmoC+3Xl6P!c_tdK&+$_ts$^0fF8=?&qa&vzu^d zoo`PhPK^4%A{UIw%g4jZ!^_PZ^9=ds6%%ODV}MV&u{_qy_x^WaU^k|?i9978eSz%2 z%V@{8yM|gG>u!cTpQ}X_;gn02V`B4(rkbn% zZIqdzFq}h{v?_6q|Jb?>{Ra-O>3F0|K_TxwmN5ItPdwYk*G(~_rqm6bJnX!;9%v7~ z4JaCrZM#KyuSq@6wtmnzl9=Mz^7ASYiY3*;S`XTg$1qDx2_V^wpV0gOM<*6aeiz|% zJyJNNtcVApIA6B#Nmti*WJXtaU-W-pY=2f685Lz+m2@NR^8B&k=V2>XF4J);Qavs zzcR6f9k=2)gj{}EJ-gKe+>;q!7tWvk!u(JU=58>5EiCGOOxFHsEMVL4z;FJ~sP|aW zg|tvDJ0CfZkG~6fsH=3O54-NpEdF%fM#++&jkubc@3t{p-J2I5LOjq|5(6+gqdX_a zDl_RVx^M0%4qNx5V+D}sYz%(_3511B)I-c3n178@Qyz4?gNL{AqD&c(XKwDU#seE0 zXNVK{6g;Z#RghcMIm$psLz3SyAlT({#?8U0@SgRP&nKr)d8Gjx(m(PBNlYz`0wbb& z7nOnbZQ4AJ3x~b;J!0-!(sd5!x3%=7+s$fT`$6{F9z<%>hWA@2k@$V>CLT`CYT5%n zDhs{azhV1>FiU)AOlVao6Ha3zysCH=&Sby>+~ipP4r2bF8qjq+iNSDgz!%o-?Fd}F z&;d|2Rq9<;%|w0X2cEg3@LDaXX6;6Pw;R~%ut$T3+GScw!j8B90Y%%c?guJVQqrn-EKo49_%@%Bi( zYqiY}4IA!Jl~MU-`{$W0r_~?hy2JUPvZo=>StCpL^m(mqpsl__gYv(U8aL;xTMc{N zl;vd(Gr{(jq)7sxn$y)iA$V)V#>Q@2-fQZYv^TQzvI3{54QnW`-4dXzZF7HsL;w{B zYfiZK%R???w7IkdYUG%%&bC$n!VS;*p*HhCSDs0efE$j#iy?l-7rANL=@^>uV;PY8 z?q#ScTYz%9g?RPZ%Yy%1REoP~`=q<(T{I&X^oQ#Fap;b&iMN0@ea*WvMaup`7Lt&p z;VJzKvO``{5nF6z}HsaP*vAy1KDYV|Cc;=Iv|ReD_A!MW4lL1i{j!UKmx z6xZKbz@x@uP9<@}rdi%51>SjIbvXomCUfy|(y$}jJ1O0}yAlmBx_WnwM^x=I>Nb#r0>BJ$UI&;O( z=4r*nRe=ppn`U@W^KgtCcBEB8C1Rw8DSZ~t~Z%!ur381@O|K-lBf(8EPhamoKB zR}cZx(%vjND$WbJqriYW9~)f9zEAh!uZ53N%ueWB2)TE_% zyDk#B(nrQRf~elP_a9HHF+@VRPxr2@tGXM>-=vf74mH`i{$5^K3aBUrd?+hT_}spae4NDG`|=56 z;6_mi6O-F$eH)9K)=C6Tq4@L7tW+@5R(u*;hlZEIC(6a)fb4Ks&Z z2=qn>OFsZQv75a9yL}ZMMxx=Q`<~}UWxDL{vTL=hh7DIe-bNO{E#AUJLaf0?mhqlM zi+)xe4GrHlO^k8YIN`{Yn(Yx+>+5qjd#7(5Sl-o`haI_=do~}qc_xz^f2$h+rPv_h z%|}b5@O}lob43cig{~nvpns_SKla`- zEUK^V14R)~5K$>^iM;`- z{*&+t6M>~;&ea{a#dy9% z@Zf*{Z{WnWPGieuvHEzsYt{MslP<$UXOb|MjN4 z8%$`L(2P}AWb^9TqF%iChJ%wEd3s$x@86KE)o`8QH_YNy8fB!fqN2j>IG#3f&??~h zg-RUVkPv}owxF%0)ppjYO|N8}A?Wfwy$nJVwR*T*D-P?c4Y|C0zpV=U+w^IPa0Vp(mND@8$CE=?cv>p z>a~u^y&Ephn&f7ek5=W`08!hnwXTv086We>~2C*_H*{JXbY;UhV-;dY|E>D_w5O<4)4T$ui zaaX)$`TdWgA)Ya=jyTJ;gFC^JRVmiIxV?7?L-ug>BoCvq%pQ|%Dm&{(1}a3r-#(H5 ztTlp0LNTvwWoOsJavv?(MQUXP6`6K#s|M7!Irxd)IyNCrwLg^eQ=v@C3S<9QOHmbo ziIeSU+foYUUBi`cLxQjP1(4M5 zvC%fBVU4`DBe`g3u3wiLR94lcC;4a7uRn54)_YxF5Ypzh3s1iJdKMpb%q+O?5QiYj zfa4q(JipH?6$pLwHT{3ri-G-m#4fKmGyiGM&={Vck$}L}Us?oa$TM?thJEM6ehdr$ zjt#{p`Bwiw>b9_@DB*p3ltJ{*sxXH+$as$bQL1HmK>YvJ3N)^|R=Pd^cwk5PlYd=K z*_Qj?m;cfJ|H*na{~s%#^ZyU$|N3w~O~s_Aw;-3&Z}2i1SXj8YM;od+sRRm_axiM% zY{A63-^)RvGu7#lb@hpue?OFs>0fnsBs?X?yJ#H{2;%BEl4RY}%9^S#Sta@>XCt3I zx~Gqq0&dw&r{DfPd?SsAq0U;J)GsWdD%%Hi1!hyCqTC;xUMjaqZR@8F*Y=c@*{P)~ zjTDsJnd?DkhdSYFQVy?;_w}WqBB#ws-M>4_fX}#vMaxvy{JEIb_A(}MSR^PS7WFI_*JfUHK06~AkxHPo~yMQpc@*68zGA|sxsr>Bo= zOvpjx%yP_LzkUtSR!~sD#>F+G_c=cug~0>@P>%&?3Ov{p6^9_(Ozy`JM9K3GF5B^+ zox;t6SW9hvN{it??O&p)i}@lUk<*tywvs&6@y&e5O8oDHEEUp6zX&c>+?m0_f<<3! z?7a5ZDVfp)T&$L|*FJY<0|1C6zcW=^+tsFUZ8(ezR8neqK`dN&h!TWqw>GQj8SClQ z`%MM5n7rcPIP$u}h&t!xm$4)jsb2=y4ujA!_{KTkBj#vf-9& z!6A~^!Cmr}=lXg{ve84mkk#9wHjPG4JT}pWp3>VdHCJ zAOSw{$l6glx!uA-^H3VEsP-o@&bJXHFJHzdCuf(H?Tx|HO%rNsx!vs*6(h4_-jSiX ze00{+OTpEO{uzCd{!aFYoklOuhPFn{PEHdV$}kfxuSU)CkD6!)Bm|s19}H zbFy7Ndin$485s!4R@G2r|C41gOKf%y(DkAS!itvA28?-Ftf-8M72t9dqA z4TQZ{ZG%p=ndn$e&I*<|hd%o4Lztn9cDJO+X_3n<=!~jCv7E-k8@)#len`9!9`qORW+N!lDP4V!m`eWyRUlgx0t4)K92F$@hU1j(Qf z2oo)B&&8tNIV7iP&Vw@RG(KI>XCv>~nv`1S_@AE{52$7wu!)bI0or-7y(BF!PtDEU zwdszg?{$0XB`8=zSkd-j0q6==<#4h!9*xyDvHaJmaufU6%d?OWNtlZ$+X&@?;jKN^ z;5pXhTCm^rrjvUMo7I^0>SLApU#s_HFEgi3oXvtoov|q@>0blJGc!+m_ z;vT3~xS5}knQ_cSTB;XSwQs|sh?{S3jzBp0N%VEIZtSkJ+a2q0sjvO)-`Z=zt1>yE zB5N2<5s{Ua4oALy;Du-kp54qFF5?)|RdNNT~q_01McfEMk%3BfFgSy`JwZKcIfW033a zKf}*2?Cq+pqVlpI3QSDoOA2SrTA9%{QcaGJ-=%t4ZPzDaC9V*5${?60CM7i)*iAnp zr;fiyeRB^8lH&B~&5K#vq$$W#yBeR7hoR|cSy?4Fr)*nhwhCI^GFXurNMq@Hi}>tU zyK>;0QCAq@wd+-R?UWxMW7peeRWw-MU|L}fKd2G^2T@|{wR7IaHj*isZPu-=(ZDL1 zXB<#3H_Pm7M*hNbsNew1ZOJs}>e-`Dl`b40Dd4d>9b@c^dPE_V;B(=8?>dBM>(}2V zGZV?s53N9b0k~|>~qnZAfywTd=mfc+g3hbDP68-C>QCOa|6+Lv4VA71I7QH-zvPZ9K)DuYYjxF>B3Y_UK%luNSP~{H zOa8%vlaotGXkIN=o#fkQU$P6ZjQnP_yT|Xakr8Low@qvIHXe#lYwu4~&TJXvU8P|L zvc&iLNW5K+Cgu3a5*p06u)=O3+?uA^3^_-Z89{3%k17vdl7Hx z6x3_0Yk8$Kf5&YR-Lw2%9dLY&`@zPa#O^Mtuqvv|pthk@X5*MPp{J6=FlPx31(%)n zi*p;;Deyyp+anMPCTj(?{X52s1wa^%E;%`7m@jrS6Elnq405uvmbbTk`Dio*v}4O0 zlP)j+aO6rYw)=&$y<|rl;rH_Dj9$7MX$xf*WE6b5g9@ToN^5@G;%K4uLO<)UF+acl z^HVuGW%8kV8vC}wt?9?f6}k}#&x~n9>0d^W^E!^S=U(6)>`#w%lREqmlOQAnT|etWY}Q(r34wYu!pMkJ*pyL{ zZQI!Mm=k5^ea9#)tge@tsr~l#FTE3CG)O(x>5kv>;WKwm#%Jl9f5(=SGB+A414EWj zf%3s(ad9y%2(+^D$|^GCn1z|8%@Zl7sHnJxrXzsK-sEomW$0!