Refresh native pairing and player UI and add the MusicBridge app icon

This commit is contained in:
2026-09-12 21:54:23 +03:00
parent bce04f5d96
commit cbd3c0f6f4
10 changed files with 216 additions and 42 deletions
+95 -29
View File
@@ -7,29 +7,47 @@ struct ContentView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash")
.font(.subheadline).foregroundStyle(.secondary)
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)
Text(store.connection?.endpoint.host ?? "")
.font(.caption).foregroundStyle(.secondary)
if !store.connected && !store.needsPairing {
Button("Подключиться снова") { store.resume() }
}
Button("Забыть компьютер", role: .destructive) { showForget = true }
.font(.footnote)
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(24)
}.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: {
@@ -39,6 +57,12 @@ struct ContentView: View {
}
}
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
@@ -47,22 +71,34 @@ private struct PairingView: View {
@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) {
Image(systemName: "desktopcomputer.and.arrow.down")
.font(.system(size: 48)).foregroundStyle(.indigo)
.frame(maxWidth: .infinity).accessibilityHidden(true)
Text("Музыка на ПК. Управление здесь.").font(.title2.bold())
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.")
.foregroundStyle(.secondary)
Button { showScanner = true } label: {
Label("Сканировать QR с ПК", systemImage: "qrcode.viewfinder")
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)
Text("QR заполнит адрес, отпечаток и код. Затем нажмите «Подключить компьютер».")
.font(.caption).foregroundStyle(.secondary)
DisclosureGroup("Найти ПК в сети") {
}.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
@@ -70,6 +106,7 @@ private struct PairingView: View {
endpoint = computer.endpoint
fingerprint = computer.fingerprint
code = ""
showDetails = true; prepared = false
store.error = nil
} label: {
VStack(alignment: .leading) {
@@ -85,15 +122,24 @@ private struct PairingView: View {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
}.padding(.top, 8)
}.disabled(store.pairing)
}.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()
}.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)) }
@@ -103,7 +149,8 @@ private struct PairingView: View {
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
}.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
DisclosureGroup("Импорт строки из QR") {
}
DisclosureGroup("Вставить приглашение") {
VStack(spacing: 12) {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
@@ -111,7 +158,10 @@ private struct PairingView: View {
importText(invitation)
}
}.padding(.top, 12)
}.disabled(store.pairing)
}.bridgeCard().disabled(store.pairing)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Готово") { editing = false } }
}
.sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) }
.onAppear { consumeIncoming() }
@@ -130,6 +180,7 @@ private struct PairingView: View {
endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256
code = parsed.code
prepared = true; showDetails = false; editing = false
store.error = nil
} catch { store.error = error.localizedDescription }
}
@@ -137,6 +188,7 @@ private struct PairingView: View {
private struct PlayerView: View {
@ObservedObject var store: PlayerStore
@Environment(\.dynamicTypeSize) private var textSize
@State private var seekValue = 0.0
@State private var seeking = false
@State private var volumeValue = 0.0
@@ -148,10 +200,11 @@ private struct PlayerView: View {
if let artwork = store.artwork {
Image(uiImage: artwork).resizable().scaledToFit()
} else {
RoundedRectangle(cornerRadius: 24).fill(.indigo.opacity(0.12))
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) }
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(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
}.frame(maxWidth: textSize.isAccessibilitySize ? 210 : 300).aspectRatio(1, contentMode: .fit).clipShape(RoundedRectangle(cornerRadius: 28))
.accessibilityLabel("Обложка трека")
if let error = store.artworkError {
Text(error).font(.footnote).foregroundStyle(.secondary)
@@ -178,14 +231,21 @@ private struct PlayerView: View {
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
HStack(spacing: 36) {
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: 64)
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)
@@ -195,13 +255,19 @@ private struct PlayerView: View {
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 {
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary)
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 {