Files
ios/MusicBridge/LiveActivityManager.swift
T

104 lines
4.6 KiB
Swift

import ActivityKit
import SwiftUI
@MainActor
final class LiveActivityManager: ObservableObject {
@Published private(set) var running = false
@Published private(set) var error: String?
private var activity: Activity<MusicActivityAttributes>?
private var updates: Task<Void, Never>?
private var observer: Task<Void, Never>?
private var last: MusicActivityAttributes.ContentState?
init() {
let existing = Activity<MusicActivityAttributes>.activities
.filter { $0.activityState == .active || $0.activityState == .stale }
if let first = existing.first {
activity = first
last = first.content.state
running = true
observe(first)
}
for extra in existing.dropFirst() {
Task { await extra.end(nil, dismissalPolicy: .immediate) }
}
}
static func content(_ state: MediaState, now: Date = Date()) -> MusicActivityAttributes.ContentState {
.init(title: MusicActivityAttributes.ContentState.bounded(state.title.isEmpty ? "Без названия" : state.title),
artist: MusicActivityAttributes.ContentState.bounded(state.artist),
playing: state.playbackStatus == "Playing",
position: max(0, state.position(elapsed: 0) - state.startSeconds),
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
}
func start(_ state: MediaState, connection: Connection) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return
}
do {
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
try KeychainStore.save(connection)
let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil)
activity = new
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
last = value
running = true
error = nil
observe(new)
} catch { self.error = "Не удалось включить Live Activity: " + error.localizedDescription }
}
func receive(_ state: MediaState) {
guard state.hasSession else { end(); return }
guard let activity, running else { return }
let value = Self.content(state)
// Coalesce ordinary position ticks, but publish track/playback changes immediately.
if let last, last.connected, last.title == value.title, last.artist == value.artist,
last.playing == value.playing, last.duration == value.duration,
abs(last.position - value.position) < 5, value.updatedAt.timeIntervalSince(last.updatedAt) < 5 { return }
last = value
enqueue { await activity.update(ActivityContent(state: value, staleDate: value.updatedAt.addingTimeInterval(15))) }
}
func unavailable() {
guard let activity, var value = last, value.connected else { return }
value.connected = false
last = value
enqueue { await activity.update(ActivityContent(state: value, staleDate: Date())) }
}
func end() {
guard let activity else { return }
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
self.activity = nil
running = false
last = nil
observer?.cancel()
enqueue { await activity.end(nil, dismissalPolicy: .immediate) }
}
private func enqueue(_ operation: @escaping @MainActor () async -> Void) {
let previous = updates
updates = Task { await previous?.value; await operation() }
}
private func observe(_ activity: Activity<MusicActivityAttributes>) {
observer?.cancel()
observer = Task { [weak self] in
for await state in activity.activityStateUpdates {
guard !Task.isCancelled, let self, self.activity?.id == activity.id else { return }
if state == .dismissed || state == .ended {
self.activity = nil; self.running = false; self.last = nil
return
}
}
}
}
}