Add iOS 27 remote media session and system player extension

This commit is contained in:
2026-09-14 00:17:07 +03:00
parent 2fbf3bf859
commit e2902e6031
16 changed files with 667 additions and 30 deletions
+11
View File
@@ -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>
+18
View File
@@ -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() }
+2
View File
@@ -72,5 +72,7 @@
</dict>
</dict>
</dict>
<key>MusicBridgeSharedKeychainGroup</key>
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
</dict>
</plist>
+1 -1
View File
@@ -119,7 +119,7 @@ struct Invitation: Decodable {
}
}
struct Capabilities: Decodable {
struct Capabilities: Codable {
let play, pause, toggle, next, previous, seek: Bool
}
+4 -1
View File
@@ -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) } }
+4
View File
@@ -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 = "Нужно повторное сопряжение"
+105
View File
@@ -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() }
}
}