2026-09-13 11:27:58 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 11:40:23 +03:00
|
|
|
func start(_ state: MediaState, connection: Connection) {
|
2026-09-13 11:27:58 +03:00
|
|
|
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
|
|
|
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
|
|
|
|
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
do {
|
2026-09-13 11:40:23 +03:00
|
|
|
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
|
|
|
|
|
try KeychainStore.save(connection)
|
2026-09-13 11:27:58 +03:00
|
|
|
let value = Self.content(state)
|
|
|
|
|
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
|
|
|
|
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
|
|
|
|
pushType: nil)
|
|
|
|
|
activity = new
|
2026-09-13 11:40:23 +03:00
|
|
|
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
|
2026-09-13 11:27:58 +03:00
|
|
|
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 }
|
2026-09-13 11:40:23 +03:00
|
|
|
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
|
2026-09-13 11:27:58 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|