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 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 init() { do { connection = try KeychainStore.load() } catch { self.error = error.localizedDescription } } func pair(endpoint: String, fingerprint: String, code: String) async { guard !pairing else { return } pairing = true defer { pairing = false } error = nil do { guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") } var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint) let pairingClient = BridgeClient(saved) defer { pairingClient.close() } saved.token = try await pairingClient.pair(code: code) do { try KeychainStore.save(saved) } catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") } connection = saved resume() } catch { self.error = error.localizedDescription } } func setActive(_ active: Bool) { self.active = active if active { discovery.start(); resume() } else { discovery.stop(); stop(); status = "Соединение приостановлено" } } func importInvitation(_ text: String) { guard connection == nil, !pairing else { error = "Для подключения другого ПК сначала выберите «Забыть компьютер»." return } incomingInvitation = text } func resume() { stop() guard active, let connection else { 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 KeychainStore.save(candidate) self.connection = candidate } 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 if case BridgeError.unauthorized = error { self.status = "Нужно повторное сопряжение" self.error = error.localizedDescription return } self.status = "ПК недоступен. Повтор через \(delay) с" self.error = error.localizedDescription attempt += 1 } do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return } delay = min(delay * 2, 16) } } } private func accept(_ snapshot: MediaState, client: BridgeClient) { state = snapshot receivedAt = Date() connected = true status = "Подключено" error = nil guard artworkID != snapshot.artworkId else { return } artworkID = snapshot.artworkId artworkTask?.cancel() artwork = nil guard let id = snapshot.artworkId else { return } let current = generation artworkTask = Task { [weak self] in guard let data = try? await client.artwork(id), !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return } self.artwork = UIImage(data: data) } } 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)) let fresh = try await client.state() guard generation == current else { return } accept(fresh, client: client) } catch { guard generation == current else { return } self.error = error.localizedDescription if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" } } } func forget() { do { try KeychainStore.delete() } catch { self.error = error.localizedDescription; return } stop() 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 } }