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
|
2026-09-12 21:41:37 +03:00
|
|
|
|
@Published var needsPairing = false
|
|
|
|
|
|
@Published var artworkError: String?
|
2026-09-12 15:52:31 +03:00
|
|
|
|
@Published var incomingInvitation: String?
|
|
|
|
|
|
let discovery = DiscoveryStore()
|
2026-09-13 11:27:58 +03:00
|
|
|
|
var liveActivity: LiveActivityManager?
|
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
|
2026-09-12 21:41:37 +03:00
|
|
|
|
private var pairingID: UUID?
|
|
|
|
|
|
private var pairingClient: BridgeClient?
|
|
|
|
|
|
private let storage: ConnectionStorage
|
|
|
|
|
|
private let retryUnit: UInt64
|
|
|
|
|
|
private let artworkRetryUnit: UInt64
|
|
|
|
|
|
private let discoveryEnabled: Bool
|
2026-09-10 09:11:55 +03:00
|
|
|
|
|
2026-09-12 21:41:37 +03:00
|
|
|
|
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() }
|
2026-09-10 09:11:55 +03:00
|
|
|
|
catch { self.error = error.localizedDescription }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pair(endpoint: String, fingerprint: String, code: String) async {
|
2026-09-12 21:41:37 +03:00
|
|
|
|
guard active, !pairing else { return }
|
|
|
|
|
|
let operation = UUID()
|
|
|
|
|
|
pairingID = operation
|
2026-09-10 09:11:55 +03:00
|
|
|
|
pairing = true
|
2026-09-12 21:41:37 +03:00
|
|
|
|
defer {
|
|
|
|
|
|
if pairingID == operation { pairing = false; pairingID = nil; pairingClient = nil }
|
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
|
error = nil
|
|
|
|
|
|
do {
|
|
|
|
|
|
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
|
|
|
|
|
|
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
|
|
|
|
|
|
let pairingClient = BridgeClient(saved)
|
2026-09-12 21:41:37 +03:00
|
|
|
|
self.pairingClient = pairingClient
|
2026-09-10 09:11:55 +03:00
|
|
|
|
defer { pairingClient.close() }
|
|
|
|
|
|
saved.token = try await pairingClient.pair(code: code)
|
2026-09-12 21:41:37 +03:00
|
|
|
|
guard pairingID == operation, active else { return }
|
|
|
|
|
|
do { try storage.save(saved) }
|
2026-09-10 09:11:55 +03:00
|
|
|
|
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
|
|
|
|
|
|
connection = saved
|
2026-09-12 21:41:37 +03:00
|
|
|
|
needsPairing = false
|
2026-09-10 09:11:55 +03:00
|
|
|
|
resume()
|
2026-09-12 21:41:37 +03:00
|
|
|
|
} catch {
|
|
|
|
|
|
guard pairingID == operation else { return }
|
|
|
|
|
|
self.error = BridgeError.describe(error)
|
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func setActive(_ active: Bool) {
|
2026-09-12 21:41:37 +03:00
|
|
|
|
guard self.active != active else { return }
|
2026-09-10 09:11:55 +03:00
|
|
|
|
self.active = active
|
2026-09-12 21:41:37 +03:00
|
|
|
|
if active {
|
|
|
|
|
|
// Retry loading if Keychain was locked when the app was created.
|
|
|
|
|
|
if connection == nil {
|
|
|
|
|
|
do { connection = try storage.load() }
|
2026-09-12 21:42:41 +03:00
|
|
|
|
catch { self.error = BridgeError.describe(error) }
|
2026-09-12 21:41:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
if discoveryEnabled { discovery.start() }
|
|
|
|
|
|
resume()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cancelPairing()
|
|
|
|
|
|
discovery.stop(); stop()
|
|
|
|
|
|
status = needsPairing ? "Нужно повторное сопряжение" : "Соединение приостановлено"
|
|
|
|
|
|
}
|
2026-09-12 15:52:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func importInvitation(_ text: String) {
|
|
|
|
|
|
guard connection == nil, !pairing else {
|
|
|
|
|
|
error = "Для подключения другого ПК сначала выберите «Забыть компьютер»."
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
incomingInvitation = text
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func resume() {
|
|
|
|
|
|
stop()
|
2026-09-12 16:00:12 +03:00
|
|
|
|
guard active else { return }
|
2026-09-12 21:41:37 +03:00
|
|
|
|
guard !needsPairing else { status = "Нужно повторное сопряжение"; return }
|
2026-09-12 16:00:12 +03:00
|
|
|
|
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
|
2026-09-12 15:52:31 +03:00
|
|
|
|
var attempt = 0
|
2026-09-10 09:11:55 +03:00
|
|
|
|
while !Task.isCancelled {
|
|
|
|
|
|
guard let self, self.generation == current else { return }
|
2026-09-12 15:52:31 +03:00
|
|
|
|
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 }
|
2026-09-12 15:52:31 +03:00
|
|
|
|
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
|
|
|
|
|
|
if candidate.endpoint != saved.endpoint {
|
2026-09-12 21:41:37 +03:00
|
|
|
|
try self.storage.save(candidate)
|
2026-09-12 15:52:31 +03:00
|
|
|
|
self.connection = candidate
|
|
|
|
|
|
}
|
2026-09-12 21:41:37 +03:00
|
|
|
|
self.error = nil
|
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
|
2026-09-13 11:27:58 +03:00
|
|
|
|
self.liveActivity?.unavailable()
|
2026-09-10 09:11:55 +03:00
|
|
|
|
self.state = nil
|
|
|
|
|
|
self.artworkTask?.cancel()
|
|
|
|
|
|
self.artwork = nil
|
|
|
|
|
|
self.artworkID = nil
|
2026-09-12 21:41:37 +03:00
|
|
|
|
self.artworkError = nil
|
2026-09-10 09:11:55 +03:00
|
|
|
|
if case BridgeError.unauthorized = error {
|
2026-09-12 21:41:37 +03:00
|
|
|
|
self.revokeAccess()
|
2026-09-10 09:11:55 +03:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
self.status = "ПК недоступен. Повтор через \(delay) с"
|
2026-09-12 21:41:37 +03:00
|
|
|
|
self.error = BridgeError.describe(error)
|
2026-09-12 15:52:31 +03:00
|
|
|
|
attempt += 1
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
2026-09-12 21:41:37 +03:00
|
|
|
|
do { try await Task.sleep(nanoseconds: delay * self.retryUnit) } catch { return }
|
2026-09-10 09:11:55 +03:00
|
|
|
|
delay = min(delay * 2, 16)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func accept(_ snapshot: MediaState, client: BridgeClient) {
|
|
|
|
|
|
state = snapshot
|
|
|
|
|
|
receivedAt = Date()
|
|
|
|
|
|
connected = true
|
|
|
|
|
|
status = "Подключено"
|
2026-09-13 11:27:58 +03:00
|
|
|
|
liveActivity?.receive(snapshot)
|
2026-09-10 09:11:55 +03:00
|
|
|
|
guard artworkID != snapshot.artworkId else { return }
|
|
|
|
|
|
artworkID = snapshot.artworkId
|
|
|
|
|
|
artworkTask?.cancel()
|
|
|
|
|
|
artwork = nil
|
2026-09-12 21:41:37 +03:00
|
|
|
|
artworkError = nil
|
2026-09-10 09:11:55 +03:00
|
|
|
|
guard let id = snapshot.artworkId else { return }
|
|
|
|
|
|
let current = generation
|
|
|
|
|
|
artworkTask = Task { [weak self] in
|
2026-09-12 21:41:37 +03:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 21:41:37 +03:00
|
|
|
|
func retryArtwork() {
|
|
|
|
|
|
guard connected, let state, let client else { return }
|
|
|
|
|
|
artworkID = nil
|
|
|
|
|
|
accept(state, client: client)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 09:11:55 +03:00
|
|
|
|
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 }
|
2026-09-12 21:41:37 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
|
} catch {
|
|
|
|
|
|
guard generation == current else { return }
|
2026-09-12 21:41:37 +03:00
|
|
|
|
if case BridgeError.unauthorized = error { revokeAccess(); return }
|
|
|
|
|
|
self.error = (error is URLError ? "Не удалось подтвердить выполнение команды. Перед повтором проверьте плеер. " : "") + BridgeError.describe(error)
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func forget() {
|
2026-09-12 21:41:37 +03:00
|
|
|
|
do { try storage.delete() }
|
2026-09-10 09:11:55 +03:00
|
|
|
|
catch { self.error = error.localizedDescription; return }
|
2026-09-13 11:27:58 +03:00
|
|
|
|
liveActivity?.end()
|
2026-09-10 09:11:55 +03:00
|
|
|
|
stop()
|
2026-09-12 21:41:37 +03:00
|
|
|
|
cancelPairing()
|
|
|
|
|
|
needsPairing = false
|
2026-09-10 09:11:55 +03:00
|
|
|
|
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
|
2026-09-12 21:41:37 +03:00
|
|
|
|
artworkError = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func revokeAccess() {
|
2026-09-13 11:27:58 +03:00
|
|
|
|
liveActivity?.end()
|
2026-09-12 21:41:37 +03:00
|
|
|
|
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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
|
2026-09-10 09:11:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|