import SwiftUI struct ContentView: View { @ObservedObject var store: PlayerStore @ObservedObject var liveActivity: LiveActivityManager @State private var showForget = false var body: some View { NavigationStack { GeometryReader { viewport in ScrollView { 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) if store.connection == nil { PairingView(store: store, discovery: store.discovery) } else { PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) VStack(alignment: .leading, spacing: 10) { Button { if liveActivity.running { liveActivity.end() } else if let state = store.state { liveActivity.start(state) } } label: { Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle") }.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true)) Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.") .font(.caption).foregroundStyle(.secondary) if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) } }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() if !store.connected && !store.needsPairing { Button("Подключиться снова") { store.resume() } } if store.needsPairing { Button("Выполнить сопряжение заново") { showForget = true } .buttonStyle(.borderedProminent).controlSize(.large) } } if let error = store.error { Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) Button("Скрыть сообщение") { store.error = nil }.font(.footnote) } }.padding(20) } .scrollDismissesKeyboard(.interactively) } .background(Color(.systemGroupedBackground)) .navigationTitle("MusicBridge") .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("Настройки компьютера") } } } .confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) { Button("Забыть", role: .destructive) { store.forget() } } message: { Text("Токен будет удалён с iPhone. Чтобы отозвать доступ на ПК, удалите устройство в окне MusicBridge.") } }.tint(.indigo) } } private extension View { func bridgeCard() -> some View { padding(18).background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24)) } } private struct PairingView: View { @ObservedObject var store: PlayerStore @ObservedObject var discovery: DiscoveryStore @State private var endpoint = "https://" @State private var fingerprint = "" @State private var code = "" @State private var invitation = "" @State private var showScanner = false @State private var showDetails = false @State private var prepared = false @FocusState private var editing: Bool var body: some View { VStack(alignment: .leading, spacing: 18) { 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") .frame(maxWidth: .infinity) }.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing) }.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("Найти компьютер") { 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 = "" showDetails = true; prepared = false 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) }.bridgeCard().disabled(store.pairing) DisclosureGroup(prepared ? "Детали подключения" : "Подключить вручную", isExpanded: $showDetails) { VStack(alignment: .leading, spacing: 12) { Group { Text("Адрес компьютера").font(.subheadline.weight(.medium)) TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL) Text("Отпечаток сертификата").font(.subheadline.weight(.medium)) TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint, axis: .vertical) .lineLimit(2...4).font(.system(.footnote, design: .monospaced)) Text("Код сопряжения").font(.subheadline.weight(.medium)) TextField("Восьмизначный код", text: $code).keyboardType(.numberPad) }.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled().focused($editing) Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary) }.padding(.top, 12) }.bridgeCard().disabled(store.pairing) if prepared || showDetails { Button { editing = false 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) } DisclosureGroup("Вставить приглашение") { VStack(spacing: 12) { TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical) .lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled() Button("Заполнить из приглашения") { importText(invitation) } }.padding(.top, 12) }.bridgeCard().disabled(store.pairing) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Готово") { editing = false } } } .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 prepared = true; showDetails = false; editing = false store.error = nil } catch { store.error = error.localizedDescription } } } private struct PlayerView: View { @ObservedObject var store: PlayerStore let coverSize: CGFloat @Environment(\.dynamicTypeSize) private var textSize @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 { 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)) } } }.frame(width: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize, height: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize) .clipShape(RoundedRectangle(cornerRadius: 28)) .accessibilityLabel("Обложка трека") if let error = store.artworkError { Text(error).font(.footnote).foregroundStyle(.secondary) Button("Повторить загрузку обложки") { store.retryArtwork() }.disabled(!store.connected) } if let state = store.state { Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК") .font(.title2.bold()).multilineTextAlignment(.center) Text(state.artist).foregroundStyle(.secondary).multilineTextAlignment(.center) 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) } } HStack(spacing: 28) { 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 ? "Пауза" : "Воспроизвести", enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 78) control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next) } 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) } 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("Системная громкость ПК") } }.bridgeCard() ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) } } else { VStack(spacing: 8) { Text(store.needsPairing ? "Подключите ПК заново" : "Ждём ваш компьютер").font(.title2.bold()) Text("Проверьте, что агент запущен и устройства находятся в одной сети.") .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center) } } }.disabled(!store.connected) .onChange(of: store.connected) { _, _ in seeking = false; changingVolume = false } } 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) } }