Control PC playback through interactive Live Activity intents
This commit is contained in:
@@ -59,11 +59,11 @@ final class BridgeClient {
|
||||
private let session: URLSession
|
||||
private var socket: URLSessionWebSocketTask?
|
||||
|
||||
init(_ connection: Connection) {
|
||||
init(_ connection: Connection, timeout: TimeInterval = 10) {
|
||||
self.connection = connection
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 10
|
||||
config.timeoutIntervalForResource = 15
|
||||
config.timeoutIntervalForRequest = timeout
|
||||
config.timeoutIntervalForResource = timeout + 5
|
||||
config.urlCache = nil
|
||||
config.httpCookieStorage = nil
|
||||
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
||||
|
||||
@@ -23,11 +23,11 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
if liveActivity.running { liveActivity.end() }
|
||||
else if let state = store.state { liveActivity.start(state) }
|
||||
else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
|
||||
} label: {
|
||||
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
|
||||
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
|
||||
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.")
|
||||
Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
|
||||
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
||||
|
||||
@@ -22,7 +22,7 @@ enum KeychainStore {
|
||||
|
||||
static func save(_ connection: Connection) throws {
|
||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum LiveActivityCommands {
|
||||
private static var busy = false
|
||||
static let bindingKey = "MusicBridge.liveActivityPC"
|
||||
|
||||
static func perform(_ action: MusicRemoteAction, activityID: String) async {
|
||||
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
|
||||
activity.activityState == .active || activity.activityState == .stale else { return }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
guard let saved = try KeychainStore.load(),
|
||||
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
|
||||
throw BridgeError.message("Откройте приложение и включите карточку заново.")
|
||||
}
|
||||
let client = BridgeClient(saved, timeout: 5)
|
||||
defer { client.close() }
|
||||
let fresh = try await execute(action, client: client) {
|
||||
(activity.activityState == .active || activity.activityState == .stale) &&
|
||||
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
|
||||
}
|
||||
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
|
||||
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
|
||||
} catch {
|
||||
if case BridgeError.unauthorized = error {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
return
|
||||
}
|
||||
var value = activity.content.state
|
||||
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
|
||||
await activity.update(ActivityContent(state: value, staleDate: Date()))
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
|
||||
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
|
||||
stillValid: () -> Bool = { true }) async throws -> MediaState {
|
||||
let before = try await client.state()
|
||||
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
|
||||
if action == .refresh { return before }
|
||||
guard before.hasSession else { return before }
|
||||
let command = try command(action, state: before)
|
||||
do { try await client.command(Command(type: command, value: nil)) }
|
||||
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: MusicRemoteAction, state: MediaState) throws -> String {
|
||||
switch action {
|
||||
case .previous where state.capabilities.previous: return "previous"
|
||||
case .next where state.capabilities.next: return "next"
|
||||
case .toggle where state.capabilities.toggle: return "toggle"
|
||||
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
|
||||
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
|
||||
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,18 +32,21 @@ final class LiveActivityManager: ObservableObject {
|
||||
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
|
||||
}
|
||||
|
||||
func start(_ state: MediaState) {
|
||||
func start(_ state: MediaState, connection: Connection) {
|
||||
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
|
||||
try KeychainStore.save(connection)
|
||||
let value = Self.content(state)
|
||||
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
||||
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
||||
pushType: nil)
|
||||
activity = new
|
||||
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
|
||||
last = value
|
||||
running = true
|
||||
error = nil
|
||||
@@ -72,6 +75,7 @@ final class LiveActivityManager: ObservableObject {
|
||||
|
||||
func end() {
|
||||
guard let activity else { return }
|
||||
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
|
||||
self.activity = nil
|
||||
running = false
|
||||
last = nil
|
||||
|
||||
@@ -257,7 +257,6 @@ final class PlayerStore: ObservableObject {
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
liveActivity?.unavailable()
|
||||
generation = UUID()
|
||||
runner?.cancel()
|
||||
runner = nil
|
||||
|
||||
Reference in New Issue
Block a user