Files
ios/MusicBridge/PlayerStore.swift
T

188 lines
7.8 KiB
Swift
Raw Normal View History

2026-09-10 09:11:55 +03:00
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()
2026-09-10 09:11:55 +03:00
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
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
2026-09-10 09:11:55 +03:00
}
func resume() {
stop()
guard active else { return }
guard let connection else { status = "Подключите компьютер"; return }
2026-09-10 09:11:55 +03:00
let current = generation
runner = Task { [weak self] in
var delay: UInt64 = 1
var attempt = 0
2026-09-10 09:11:55 +03:00
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)
2026-09-10 09:11:55 +03:00
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
}
2026-09-10 09:11:55 +03:00
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
2026-09-10 09:11:55 +03:00
}
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
}
}