2026-09-10 09:11:55 +03:00
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct ContentView: View {
|
|
|
|
|
@ObservedObject var store: PlayerStore
|
|
|
|
|
@State private var showForget = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
NavigationStack {
|
2026-09-13 10:56:43 +03:00
|
|
|
GeometryReader { viewport in
|
2026-09-10 09:11:55 +03:00
|
|
|
ScrollView {
|
2026-09-12 21:54:23 +03:00
|
|
|
VStack(spacing: 20) {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Circle().fill(store.connected ? Color.green : Color.secondary).frame(width: 7, height: 7)
|
|
|
|
|
Text(store.status).font(.subheadline).foregroundStyle(.secondary)
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}.accessibilityElement(children: .combine)
|
2026-09-10 09:11:55 +03:00
|
|
|
if store.connection == nil {
|
2026-09-12 15:52:31 +03:00
|
|
|
PairingView(store: store, discovery: store.discovery)
|
2026-09-10 09:11:55 +03:00
|
|
|
} else {
|
2026-09-13 10:56:43 +03:00
|
|
|
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
|
2026-09-12 21:54:23 +03:00
|
|
|
|
2026-09-12 21:41:37 +03:00
|
|
|
if !store.connected && !store.needsPairing {
|
2026-09-10 09:11:55 +03:00
|
|
|
Button("Подключиться снова") { store.resume() }
|
|
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
if store.needsPairing {
|
|
|
|
|
Button("Выполнить сопряжение заново") { showForget = true }
|
|
|
|
|
.buttonStyle(.borderedProminent).controlSize(.large)
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
|
|
|
|
if let error = store.error {
|
|
|
|
|
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
2026-09-12 21:41:37 +03:00
|
|
|
Button("Скрыть сообщение") { store.error = nil }.font(.footnote)
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
}.padding(20)
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
.scrollDismissesKeyboard(.interactively)
|
2026-09-13 10:56:43 +03:00
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
.background(Color(.systemGroupedBackground))
|
|
|
|
|
.navigationTitle("MusicBridge")
|
2026-09-12 21:54:23 +03:00
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
|
.toolbar {
|
|
|
|
|
if store.connection != nil {
|
|
|
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
Menu {
|
|
|
|
|
Text(store.connection?.endpoint.host ?? "Компьютер")
|
|
|
|
|
Button("Подключиться снова", systemImage: "arrow.clockwise") { store.resume() }.disabled(store.needsPairing)
|
|
|
|
|
Button("Забыть компьютер", systemImage: "trash", role: .destructive) { showForget = true }
|
|
|
|
|
} label: { Image(systemName: "desktopcomputer").frame(minWidth: 48, minHeight: 48) }
|
|
|
|
|
.accessibilityLabel("Настройки компьютера")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
.confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
|
|
|
|
|
Button("Забыть", role: .destructive) { store.forget() }
|
|
|
|
|
} message: {
|
|
|
|
|
Text("Токен будет удалён с iPhone. Чтобы отозвать доступ на ПК, удалите устройство в окне MusicBridge.")
|
|
|
|
|
}
|
|
|
|
|
}.tint(.indigo)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 21:54:23 +03:00
|
|
|
private extension View {
|
|
|
|
|
func bridgeCard() -> some View {
|
|
|
|
|
padding(18).background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 09:11:55 +03:00
|
|
|
private struct PairingView: View {
|
|
|
|
|
@ObservedObject var store: PlayerStore
|
2026-09-12 15:52:31 +03:00
|
|
|
@ObservedObject var discovery: DiscoveryStore
|
2026-09-10 09:11:55 +03:00
|
|
|
@State private var endpoint = "https://"
|
|
|
|
|
@State private var fingerprint = ""
|
|
|
|
|
@State private var code = ""
|
|
|
|
|
@State private var invitation = ""
|
2026-09-12 15:52:31 +03:00
|
|
|
@State private var showScanner = false
|
2026-09-12 21:54:23 +03:00
|
|
|
@State private var showDetails = false
|
|
|
|
|
@State private var prepared = false
|
|
|
|
|
@FocusState private var editing: Bool
|
2026-09-10 09:11:55 +03:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 18) {
|
2026-09-12 21:54:23 +03:00
|
|
|
VStack(spacing: 14) {
|
|
|
|
|
Image(systemName: "waveform.path")
|
|
|
|
|
.font(.system(size: 40, weight: .medium)).foregroundStyle(.white)
|
|
|
|
|
.frame(width: 84, height: 84)
|
|
|
|
|
.background(LinearGradient(colors: [.indigo, .teal], startPoint: .topLeading, endPoint: .bottomTrailing), in: RoundedRectangle(cornerRadius: 25))
|
|
|
|
|
.accessibilityHidden(true)
|
|
|
|
|
Text("Ваш ПК. Ваша музыка.").font(.title2.bold()).multilineTextAlignment(.center)
|
|
|
|
|
Text("Откройте QR в агенте на компьютере. iPhone и ПК должны быть в одной сети.")
|
|
|
|
|
.font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
|
|
|
Button { editing = false; showScanner = true } label: {
|
|
|
|
|
Label("Сканировать QR", systemImage: "qrcode.viewfinder")
|
2026-09-12 15:52:31 +03:00
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
|
2026-09-12 21:54:23 +03:00
|
|
|
}.frame(maxWidth: .infinity).bridgeCard()
|
|
|
|
|
if prepared {
|
|
|
|
|
Label("QR прочитан · данные готовы", systemImage: "checkmark.circle.fill")
|
|
|
|
|
.font(.subheadline.weight(.semibold)).foregroundStyle(.teal)
|
|
|
|
|
Text(endpoint).font(.footnote.monospaced()).textSelection(.enabled)
|
|
|
|
|
Text("Если приглашение пришло не с экрана вашего ПК, сверьте отпечаток в деталях подключения.")
|
|
|
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
DisclosureGroup("Найти компьютер") {
|
2026-09-12 15:52:31 +03:00
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
Text(discovery.message).font(.footnote).foregroundStyle(.secondary)
|
|
|
|
|
ForEach(discovery.computers) { computer in
|
|
|
|
|
Button {
|
|
|
|
|
endpoint = computer.endpoint
|
|
|
|
|
fingerprint = computer.fingerprint
|
|
|
|
|
code = ""
|
2026-09-12 21:54:23 +03:00
|
|
|
showDetails = true; prepared = false
|
2026-09-12 15:52:31 +03:00
|
|
|
store.error = nil
|
|
|
|
|
} label: {
|
|
|
|
|
VStack(alignment: .leading) {
|
|
|
|
|
Label(computer.name, systemImage: "desktopcomputer")
|
|
|
|
|
Text(computer.endpoint).font(.caption)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Text("Выберите ПК, сверьте весь отпечаток с его экраном и введите свежий код сопряжения.")
|
|
|
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
Button("Повторить поиск") { discovery.restart() }
|
|
|
|
|
Button("Настройки доступа к сети") {
|
|
|
|
|
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
|
|
|
|
|
}
|
|
|
|
|
}.padding(.top, 8)
|
2026-09-12 21:54:23 +03:00
|
|
|
}.bridgeCard().disabled(store.pairing)
|
|
|
|
|
DisclosureGroup(prepared ? "Детали подключения" : "Подключить вручную", isExpanded: $showDetails) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
2026-09-10 09:11:55 +03:00
|
|
|
Group {
|
2026-09-12 21:54:23 +03:00
|
|
|
Text("Адрес компьютера").font(.subheadline.weight(.medium))
|
2026-09-10 09:11:55 +03:00
|
|
|
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
|
2026-09-12 21:54:23 +03:00
|
|
|
Text("Отпечаток сертификата").font(.subheadline.weight(.medium))
|
2026-09-12 15:55:33 +03:00
|
|
|
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint, axis: .vertical)
|
|
|
|
|
.lineLimit(2...4).font(.system(.footnote, design: .monospaced))
|
2026-09-12 21:54:23 +03:00
|
|
|
Text("Код сопряжения").font(.subheadline.weight(.medium))
|
2026-09-10 09:11:55 +03:00
|
|
|
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
|
2026-09-12 21:54:23 +03:00
|
|
|
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled().focused($editing)
|
2026-09-10 09:11:55 +03:00
|
|
|
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
|
2026-09-12 21:54:23 +03:00
|
|
|
}.padding(.top, 12)
|
|
|
|
|
}.bridgeCard().disabled(store.pairing)
|
|
|
|
|
if prepared || showDetails {
|
2026-09-10 09:11:55 +03:00
|
|
|
Button {
|
2026-09-12 21:54:23 +03:00
|
|
|
editing = false
|
2026-09-10 09:11:55 +03:00
|
|
|
Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
code: code.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
|
|
|
} label: {
|
|
|
|
|
HStack {
|
|
|
|
|
if store.pairing { ProgressView().tint(.white) }
|
|
|
|
|
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
|
|
|
|
|
}.frame(maxWidth: .infinity)
|
|
|
|
|
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
|
2026-09-12 21:54:23 +03:00
|
|
|
}
|
|
|
|
|
DisclosureGroup("Вставить приглашение") {
|
2026-09-10 09:11:55 +03:00
|
|
|
VStack(spacing: 12) {
|
|
|
|
|
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
|
|
|
|
|
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
|
|
|
Button("Заполнить из приглашения") {
|
2026-09-12 15:52:31 +03:00
|
|
|
importText(invitation)
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
|
|
|
|
}.padding(.top, 12)
|
2026-09-12 21:54:23 +03:00
|
|
|
}.bridgeCard().disabled(store.pairing)
|
|
|
|
|
}
|
|
|
|
|
.toolbar {
|
|
|
|
|
ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Готово") { editing = false } }
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
2026-09-12 15:52:31 +03:00
|
|
|
.sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) }
|
|
|
|
|
.onAppear { consumeIncoming() }
|
|
|
|
|
.onChange(of: store.incomingInvitation) { _, _ in consumeIncoming() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func consumeIncoming() {
|
|
|
|
|
guard let text = store.incomingInvitation else { return }
|
|
|
|
|
store.incomingInvitation = nil
|
|
|
|
|
importText(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func importText(_ text: String) {
|
|
|
|
|
do {
|
|
|
|
|
let parsed = try Invitation.parse(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
|
|
endpoint = parsed.endpoint
|
|
|
|
|
fingerprint = parsed.certificateSha256
|
|
|
|
|
code = parsed.code
|
2026-09-12 21:54:23 +03:00
|
|
|
prepared = true; showDetails = false; editing = false
|
2026-09-12 15:52:31 +03:00
|
|
|
store.error = nil
|
|
|
|
|
} catch { store.error = error.localizedDescription }
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PlayerView: View {
|
|
|
|
|
@ObservedObject var store: PlayerStore
|
2026-09-13 10:56:43 +03:00
|
|
|
let coverSize: CGFloat
|
2026-09-12 21:54:23 +03:00
|
|
|
@Environment(\.dynamicTypeSize) private var textSize
|
2026-09-10 09:11:55 +03:00
|
|
|
@State private var seekValue = 0.0
|
|
|
|
|
@State private var seeking = false
|
|
|
|
|
@State private var volumeValue = 0.0
|
|
|
|
|
@State private var changingVolume = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 20) {
|
|
|
|
|
Group {
|
|
|
|
|
if let artwork = store.artwork {
|
|
|
|
|
Image(uiImage: artwork).resizable().scaledToFit()
|
|
|
|
|
} else {
|
2026-09-12 21:54:23 +03:00
|
|
|
RoundedRectangle(cornerRadius: 28)
|
|
|
|
|
.fill(LinearGradient(colors: [.indigo.opacity(0.85), .teal.opacity(0.75)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
|
|
|
.overlay { Image(systemName: "waveform.path").font(.system(size: 72, weight: .light)).foregroundStyle(.white.opacity(0.9)) }
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
2026-09-13 10:56:43 +03:00
|
|
|
}.frame(width: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize,
|
|
|
|
|
height: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize)
|
|
|
|
|
.clipShape(RoundedRectangle(cornerRadius: 28))
|
2026-09-10 09:11:55 +03:00
|
|
|
.accessibilityLabel("Обложка трека")
|
2026-09-12 21:41:37 +03:00
|
|
|
if let error = store.artworkError {
|
|
|
|
|
Text(error).font(.footnote).foregroundStyle(.secondary)
|
|
|
|
|
Button("Повторить загрузку обложки") { store.retryArtwork() }.disabled(!store.connected)
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
if let state = store.state {
|
|
|
|
|
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
|
|
|
|
|
.font(.title2.bold()).multilineTextAlignment(.center)
|
2026-09-13 10:56:43 +03:00
|
|
|
Text(state.artist).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
2026-09-10 09:11:55 +03:00
|
|
|
TimelineView(.periodic(from: .now, by: 0.25)) { timeline in
|
|
|
|
|
let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt))
|
|
|
|
|
VStack {
|
|
|
|
|
Slider(value: Binding(get: { seeking ? seekValue : position }, set: { seekValue = $0 }),
|
|
|
|
|
in: state.minSeekSeconds...max(state.minSeekSeconds + 0.001, state.maxSeekSeconds)) { editing in
|
|
|
|
|
if editing { seekValue = position }
|
|
|
|
|
seeking = editing
|
|
|
|
|
if !editing { Task { await store.send("seek", value: seekValue) } }
|
|
|
|
|
}.disabled(!state.capabilities.seek || state.maxSeekSeconds <= state.minSeekSeconds || store.busy)
|
|
|
|
|
.accessibilityLabel("Позиция трека")
|
|
|
|
|
HStack {
|
|
|
|
|
Text(time((seeking ? seekValue : position) - state.startSeconds))
|
|
|
|
|
Spacer()
|
|
|
|
|
Text(time(state.endSeconds - state.startSeconds))
|
|
|
|
|
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
HStack(spacing: 28) {
|
2026-09-10 09:11:55 +03:00
|
|
|
control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous)
|
|
|
|
|
let playing = state.playbackStatus == "Playing"
|
|
|
|
|
let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play")
|
|
|
|
|
control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести",
|
2026-09-12 21:54:23 +03:00
|
|
|
enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 78)
|
2026-09-10 09:11:55 +03:00
|
|
|
control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next)
|
|
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
VStack(spacing: 8) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text("Громкость ПК").font(.subheadline.weight(.semibold))
|
|
|
|
|
Spacer()
|
|
|
|
|
Text(state.muted == true ? "Без звука" : state.volume.map { "\(Int((changingVolume ? volumeValue : $0) * 100))%" } ?? "Недоступна")
|
|
|
|
|
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
HStack {
|
|
|
|
|
control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
|
|
|
|
label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22)
|
|
|
|
|
Slider(value: Binding(get: { changingVolume ? volumeValue : (state.volume ?? 0) }, set: { volumeValue = $0 }), in: 0...1) { editing in
|
|
|
|
|
if editing { volumeValue = state.volume ?? 0 }
|
|
|
|
|
changingVolume = editing
|
|
|
|
|
if !editing { Task { await store.send("volume", value: volumeValue) } }
|
|
|
|
|
}.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК")
|
|
|
|
|
}
|
2026-09-12 21:54:23 +03:00
|
|
|
}.bridgeCard()
|
2026-09-10 09:11:55 +03:00
|
|
|
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
|
|
|
|
|
Text($0).font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-09-12 21:54:23 +03:00
|
|
|
VStack(spacing: 8) {
|
|
|
|
|
Text(store.needsPairing ? "Подключите ПК заново" : "Ждём ваш компьютер").font(.title2.bold())
|
|
|
|
|
Text("Проверьте, что агент запущен и устройства находятся в одной сети.")
|
|
|
|
|
.font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
|
|
|
}
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
|
|
|
|
}.disabled(!store.connected)
|
2026-09-12 21:54:23 +03:00
|
|
|
.onChange(of: store.connected) { _, _ in seeking = false; changingVolume = false }
|
2026-09-10 09:11:55 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View {
|
|
|
|
|
Button { Task { await store.send(type) } } label: {
|
|
|
|
|
Image(systemName: icon).font(.system(size: size)).frame(minWidth: 44, minHeight: 44)
|
|
|
|
|
}.disabled(!enabled || store.busy).accessibilityLabel(label)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func time(_ seconds: Double) -> String {
|
|
|
|
|
let value = Int(min(max(0, seconds), 359999))
|
|
|
|
|
return String(format: "%d:%02d", value / 60, value % 60)
|
|
|
|
|
}
|
|
|
|
|
}
|