Add native SwiftUI player and macOS CI
iOS build and tests / simulator (push) Has been cancelled

This commit is contained in:
2026-09-10 09:11:55 +03:00
parent 6307070194
commit 6174be5610
20 changed files with 1729 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var store: PlayerStore
@State private var showForget = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash")
.font(.subheadline).foregroundStyle(.secondary)
if store.connection == nil {
PairingView(store: store)
} else {
PlayerView(store: store)
Text(store.connection?.endpoint.host ?? "")
.font(.caption).foregroundStyle(.secondary)
if !store.connected {
Button("Подключиться снова") { store.resume() }
}
Button("Забыть компьютер", role: .destructive) { showForget = true }
.font(.footnote)
}
if let error = store.error {
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
}
}.padding(24)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("MusicBridge")
.confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
Button("Забыть", role: .destructive) { store.forget() }
} message: {
Text("Токен будет удалён с iPhone. Чтобы отозвать доступ на ПК, удалите устройство в окне MusicBridge.")
}
}.tint(.indigo)
}
}
private struct PairingView: View {
@ObservedObject var store: PlayerStore
@State private var endpoint = "https://"
@State private var fingerprint = ""
@State private var code = ""
@State private var invitation = ""
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)
Group {
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint)
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled()
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
Button {
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("Импорт строки из QR") {
VStack(spacing: 12) {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Заполнить из приглашения") {
do {
let parsed = try Invitation.parse(invitation.trimmingCharacters(in: .whitespacesAndNewlines))
endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256
code = parsed.code
store.error = nil
} catch { store.error = error.localizedDescription }
}
}.padding(.top, 12)
}.disabled(store.pairing)
}
}
}
private struct PlayerView: View {
@ObservedObject var store: PlayerStore
@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: 24).fill(.indigo.opacity(0.12))
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) }
}
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
.accessibilityLabel("Обложка трека")
if let state = store.state {
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
.font(.title2.bold()).multilineTextAlignment(.center)
Text(state.artist).foregroundStyle(.secondary)
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: 36) {
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)
control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next)
}
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("Системная громкость ПК")
}
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
Text($0).font(.caption).foregroundStyle(.secondary)
}
} else {
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary)
}
}.disabled(!store.connected)
}
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)
}
}