Files
ios/MusicBridge/PlayerStore.swift
T

187 lines
7.7 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 incomingInvitation: String?
let discovery = DiscoveryStore()
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
}
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
}
}