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 @@
-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 @@
-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#9wHjPG4