Add local Live Activity and Dynamic Island track display

This commit is contained in:
2026-09-13 11:27:58 +03:00
parent 2ca6201ad6
commit ddd44e8edb
16 changed files with 600 additions and 27 deletions
+13
View File
@@ -2,6 +2,7 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var store: PlayerStore
@ObservedObject var liveActivity: LiveActivityManager
@State private var showForget = false
var body: some View {
@@ -19,6 +20,18 @@ struct ContentView: View {
} else {
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
VStack(alignment: .leading, spacing: 10) {
Button {
if liveActivity.running { liveActivity.end() }
else if let state = store.state { liveActivity.start(state) }
} label: {
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.")
.font(.caption).foregroundStyle(.secondary)
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
if !store.connected && !store.needsPairing {
Button("Подключиться снова") { store.resume() }
}
+2
View File
@@ -24,6 +24,8 @@
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
+99
View File
@@ -0,0 +1,99 @@
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) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return
}
do {
let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil)
activity = new
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 }
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
}
}
}
}
}
+21 -3
View File
@@ -3,14 +3,32 @@ import SwiftUI
@main
struct MusicBridgeApp: App {
@StateObject private var store = PreviewSupport.makeStore()
@StateObject private var liveActivity = LiveActivityManager()
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup {
ContentView(store: store)
Group {
if PreviewSupport.liveCard, let state = store.state {
ScrollView {
VStack(spacing: 24) {
Text("Live Activity · предпросмотр").font(.headline)
MusicActivityCard(state: LiveActivityManager.content(state), stale: false)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24))
MusicActivityCard(state: LiveActivityManager.content(state), stale: true)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24))
}.padding()
}
} else {
ContentView(store: store, liveActivity: liveActivity)
}
}
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { store.importInvitation($0.absoluteString) }
.task { if !PreviewSupport.enabled { store.setActive(phase == .active) } }
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task {
store.liveActivity = liveActivity
if !PreviewSupport.enabled { store.setActive(phase == .active) }
}
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
}
}
+6
View File
@@ -14,6 +14,7 @@ final class PlayerStore: ObservableObject {
@Published var artworkError: String?
@Published var incomingInvitation: String?
let discovery = DiscoveryStore()
var liveActivity: LiveActivityManager?
private(set) var receivedAt = Date()
private var client: BridgeClient?
private var runner: Task<Void, Never>?
@@ -143,6 +144,7 @@ final class PlayerStore: ObservableObject {
client.close()
guard !Task.isCancelled, self.generation == current else { return }
self.connected = false
self.liveActivity?.unavailable()
self.state = nil
self.artworkTask?.cancel()
self.artwork = nil
@@ -167,6 +169,7 @@ final class PlayerStore: ObservableObject {
receivedAt = Date()
connected = true
status = "Подключено"
liveActivity?.receive(snapshot)
guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId
artworkTask?.cancel()
@@ -244,6 +247,7 @@ final class PlayerStore: ObservableObject {
func forget() {
do { try storage.delete() }
catch { self.error = error.localizedDescription; return }
liveActivity?.end()
stop()
cancelPairing()
needsPairing = false
@@ -253,6 +257,7 @@ final class PlayerStore: ObservableObject {
}
private func stop() {
liveActivity?.unavailable()
generation = UUID()
runner?.cancel()
runner = nil
@@ -268,6 +273,7 @@ final class PlayerStore: ObservableObject {
}
private func revokeAccess() {
liveActivity?.end()
stop()
needsPairing = true
status = "Нужно повторное сопряжение"
+7
View File
@@ -17,6 +17,13 @@ enum PreviewSupport {
false
#endif
}
static var liveCard: Bool {
#if DEBUG
enabled && ProcessInfo.processInfo.arguments.contains("--preview-live-card")
#else
false
#endif
}
static func makeStore() -> PlayerStore {
#if DEBUG
if enabled {