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() private(set) var receivedAt = Date() private var client: BridgeClient? private var runner: Task? private var artworkTask: Task? 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.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 = "Подключено" 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 } 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() { 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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой." } }