Files
ios/MusicBridge/PlayerStore.swift
T

294 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
@MainActor
final class PlayerStore: ObservableObject {
@Published var connection: Connection?
@Published var state: MediaState?
@Published var artwork: UIImage?
@Published var status = "Подключите компьютер"
@Published var error: String?
@Published var connected = false
@Published var busy = false
@Published var pairing = false
@Published var needsPairing = false
@Published var artworkError: String?
@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>?
private var artworkTask: Task<Void, Never>?
private var artworkID: String?
private var generation = UUID()
private var active = false
private var pairingID: UUID?
private var pairingClient: BridgeClient?
private let storage: ConnectionStorage
private let retryUnit: UInt64
private let artworkRetryUnit: UInt64
private let discoveryEnabled: Bool
init(storage: ConnectionStorage = .keychain, retryUnit: UInt64 = 1_000_000_000,
artworkRetryUnit: UInt64 = 2_000_000_000, discoveryEnabled: Bool = true) {
self.storage = storage
self.retryUnit = retryUnit
self.artworkRetryUnit = artworkRetryUnit
self.discoveryEnabled = discoveryEnabled
do { connection = try storage.load() }
catch { self.error = error.localizedDescription }
}
func pair(endpoint: String, fingerprint: String, code: String) async {
guard active, !pairing else { return }
let operation = UUID()
pairingID = operation
pairing = true
defer {
if pairingID == operation { pairing = false; pairingID = nil; pairingClient = nil }
}
error = nil
do {
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
let pairingClient = BridgeClient(saved)
self.pairingClient = pairingClient
defer { pairingClient.close() }
saved.token = try await pairingClient.pair(code: code)
guard pairingID == operation, active else { return }
do { try storage.save(saved) }
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
connection = saved
needsPairing = false
resume()
} catch {
guard pairingID == operation else { return }
self.error = BridgeError.describe(error)
}
}
func setActive(_ active: Bool) {
guard self.active != active else { return }
self.active = active
if active {
// Retry loading if Keychain was locked when the app was created.
if connection == nil {
do { connection = try storage.load() }
catch { self.error = BridgeError.describe(error) }
}
if discoveryEnabled { discovery.start() }
resume()
} else {
cancelPairing()
discovery.stop(); stop()
status = needsPairing ? "Нужно повторное сопряжение" : "Соединение приостановлено"
}
}
func importInvitation(_ text: String) {
guard connection == nil, !pairing else {
error = "Для подключения другого ПК сначала выберите «Забыть компьютер»."
return
}
incomingInvitation = text
}
func resume() {
stop()
guard active else { return }
guard !needsPairing else { status = "Нужно повторное сопряжение"; return }
guard let connection else { status = "Подключите компьютер"; return }
let current = generation
runner = Task { [weak self] in
var delay: UInt64 = 1
var attempt = 0
while !Task.isCancelled {
guard let self, self.generation == current else { return }
let saved = self.connection ?? connection
let alternatives = self.discovery.computers.compactMap { $0.reconnect(saved) }
.filter { $0.endpoint != saved.endpoint }
let candidates = [saved] + alternatives
let candidate = candidates[attempt % candidates.count]
let client = BridgeClient(candidate)
self.client = client
self.status = "Подключение к ПК…"
do {
let initial = try await client.state()
try Task.checkCancellation()
guard self.generation == current else { client.close(); return }
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
if candidate.endpoint != saved.endpoint {
try self.storage.save(candidate)
self.connection = candidate
}
self.error = nil
self.accept(initial, client: client)
client.openEvents()
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
let watchdog = Task { [weak self] in
while !Task.isCancelled {
do { try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return }
guard let self, self.generation == current else { return }
if Date().timeIntervalSince(self.receivedAt) > 15 { client.close(); return }
}
}
defer { watchdog.cancel() }
while !Task.isCancelled {
let snapshot = try await client.nextState()
try Task.checkCancellation()
guard self.generation == current else { return }
self.accept(snapshot, client: client)
delay = 1
}
} catch {
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
self.artworkID = nil
self.artworkError = nil
if case BridgeError.unauthorized = error {
self.revokeAccess()
return
}
self.status = "ПК недоступен. Повтор через \(delay) с"
self.error = BridgeError.describe(error)
attempt += 1
}
do { try await Task.sleep(nanoseconds: delay * self.retryUnit) } catch { return }
delay = min(delay * 2, 16)
}
}
}
private func accept(_ snapshot: MediaState, client: BridgeClient) {
state = snapshot
receivedAt = Date()
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()
artwork = nil
artworkError = nil
guard let id = snapshot.artworkId else { return }
let current = generation
artworkTask = Task { [weak self] in
for attempt in 0..<4 {
do {
try Task.checkCancellation()
let data = try await client.artwork(id)
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
guard let image = UIImage(data: data) else { throw BridgeError.message("ПК прислал повреждённую обложку.") }
self.artwork = image
self.artworkError = nil
return
} catch {
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
self.artworkError = attempt < 3 ? "Обложка пока недоступна. Повторяем загрузку…" : "Не удалось загрузить обложку. Можно повторить вручную."
// A 404 often means the song changed between the state and image requests.
if case BridgeError.http(status: 404, code: _) = error {
do {
let fresh = try await client.state()
guard !Task.isCancelled, self.generation == current, self.artworkID == id else { return }
self.accept(fresh, client: client)
if self.artworkID != id { return }
} catch {
guard !Task.isCancelled, self.generation == current else { return }
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
}
}
if attempt < 3 {
do { try await Task.sleep(nanoseconds: self.artworkRetryUnit * UInt64(1 << attempt)) }
catch { return }
}
}
}
}
}
func retryArtwork() {
guard connected, let state, let client else { return }
artworkID = nil
accept(state, client: client)
}
func send(_ type: String, value: Double? = nil) async {
guard connected, !busy, let client else { return }
let current = generation
busy = true
defer { if generation == current { busy = false } }
do {
// Commands are never retried: their IDs are not idempotency keys.
try await client.command(Command(type: type, value: value))
guard generation == current else { return }
self.error = nil
do {
let fresh = try await client.state()
guard generation == current else { return }
accept(fresh, client: client)
} catch {
guard generation == current else { return }
if case BridgeError.unauthorized = error { revokeAccess(); return }
self.error = "Команда выполнена, но состояние пока не обновилось. " + BridgeError.describe(error)
}
} catch {
guard generation == current else { return }
if case BridgeError.unauthorized = error { revokeAccess(); return }
self.error = (error is URLError ? "Не удалось подтвердить выполнение команды. Перед повтором проверьте плеер. " : "") + BridgeError.describe(error)
}
}
func forget() {
do { try storage.delete() }
catch { self.error = error.localizedDescription; return }
liveActivity?.end()
systemPlayer?.end()
stop()
cancelPairing()
needsPairing = false
connection = nil
status = "Подключите компьютер"
error = nil
}
private func stop() {
generation = UUID()
runner?.cancel()
runner = nil
artworkTask?.cancel()
client?.close()
client = nil
connected = false
busy = false
state = nil
artwork = nil
artworkID = nil
artworkError = nil
}
private func revokeAccess() {
liveActivity?.end()
systemPlayer?.end()
stop()
needsPairing = true
status = "Нужно повторное сопряжение"
error = BridgeError.unauthorized.localizedDescription
}
private func cancelPairing() {
guard pairingID != nil else { return }
pairingID = nil
pairingClient?.close(); pairingClient = nil
pairing = false
error = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
}
}