Add iOS 27 remote media session and system player extension
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import NowPlaying
|
||||
|
||||
@available(iOS 27.0, *)
|
||||
struct SystemPlayerAttributes: RemoteMediaSessionAttributes {
|
||||
let id: String
|
||||
var title: String
|
||||
var artist: String
|
||||
var source: String
|
||||
var artworkID: String?
|
||||
var playing: Bool
|
||||
var rate: Double
|
||||
var position: Double
|
||||
var duration: Double
|
||||
var timestamp: Date
|
||||
var capabilities: Capabilities
|
||||
var volume: Double?
|
||||
var hasSession: Bool
|
||||
|
||||
init(id: String, state: MediaState, now: Date = Date()) {
|
||||
self.id = id
|
||||
title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256))
|
||||
source = String(state.source.prefix(128)); artworkID = state.artworkId
|
||||
playing = state.playbackStatus == "Playing"; rate = state.playbackRate
|
||||
position = max(0, state.position(elapsed: 0) - state.startSeconds)
|
||||
duration = max(0, state.endSeconds - state.startSeconds)
|
||||
timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum SystemPlayerCredentials {
|
||||
private static func query(_ id: String) throws -> [String: Any] {
|
||||
guard let group = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
|
||||
!group.isEmpty, !group.contains("$(") else {
|
||||
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
|
||||
}
|
||||
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
|
||||
kSecAttrAccount as String: id, kSecAttrAccessGroup as String: group]
|
||||
}
|
||||
|
||||
static func save(_ connection: Connection, id: String) throws {
|
||||
let query = try query(id)
|
||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||
if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) }
|
||||
guard status == errSecSuccess else { throw failure(status) }
|
||||
}
|
||||
|
||||
static func load(_ id: String) throws -> Connection {
|
||||
var query = try query(id)
|
||||
query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
|
||||
let saved = try JSONDecoder().decode(Connection.self, from: data)
|
||||
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized }
|
||||
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
|
||||
}
|
||||
|
||||
static func delete(_ id: String) throws {
|
||||
let status = SecItemDelete(try query(id) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
|
||||
}
|
||||
|
||||
private static func failure(_ status: OSStatus) -> BridgeError {
|
||||
.message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemPlayerAction { case play, pause, next, previous, seek, volume }
|
||||
|
||||
@MainActor
|
||||
final class SystemPlayerTransport {
|
||||
private var busy = false
|
||||
|
||||
func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState {
|
||||
guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
let saved = try SystemPlayerCredentials.load(sessionID)
|
||||
let client = BridgeClient(saved, timeout: 5)
|
||||
defer { client.close() }
|
||||
return try await Self.execute(action, value: value, client: client) {
|
||||
let current = try SystemPlayerCredentials.load(sessionID)
|
||||
guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else {
|
||||
throw BridgeError.unauthorized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient,
|
||||
validate: () throws -> Void = {}) async throws -> MediaState {
|
||||
let state = try await client.state()
|
||||
try validate()
|
||||
let command = try command(action, value: value, state: state)
|
||||
do { try await client.command(command) }
|
||||
catch {
|
||||
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Откройте плеер перед повтором.") }
|
||||
throw error
|
||||
}
|
||||
do { return try await client.state() }
|
||||
catch {
|
||||
if case BridgeError.unauthorized = error { throw error }
|
||||
throw BridgeError.message("Команда выполнена, но новый трек не получен. Откройте приложение для обновления.")
|
||||
}
|
||||
}
|
||||
|
||||
static func command(_ action: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command {
|
||||
switch action {
|
||||
case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil)
|
||||
case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil)
|
||||
case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil)
|
||||
case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil)
|
||||
case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds:
|
||||
guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") }
|
||||
return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value)))
|
||||
case .volume where state.volume != nil:
|
||||
guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") }
|
||||
return Command(type: "volume", value: min(1, max(0, value)))
|
||||
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user