Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ final class PlaybackStore {
final class LyricsStore {
private(set) var live = BLELiveLyricState()
private(set) var document = BLEFullLyricsViewState()
private(set) var floatingPlaybackControlState: LyricsPlaybackControlState = .idle

func updateFloatingPlaybackControlState(_ value: LyricsPlaybackControlState) {
if floatingPlaybackControlState != value { floatingPlaybackControlState = value }
}

func updateLive(_ value: BLELiveLyricState) {
if live != value { live = value }
Expand Down
127 changes: 109 additions & 18 deletions IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift

Large diffs are not rendered by default.

90 changes: 65 additions & 25 deletions IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
@preconcurrency import ActivityKit
import Foundation

/// Ending resources is independent of the publication epoch: an end() must
/// finish already scheduled cleanup, while a newly selected target is protected.
@MainActor
final class LiveActivityCleanupQueue {
private var activityIDs: Set<String> = []

func contains(_ id: String) -> Bool { activityIDs.contains(id) }

func enqueue(id: String, canEnd: @escaping () -> Bool = { true },
end: @escaping () async -> Void) {
guard activityIDs.insert(id).inserted else { return }
Task {
defer { activityIDs.remove(id) }
guard canEnd() else { return }
await end()
}
}
}

@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
Expand All @@ -14,6 +33,8 @@ final class LiveActivityManager {

private var activity: Activity<SonyMusicActivityAttributes>?
private var activityStateTask: Task<Void, Never>?
private var publicationTask: Task<Void, Never>?
private let activityCleanup = LiveActivityCleanupQueue()
private var latestState: SonyMusicActivityAttributes.ContentState?
private var latestReason = "unknown"
private var latestForce = false
Expand Down Expand Up @@ -147,6 +168,8 @@ final class LiveActivityManager {

func end(logger: ((String) -> Void)? = nil) {
publicationEpoch &+= 1
publicationTask?.cancel()
publicationTask = nil
updateInFlight = false
debounceTask?.cancel()
debounceTask = nil
Expand All @@ -161,11 +184,11 @@ final class LiveActivityManager {
let state = lastSentState
lastSentState = nil
lastSentVersion = 0
guard let activity else { return }
self.activity = nil
activityStateTask?.cancel()
activityStateTask = nil
Task {
guard let activity else { return }
self.activity = nil
activityCleanup.enqueue(id: activity.id) {
if let state {
await activity.end(
ActivityContent(state: state, staleDate: nil),
Expand Down Expand Up @@ -399,16 +422,16 @@ final class LiveActivityManager {
}
updateInFlight = true
let epoch = publicationEpoch
let backgroundTask = BoundedBackgroundTask(name: "Live Activity update")

Task { [weak self] in
publicationTask = Task { [weak self] in
guard !Task.isCancelled, let self, self.publicationEpoch == epoch else { return }
let backgroundTask = BoundedBackgroundTask(name: "Live Activity update")
defer { backgroundTask.end() }
guard let self else { return }
let startedAt = Date()
logger?("[LiveActivityPerf] update start reason=\(reason)")
logger?("[LiveActivityState] update start version=\(version)")
let published = await self.publish(state, epoch: epoch, logger: logger)
guard self.publicationEpoch == epoch else { return }
guard !Task.isCancelled, self.publicationEpoch == epoch else { return }
self.publicationTask = nil
if published {
self.lastSentState = state
self.lastSentVersion = version
Expand Down Expand Up @@ -442,36 +465,44 @@ final class LiveActivityManager {
epoch: UInt64,
logger: ((String) -> Void)?
) async -> Bool {
guard !Task.isCancelled, publicationEpoch == epoch else { return false }
if let statePublisher { return await statePublisher(state) }
guard let target = await ensureActivity(for: state, logger: logger) else { return false }
guard let target = await ensureActivity(for: state, epoch: epoch, logger: logger),
!Task.isCancelled, publicationEpoch == epoch, activity?.id == target.id,
target.activityState == .active || target.activityState == .stale else { return false }
await target.update(ActivityContent(state: state, staleDate: staleDate(for: state)))
guard publicationEpoch == epoch else { return false }
guard !Task.isCancelled, publicationEpoch == epoch, activity?.id == target.id,
target.activityState == .active || target.activityState == .stale else { return false }
activity = target
return true
}

private func ensureActivity(
for state: SonyMusicActivityAttributes.ContentState,
epoch: UInt64,
logger: ((String) -> Void)?
) async -> Activity<SonyMusicActivityAttributes>? {
guard !Task.isCancelled, publicationEpoch == epoch else { return nil }
if let activity {
endDuplicateActivities(keeping: activity.id, logger: logger)
endDuplicateActivities(keeping: activity.id, epoch: epoch, logger: logger)
return activity
}

logger?("[LiveActivity] activity missing, lookup existing")
if let existing = restoreExistingActivity(logger: logger) {
if let existing = restoreExistingActivity(epoch: epoch, logger: logger) {
return existing
}

return await start(state: state, logger: logger)
return await start(state: state, epoch: epoch, logger: logger)
}

private func start(
state: SonyMusicActivityAttributes.ContentState,
epoch: UInt64,
logger: ((String) -> Void)?
) async -> Activity<SonyMusicActivityAttributes>? {
if let existing = restoreExistingActivity(logger: logger) {
guard !Task.isCancelled, publicationEpoch == epoch else { return nil }
if let existing = restoreExistingActivity(epoch: epoch, logger: logger) {
return existing
}
if startInFlight {
Expand All @@ -494,7 +525,7 @@ final class LiveActivityManager {
pushType: nil
)
activity = requested
observeStateUpdates(for: requested, logger: logger)
observeStateUpdates(for: requested, epoch: epoch, logger: logger)
logger?("[LiveActivity] start id=\(requested.id)")
return requested
} catch {
Expand All @@ -505,46 +536,55 @@ final class LiveActivityManager {
}

private func restoreExistingActivity(
epoch: UInt64,
logger: ((String) -> Void)?
) -> Activity<SonyMusicActivityAttributes>? {
guard !Task.isCancelled, publicationEpoch == epoch else { return nil }
let activities = Activity<SonyMusicActivityAttributes>.activities
guard let existing = activities.first else { return nil }
guard let existing = activities.first(where: {
!activityCleanup.contains($0.id) && ($0.activityState == .active || $0.activityState == .stale)
}) else { return nil }
activity = existing
observeStateUpdates(for: existing, logger: logger)
endDuplicateActivities(keeping: existing.id, logger: logger)
observeStateUpdates(for: existing, epoch: epoch, logger: logger)
endDuplicateActivities(keeping: existing.id, epoch: epoch, logger: logger)
logger?("[LiveActivity] existing restored id=\(existing.id)")
return existing
}

private func endDuplicateActivities(
keeping activityID: String?,
epoch: UInt64,
logger: ((String) -> Void)?
) {
guard !Task.isCancelled, publicationEpoch == epoch else { return }
for duplicate in Activity<SonyMusicActivityAttributes>.activities
where duplicate.id != activityID {
where duplicate.id != activityID && !activityCleanup.contains(duplicate.id) {
logger?("[LiveActivity] duplicate activity ending id=\(duplicate.id)")
Task {
activityCleanup.enqueue(id: duplicate.id, canEnd: { [weak self] in
self?.activity?.id != duplicate.id
}) {
await duplicate.end(nil, dismissalPolicy: .immediate)
}
}
}

private func observeStateUpdates(
for activity: Activity<SonyMusicActivityAttributes>,
epoch: UInt64,
logger: ((String) -> Void)?
) {
activityStateTask?.cancel()
activityStateTask = Task { [weak self] in
for await state in activity.activityStateUpdates {
await MainActor.run {
guard !Task.isCancelled, let self, self.publicationEpoch == epoch,
self.activity?.id == activity.id else { return }
logger?("[LiveActivity] state changed state=\(state)")
if state == .active || state == .stale {
self?.activity = activity
self.activity = activity
} else if state == .ended || state == .dismissed {
if self?.activity?.id == activity.id {
self?.activity = nil
}
self?.startCooldownUntil = Date().addingTimeInterval(5)
self.activity = nil
self.startCooldownUntil = Date().addingTimeInterval(5)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"sourceLanguage" : "zh-Hans",
"strings" : {
"播放控制已发送,等待确认" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback control sent. Waiting for confirmation."}}}},
"播放控制结果未知,请回到应用重新连接后重试" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback result unknown. Return to the app, reconnect, then try again."}}}},
"播放控制未执行,状态同步后可重试" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback control was not executed. Sync the state, then try again."}}}},
"灵动岛显示歌词" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Show lyrics in Dynamic Island"}}}},
"在紧凑态优先显示当前歌词;长句会截断,多活动时由系统选择最小态。" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Prioritize lyrics in the compact view. Long lines are truncated; the system chooses the minimal view when multiple activities compete."}}}},
"悬浮歌词" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Floating lyrics"}}}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,16 @@ extension LyricsPictureInPictureController: AVPictureInPictureSampleBufferPlayba
Task { @MainActor [weak self] in
guard let self, self.controller === pip, self.state == .active, self.lifecycle.enabled,
self.playbackStatus.acceptsControls(owner: ObjectIdentifier(pip)) else { return }
if self.setPlaying?(playing) != true {
self.reason = AppLocalization.string("播放状态未同步,无法执行控制")
let accepted = self.setPlaying?(playing) == true
if !accepted {
// A known control failure/unknown result is displayed in the
// snapshot. Keep the fallback for an unavailable source only.
if self.snapshotProvider?().playbackControlState == .idle {
self.reason = AppLocalization.string("播放状态未同步,无法执行控制")
}
pip.invalidatePlaybackState()
}
if let snapshot = self.snapshotProvider?() { self.update(snapshot, force: true) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ struct FloatingLyricsPanel: View {
if !controller.reason.isEmpty {
Text(controller.reason).font(.caption2).foregroundStyle(.secondary)
}
let controlState = manager.lyricsStore.floatingPlaybackControlState
if controlState != .idle {
Text(controlState.message).font(.caption2).foregroundStyle(.secondary)
}
if controlState == .unknown {
Button("重新连接") { manager.forceReconnect() }
}
}
Spacer(minLength: 0)
}
Expand Down
Loading
Loading