Add iOS 27 remote media session and system player extension
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@ObservedObject var store: PlayerStore
|
||||
@ObservedObject var liveActivity: LiveActivityManager
|
||||
@ObservedObject var systemPlayer: SystemPlayerManager
|
||||
@State private var showForget = false
|
||||
|
||||
var body: some View {
|
||||
@@ -20,6 +21,23 @@ struct ContentView: View {
|
||||
} else {
|
||||
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
|
||||
|
||||
if #available(iOS 27.0, *) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
if systemPlayer.running { systemPlayer.end() }
|
||||
else if let state = store.state, let connection = store.connection {
|
||||
Task { await systemPlayer.start(state: state, connection: connection) }
|
||||
}
|
||||
} label: {
|
||||
Label(systemPlayer.running ? "Выключить системный пульт" : "Включить системный пульт iOS", systemImage: "play.rectangle")
|
||||
}.disabled(systemPlayer.busy || (!systemPlayer.running && (!store.connected || store.state?.hasSession != true)))
|
||||
if systemPlayer.busy { ProgressView() }
|
||||
Text("Музыка ПК в Пункте управления. Звук остаётся на компьютере.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) }
|
||||
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
if liveActivity.running { liveActivity.end() }
|
||||
|
||||
@@ -72,5 +72,7 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>MusicBridgeSharedKeychainGroup</key>
|
||||
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -119,7 +119,7 @@ struct Invitation: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct Capabilities: Decodable {
|
||||
struct Capabilities: Codable {
|
||||
let play, pause, toggle, next, previous, seek: Bool
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
struct MusicBridgeApp: App {
|
||||
@StateObject private var store = PreviewSupport.makeStore()
|
||||
@StateObject private var liveActivity = LiveActivityManager()
|
||||
@StateObject private var systemPlayer = SystemPlayerManager()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
@@ -20,13 +21,15 @@ struct MusicBridgeApp: App {
|
||||
}.padding()
|
||||
}
|
||||
} else {
|
||||
ContentView(store: store, liveActivity: liveActivity)
|
||||
ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer)
|
||||
}
|
||||
}
|
||||
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
|
||||
.onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
|
||||
.task {
|
||||
store.liveActivity = liveActivity
|
||||
store.systemPlayer = systemPlayer
|
||||
if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) }
|
||||
if !PreviewSupport.enabled { store.setActive(phase == .active) }
|
||||
}
|
||||
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
|
||||
|
||||
@@ -15,6 +15,7 @@ final class PlayerStore: ObservableObject {
|
||||
@Published var incomingInvitation: String?
|
||||
let discovery = DiscoveryStore()
|
||||
var liveActivity: LiveActivityManager?
|
||||
var systemPlayer: SystemPlayerManager?
|
||||
private(set) var receivedAt = Date()
|
||||
private var client: BridgeClient?
|
||||
private var runner: Task<Void, Never>?
|
||||
@@ -170,6 +171,7 @@ final class PlayerStore: ObservableObject {
|
||||
connected = true
|
||||
status = "Подключено"
|
||||
liveActivity?.receive(snapshot)
|
||||
if let connection { systemPlayer?.receive(snapshot, connection: connection) }
|
||||
guard artworkID != snapshot.artworkId else { return }
|
||||
artworkID = snapshot.artworkId
|
||||
artworkTask?.cancel()
|
||||
@@ -248,6 +250,7 @@ final class PlayerStore: ObservableObject {
|
||||
do { try storage.delete() }
|
||||
catch { self.error = error.localizedDescription; return }
|
||||
liveActivity?.end()
|
||||
systemPlayer?.end()
|
||||
stop()
|
||||
cancelPairing()
|
||||
needsPairing = false
|
||||
@@ -273,6 +276,7 @@ final class PlayerStore: ObservableObject {
|
||||
|
||||
private func revokeAccess() {
|
||||
liveActivity?.end()
|
||||
systemPlayer?.end()
|
||||
stop()
|
||||
needsPairing = true
|
||||
status = "Нужно повторное сопряжение"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import NowPlaying
|
||||
|
||||
@MainActor
|
||||
final class SystemPlayerManager: ObservableObject {
|
||||
@Published private(set) var running = false
|
||||
@Published private(set) var busy = false
|
||||
@Published private(set) var error: String?
|
||||
private var storage: Any?
|
||||
private var id: String?
|
||||
private var generation = UUID()
|
||||
private var updates: Task<Void, Never>?
|
||||
private var lastUpdate = Date.distantPast
|
||||
private var lastTrack = ""
|
||||
private var lastPlaying = false
|
||||
private let savedIDKey = "MusicBridge.systemPlayerSession"
|
||||
|
||||
func start(state: MediaState, connection: Connection) async {
|
||||
guard !busy, !running, state.hasSession, connection.token != nil,
|
||||
UIApplication.shared.applicationState == .active else { return }
|
||||
guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return }
|
||||
busy = true; error = nil
|
||||
let operation = generation
|
||||
let newID = UUID().uuidString
|
||||
id = newID
|
||||
defer { if generation == operation { busy = false } }
|
||||
do {
|
||||
try SystemPlayerCredentials.save(connection, id: newID)
|
||||
_ = try SystemPlayerCredentials.load(newID)
|
||||
let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state))
|
||||
guard generation == operation else { try? await session.end(); return }
|
||||
storage = session
|
||||
try await session.requestToBecomeSystemPrimary()
|
||||
guard generation == operation else { try? await session.end(); return }
|
||||
running = true
|
||||
UserDefaults.standard.set(newID, forKey: savedIDKey)
|
||||
} catch {
|
||||
guard generation == operation else { return }
|
||||
self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error)
|
||||
end()
|
||||
}
|
||||
}
|
||||
|
||||
func restore(connection: Connection?) async {
|
||||
guard #available(iOS 27.0, *), id == nil, !busy,
|
||||
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
|
||||
do {
|
||||
let saved = try SystemPlayerCredentials.load(savedID)
|
||||
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
|
||||
try SystemPlayerCredentials.delete(savedID)
|
||||
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
|
||||
return
|
||||
}
|
||||
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
|
||||
storage = session; id = savedID; running = true
|
||||
} else {
|
||||
try SystemPlayerCredentials.delete(savedID)
|
||||
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||
}
|
||||
} catch { self.error = BridgeError.describe(error) }
|
||||
}
|
||||
|
||||
func receive(_ state: MediaState, connection: Connection) {
|
||||
guard #available(iOS 27.0, *), running, let id,
|
||||
let session = storage as? RemoteMediaSession<SystemPlayerAttributes> else { return }
|
||||
guard state.hasSession else { end(); return }
|
||||
let track = state.title + "\n" + state.artist
|
||||
let playing = state.playbackStatus == "Playing"
|
||||
guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return }
|
||||
lastUpdate = Date(); lastTrack = track; lastPlaying = playing
|
||||
let value = SystemPlayerAttributes(id: id, state: state)
|
||||
let operation = generation
|
||||
enqueue { [weak self] in
|
||||
guard let self, self.generation == operation else { return }
|
||||
do {
|
||||
try SystemPlayerCredentials.save(connection, id: id)
|
||||
try await session.update(value)
|
||||
} catch { self.error = BridgeError.describe(error) }
|
||||
}
|
||||
}
|
||||
|
||||
func end() {
|
||||
generation = UUID()
|
||||
busy = false; running = false
|
||||
if let id {
|
||||
do { try SystemPlayerCredentials.delete(id) }
|
||||
catch { self.error = BridgeError.describe(error) }
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||
id = nil
|
||||
if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession<SystemPlayerAttributes> {
|
||||
enqueue { [weak self] in
|
||||
do { try await session.end() }
|
||||
catch { self?.error = BridgeError.describe(error) }
|
||||
}
|
||||
}
|
||||
storage = nil
|
||||
}
|
||||
|
||||
private func enqueue(_ action: @escaping @MainActor () async -> Void) {
|
||||
let previous = updates
|
||||
updates = Task { await previous?.value; await action() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user