Add local Live Activity and Dynamic Island track display
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "Нужно повторное сопряжение"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user