Compare commits

...

3 Commits

12 changed files with 268 additions and 61 deletions
+19 -1
View File
@@ -75,11 +75,29 @@ jobs:
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge
sleep 3 sleep 3
xcrun simctl io "$SIMULATOR_ID" screenshot build/pairing-screen.png xcrun simctl io "$SIMULATOR_ID" screenshot build/pairing-screen.png
xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player
sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-light-screen.png
xcrun simctl ui "$SIMULATOR_ID" appearance dark
sleep 1
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-dark-screen.png
xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-large-text
sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-large-text-screen.png
RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))')
COMPACT_ID=$(xcrun simctl create MusicBridge-Compact com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation "$RUNTIME")
xcrun simctl bootstatus "$COMPACT_ID" -b
xcrun simctl install "$COMPACT_ID" DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app
xcrun simctl launch "$COMPACT_ID" ru.yukinoki.musicbridge --preview-player
sleep 3
xcrun simctl io "$COMPACT_ID" screenshot build/player-compact-screen.png
- name: Save pairing screen - name: Save pairing screen
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ios-ui-preview name: ios-ui-preview
path: build/pairing-screen.png path: build/*-screen.png
retention-days: 7 retention-days: 7
- name: Save simulator application - name: Save simulator application
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+29 -7
View File
@@ -85,6 +85,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "9C432A0DA0BEC173839EBF86"; "fileRef" = "9C432A0DA0BEC173839EBF86";
}; };
"6B0F2027221CDD63E8FD4C59" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "PreviewSupport.swift";
"sourceTree" = "<group>";
};
"287044ADBD143F929035BC61" = {
"isa" = "PBXBuildFile";
"fileRef" = "6B0F2027221CDD63E8FD4C59";
};
"7148B52E915554F67E875C26" = { "7148B52E915554F67E875C26" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -99,7 +109,17 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridge"; "path" = "MusicBridge";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",); "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",);
};
"8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "folder.assetcatalog";
"path" = "Assets.xcassets";
"sourceTree" = "<group>";
};
"5522B3B3B2EACC1C8F9BEAAC" = {
"isa" = "PBXBuildFile";
"fileRef" = "8BF729FFE074CAEE622C0292";
}; };
"C9F705D432CA45B6056BC572" = { "C9F705D432CA45B6056BC572" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -177,7 +197,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",); "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"734C4650C7A5604092763C32" = { "734C4650C7A5604092763C32" = {
@@ -189,7 +209,7 @@
"69816CB820E8173702C54C37" = { "69816CB820E8173702C54C37" = {
"isa" = "PBXResourcesBuildPhase"; "isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = (); "files" = ("5522B3B3B2EACC1C8F9BEAAC",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"E3322BA79DBD9A84650B2CAE" = { "E3322BA79DBD9A84650B2CAE" = {
@@ -206,8 +226,9 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "4"; "CURRENT_PROJECT_VERSION" = "5";
"MARKETING_VERSION" = "0.3.0"; "MARKETING_VERSION" = "0.4.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
@@ -227,8 +248,9 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "4"; "CURRENT_PROJECT_VERSION" = "5";
"MARKETING_VERSION" = "0.3.0"; "MARKETING_VERSION" = "0.4.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1 @@
{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
@@ -0,0 +1 @@
{"info":{"author":"xcode","version":1}}
+101 -30
View File
@@ -6,30 +6,50 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
GeometryReader { viewport in
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 20) {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash") HStack(spacing: 8) {
.font(.subheadline).foregroundStyle(.secondary) 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 { if store.connection == nil {
PairingView(store: store, discovery: store.discovery) PairingView(store: store, discovery: store.discovery)
} else { } else {
PlayerView(store: store) PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30))))
Text(store.connection?.endpoint.host ?? "")
.font(.caption).foregroundStyle(.secondary)
if !store.connected && !store.needsPairing { if !store.connected && !store.needsPairing {
Button("Подключиться снова") { store.resume() } Button("Подключиться снова") { store.resume() }
} }
Button("Забыть компьютер", role: .destructive) { showForget = true } if store.needsPairing {
.font(.footnote) Button("Выполнить сопряжение заново") { showForget = true }
.buttonStyle(.borderedProminent).controlSize(.large)
}
} }
if let error = store.error { if let error = store.error {
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
Button("Скрыть сообщение") { store.error = nil }.font(.footnote) Button("Скрыть сообщение") { store.error = nil }.font(.footnote)
} }
}.padding(24) }.padding(20)
}
.scrollDismissesKeyboard(.interactively)
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("MusicBridge") .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) { .confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
Button("Забыть", role: .destructive) { store.forget() } Button("Забыть", role: .destructive) { store.forget() }
} message: { } message: {
@@ -39,6 +59,12 @@ struct ContentView: View {
} }
} }
private extension View {
func bridgeCard() -> some View {
padding(18).background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24))
}
}
private struct PairingView: View { private struct PairingView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@ObservedObject var discovery: DiscoveryStore @ObservedObject var discovery: DiscoveryStore
@@ -47,22 +73,34 @@ private struct PairingView: View {
@State private var code = "" @State private var code = ""
@State private var invitation = "" @State private var invitation = ""
@State private var showScanner = false @State private var showScanner = false
@State private var showDetails = false
@State private var prepared = false
@FocusState private var editing: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Image(systemName: "desktopcomputer.and.arrow.down") VStack(spacing: 14) {
.font(.system(size: 48)).foregroundStyle(.indigo) Image(systemName: "waveform.path")
.frame(maxWidth: .infinity).accessibilityHidden(true) .font(.system(size: 40, weight: .medium)).foregroundStyle(.white)
Text("Музыка на ПК. Управление здесь.").font(.title2.bold()) .frame(width: 84, height: 84)
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.") .background(LinearGradient(colors: [.indigo, .teal], startPoint: .topLeading, endPoint: .bottomTrailing), in: RoundedRectangle(cornerRadius: 25))
.foregroundStyle(.secondary) .accessibilityHidden(true)
Button { showScanner = true } label: { Text("Ваш ПК. Ваша музыка.").font(.title2.bold()).multilineTextAlignment(.center)
Label("Сканировать QR с ПК", systemImage: "qrcode.viewfinder") Text("Откройте QR в агенте на компьютере. iPhone и ПК должны быть в одной сети.")
.font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
Button { editing = false; showScanner = true } label: {
Label("Сканировать QR", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing) }.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
Text("QR заполнит адрес, отпечаток и код. Затем нажмите «Подключить компьютер».") }.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) .font(.caption).foregroundStyle(.secondary)
DisclosureGroup("Найти ПК в сети") { }
DisclosureGroup("Найти компьютер") {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(discovery.message).font(.footnote).foregroundStyle(.secondary) Text(discovery.message).font(.footnote).foregroundStyle(.secondary)
ForEach(discovery.computers) { computer in ForEach(discovery.computers) { computer in
@@ -70,6 +108,7 @@ private struct PairingView: View {
endpoint = computer.endpoint endpoint = computer.endpoint
fingerprint = computer.fingerprint fingerprint = computer.fingerprint
code = "" code = ""
showDetails = true; prepared = false
store.error = nil store.error = nil
} label: { } label: {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -85,15 +124,24 @@ private struct PairingView: View {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
} }
}.padding(.top, 8) }.padding(.top, 8)
}.disabled(store.pairing) }.bridgeCard().disabled(store.pairing)
DisclosureGroup(prepared ? "Детали подключения" : "Подключить вручную", isExpanded: $showDetails) {
VStack(alignment: .leading, spacing: 12) {
Group { Group {
Text("Адрес компьютера").font(.subheadline.weight(.medium))
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL) TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
Text("Отпечаток сертификата").font(.subheadline.weight(.medium))
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint, axis: .vertical) TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint, axis: .vertical)
.lineLimit(2...4).font(.system(.footnote, design: .monospaced)) .lineLimit(2...4).font(.system(.footnote, design: .monospaced))
Text("Код сопряжения").font(.subheadline.weight(.medium))
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad) TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled() }.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled().focused($editing)
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary) Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
}.padding(.top, 12)
}.bridgeCard().disabled(store.pairing)
if prepared || showDetails {
Button { Button {
editing = false
Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines), Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines),
fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines), fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines),
code: code.trimmingCharacters(in: .whitespacesAndNewlines)) } code: code.trimmingCharacters(in: .whitespacesAndNewlines)) }
@@ -103,7 +151,8 @@ private struct PairingView: View {
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер") Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
}.frame(maxWidth: .infinity) }.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing) }.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
DisclosureGroup("Импорт строки из QR") { }
DisclosureGroup("Вставить приглашение") {
VStack(spacing: 12) { VStack(spacing: 12) {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical) TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled() .lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
@@ -111,7 +160,10 @@ private struct PairingView: View {
importText(invitation) importText(invitation)
} }
}.padding(.top, 12) }.padding(.top, 12)
}.disabled(store.pairing) }.bridgeCard().disabled(store.pairing)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Готово") { editing = false } }
} }
.sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) } .sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) }
.onAppear { consumeIncoming() } .onAppear { consumeIncoming() }
@@ -130,6 +182,7 @@ private struct PairingView: View {
endpoint = parsed.endpoint endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256 fingerprint = parsed.certificateSha256
code = parsed.code code = parsed.code
prepared = true; showDetails = false; editing = false
store.error = nil store.error = nil
} catch { store.error = error.localizedDescription } } catch { store.error = error.localizedDescription }
} }
@@ -137,6 +190,8 @@ private struct PairingView: View {
private struct PlayerView: View { private struct PlayerView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
let coverSize: CGFloat
@Environment(\.dynamicTypeSize) private var textSize
@State private var seekValue = 0.0 @State private var seekValue = 0.0
@State private var seeking = false @State private var seeking = false
@State private var volumeValue = 0.0 @State private var volumeValue = 0.0
@@ -148,10 +203,13 @@ private struct PlayerView: View {
if let artwork = store.artwork { if let artwork = store.artwork {
Image(uiImage: artwork).resizable().scaledToFit() Image(uiImage: artwork).resizable().scaledToFit()
} else { } else {
RoundedRectangle(cornerRadius: 24).fill(.indigo.opacity(0.12)) RoundedRectangle(cornerRadius: 28)
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) } .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(width: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize,
height: textSize.isAccessibilitySize ? min(180, coverSize) : coverSize)
.clipShape(RoundedRectangle(cornerRadius: 28))
.accessibilityLabel("Обложка трека") .accessibilityLabel("Обложка трека")
if let error = store.artworkError { if let error = store.artworkError {
Text(error).font(.footnote).foregroundStyle(.secondary) Text(error).font(.footnote).foregroundStyle(.secondary)
@@ -160,7 +218,7 @@ private struct PlayerView: View {
if let state = store.state { if let state = store.state {
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК") Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
.font(.title2.bold()).multilineTextAlignment(.center) .font(.title2.bold()).multilineTextAlignment(.center)
Text(state.artist).foregroundStyle(.secondary) Text(state.artist).foregroundStyle(.secondary).multilineTextAlignment(.center)
TimelineView(.periodic(from: .now, by: 0.25)) { timeline in TimelineView(.periodic(from: .now, by: 0.25)) { timeline in
let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt)) let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt))
VStack { VStack {
@@ -178,14 +236,21 @@ private struct PlayerView: View {
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary) }.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
} }
} }
HStack(spacing: 36) { HStack(spacing: 28) {
control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous) control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous)
let playing = state.playbackStatus == "Playing" let playing = state.playbackStatus == "Playing"
let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play") let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play")
control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести", 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) 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 { HStack {
control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill", control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill",
label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22) label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22)
@@ -195,13 +260,19 @@ private struct PlayerView: View {
if !editing { Task { await store.send("volume", value: volumeValue) } } if !editing { Task { await store.send("volume", value: volumeValue) } }
}.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК") }.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК")
} }
}.bridgeCard()
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) { ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
Text($0).font(.caption).foregroundStyle(.secondary) Text($0).font(.caption).foregroundStyle(.secondary)
} }
} else { } else {
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary) VStack(spacing: 8) {
Text(store.needsPairing ? "Подключите ПК заново" : "Ждём ваш компьютер").font(.title2.bold())
Text("Проверьте, что агент запущен и устройства находятся в одной сети.")
.font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
} }
}.disabled(!store.connected) }.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 { private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View {
+4 -3
View File
@@ -2,15 +2,16 @@ import SwiftUI
@main @main
struct MusicBridgeApp: App { struct MusicBridgeApp: App {
@StateObject private var store = PlayerStore() @StateObject private var store = PreviewSupport.makeStore()
@Environment(\.scenePhase) private var phase @Environment(\.scenePhase) private var phase
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(store: store) ContentView(store: store)
.dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { store.importInvitation($0.absoluteString) } .onOpenURL { store.importInvitation($0.absoluteString) }
.task { store.setActive(phase == .active) } .task { if !PreviewSupport.enabled { store.setActive(phase == .active) } }
.onChange(of: phase) { _, value in store.setActive(value == .active) } .onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
} }
} }
} }
+37
View File
@@ -0,0 +1,37 @@
import SwiftUI
// Screenshots only: this fixture and its launch switches do not exist in Release.
@MainActor
enum PreviewSupport {
static var enabled: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--preview-player")
#else
false
#endif
}
static var largeText: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("--preview-large-text")
#else
false
#endif
}
static func makeStore() -> PlayerStore {
#if DEBUG
if enabled {
let store = PlayerStore(storage: ConnectionStorage(load: { nil }, save: { _ in }, delete: {}), discoveryEnabled: false)
store.connection = try? Connection(endpoint: "https://192.168.1.10:8765", fingerprint: String(repeating: "A", count: 64))
store.connected = true
store.status = "Предпросмотр интерфейса"
store.state = MediaState(protocolVersion: 1, hasSession: true, title: "Ночной город", artist: "MusicBridge · пример плеера", source: "",
playbackStatus: "Playing", positionSeconds: 42, startSeconds: 0, endSeconds: 214,
minSeekSeconds: 0, maxSeekSeconds: 214, playbackRate: 1,
capabilities: Capabilities(play: true, pause: true, toggle: true, next: true, previous: true, seek: true),
volume: 0.64, muted: false, mediaError: nil, volumeError: nil, artworkId: nil, artworkError: nil)
return store
}
#endif
return PlayerStore()
}
}
+28 -4
View File
@@ -1,6 +1,6 @@
# MusicBridge iOS — план # MusicBridge iOS — план
Обновлено: 2026-09-12. Пользователь разрешил начать нативное iOS-приложение. Обновлено: 2026-09-13. Пользователь разрешил начать нативное iOS-приложение.
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios. Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`. Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`.
@@ -41,7 +41,9 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] QR через камеру работает на физическом iPhone — подтверждено пользователем после установки 0.2.0. - [x] QR через камеру работает на физическом iPhone — подтверждено пользователем после установки 0.2.0.
- [ ] Отдельно проверить оба входа (встроенный сканер и стандартная камера), отказ в доступе к камере, Bonjour и смену IP на физическом iPhone. - [ ] Отдельно проверить оба входа (встроенный сканер и стандартная камера), отказ в доступе к камере, Bonjour и смену IP на физическом iPhone.
- [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор. - [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор.
- [ ] Иконка приложения, визуальная проверка разных размеров экрана и Dynamic Type. - [x] Иконка приложения и обновлённый интерфейс подключения/плеера.
- [x] Просмотрены снимки 0.4.0: подключение, светлая/тёмная темы, iPhone SE и крупный текст.
- [ ] Проверить новый интерфейс и иконку 0.4.0 на физическом iPhone.
- [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап. - [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
@@ -131,13 +133,35 @@ Bonjour, если доступны объявления того же агент
1. **Устойчивость и ошибки — версия 0.3.0 (build 4).** Исправления и автоматические 1. **Устойчивость и ошибки — версия 0.3.0 (build 4).** Исправления и автоматические
проверки завершены. Следующий ручной шаг — проверка на iPhone. проверки завершены. Следующий ручной шаг — проверка на iPhone.
2. **Интерфейс.** Иконка, размеры/крупный текст, удобство плеера и состояния пустого 2. **Интерфейс — версия 0.4.0 (build 5).** Добавлены иконка, компактный экран QR,
экрана. Сохранять рабочие сценарии QR и ручного подключения. раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Сценарии QR и ручного подключения сохранены; нужна проверка на телефоне.
3. **Системные интеграции.** Сначала исследовать актуальные ограничения Live Activities, 3. **Системные интеграции.** Сначала исследовать актуальные ограничения Live Activities,
Dynamic Island, App Intents и локальной связи в фоне; затем реализовать доступные Dynamic Island, App Intents и локальной связи в фоне; затем реализовать доступные
сценарии. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке. сценарии. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один. 4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Изменения второго этапа:
- Итоговый `aa0b0f0`: [Actions 34746477145](https://github.com/polskikh13/musicbridgeios/actions/runs/34746477145)
— 23 XCTest без отказов и успешный Release archive. Итоговая IPA скачана в
`build/iPhone-0.4.0/MusicBridge-iPhone.ipa`; проверены ZIP, iPhoneOS ARM64,
версия 0.4.0 (5), AppIcon в Info.plist и скомпилированный каталог Assets.car.
Снимки находятся в `build/iPhone-0.4.0/final-preview`. На маленьком экране
основные кнопки видны сразу; для нижней части громкости может требоваться прокрутка.
- `cbd3c0f`: добавлены оригинальная иконка 1024×1024 и каталог AppIcon,
карточка QR, раскрываемые ручные поля, меню ПК, крупная play/pause и процент громкости.
- `aa0b0f0`: после просмотра iPhone SE уменьшена обложка по доступной высоте окна;
размеры заданы явно, чтобы заглушка оставалась квадратной. Исполнитель выровнен по центру.
- В Debug есть изолированный предпросмотр с примером трека и памятью вместо Keychain;
в Release этот режим недоступен. CI сохраняет светлый/тёмный плеер, крупный текст,
компактный экран и подключение. Ошибка Keychain -34018 на снимке подключения
относится к неподписанному запуску в CI; доступ к Keychain на телефоне требует подписи.
- Ручная проверка обновления: установить IPA через iloader, проверить иконку,
сохранённое сопряжение, QR, меню ПК, перемотку и громкость; повторить с крупным
системным шрифтом и тёмной темой. Реальную камеру и домашнюю сеть CI не проверяет.
Изменения первого этапа: Изменения первого этапа:
- Итоговый `bde6f7a`: [Actions 34712051233](https://github.com/polskikh13/musicbridgeios/actions/runs/34712051233) - Итоговый `bde6f7a`: [Actions 34712051233](https://github.com/polskikh13/musicbridgeios/actions/runs/34712051233)
+17 -13
View File
@@ -3,10 +3,11 @@
Нативный пульт на SwiftUI для MusicBridge.Agent на Windows. Звук играет на ПК. Нативный пульт на SwiftUI для MusicBridge.Agent на Windows. Звук играет на ПК.
Минимальная версия iOS — 17. Зависимостей Swift Package/CocoaPods нет. Минимальная версия iOS — 17. Зависимостей Swift Package/CocoaPods нет.
## Первый вариант ## Возможности
- Сопряжение по HTTPS-адресу домашнего ПК, SHA-256 сертификата и коду. - Сопряжение по HTTPS-адресу домашнего ПК, SHA-256 сертификата и коду.
- Импорт строки `musicbridge://pair?data=…` из QR агента; камера пока не реализована. - Сканирование QR агента камерой, открытие `musicbridge://pair?data=…` и ручной ввод.
- Поиск компьютера через Bonjour и восстановление адреса доверенного ПК.
- Проверка закреплённого DER-сертификата до отправки кода и при следующих соединениях. - Проверка закреплённого DER-сертификата до отправки кода и при следующих соединениях.
- Хранение адреса, отпечатка и токена в Keychain только на этом устройстве. - Хранение адреса, отпечатка и токена в Keychain только на этом устройстве.
- Название, исполнитель, обложка, позиция, play/pause, следующий/предыдущий трек, - Название, исполнитель, обложка, позиция, play/pause, следующий/предыдущий трек,
@@ -14,11 +15,10 @@
- WSS-состояние, переподключение с задержкой, остановка сети в фоне и восстановление - WSS-состояние, переподключение с задержкой, остановка сети в фоне и восстановление
при возврате. Команды автоматически не повторяются. при возврате. Команды автоматически не повторяются.
**Статус:** первый вариант собирается в GitHub Actions на macOS, Xcode 16.4, **Статус:** пользователь подтвердил установку через iloader, рабочий пилот с
Swift 6.1.2. Семь XCTest прошли на симуляторе iOS 18.5; проверка на физическом Windows-агентом и подключение по QR. В 0.4.0 добавлены иконка и обновлённый
iPhone и соединение с Windows-агентом ещё не выполнены. интерфейс. Автоматические проверки включают 23 XCTest и Release archive;
[Успешный запуск](https://github.com/polskikh13/musicbridgeios/actions/runs/34444563869) полная проверка устойчивости на физических устройствах остаётся в `PLAN.md`.
проверяет исходники коммита `a761431`.
## Сборка на Mac ## Сборка на Mac
@@ -53,12 +53,12 @@ Apple ID и сертификаты для этой проверки не тре
## Подключение через QR и поиск ПК ## Подключение через QR и поиск ПК
В версии 0.2.0 откройте окно сопряжения в Windows-агенте (`--tray --lan`), Откройте окно сопряжения в Windows-агенте (`--tray --lan`),
на iPhone нажмите «Сканировать QR с ПК» и разрешите камеру. После сканирования на iPhone нажмите «Сканировать QR» и разрешите камеру. После сканирования
нажмите «Подключить компьютер». Ссылки `musicbridge://` также открываются нажмите «Подключить компьютер». Ссылки `musicbridge://` также открываются
из стандартной камеры. Приглашение должно быть свежим. из стандартной камеры. Приглашение должно быть свежим.
В разделе «Найти ПК в сети» можно выбрать найденный адрес. Сверьте **весь** В разделе «Найти компьютер» можно выбрать найденный адрес. Сверьте **весь**
предложенный отпечаток с экраном ПК и введите код. Если поиск не работает, предложенный отпечаток с экраном ПК и введите код. Если поиск не работает,
проверьте доступ к локальной сети в настройках MusicBridge; ручной ввод остаётся. проверьте доступ к локальной сети в настройках MusicBridge; ручной ввод остаётся.
При смене адреса уже доверенного ПК приложение пробует объявления с тем же При смене адреса уже доверенного ПК приложение пробует объявления с тем же
@@ -67,11 +67,15 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly ## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки Пользователь также подтвердил установку через iloader. Для повторной установки
с исправлениями устойчивости используйте версию **0.3.0 (build 4)**: с новым интерфейсом используйте версию **0.4.0 (build 5)**:
`build/iPhone-0.3.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и `build/iPhone-0.4.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия. подпишите тем же Apple Account, которым установлена первая версия.
В 0.3.0 обложка повторно загружается после временного сбоя, а после четырёх В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
play/pause, процент громкости и меню компьютера. Размер обложки зависит от
доступной высоты окна; при крупном системном шрифте экран прокручивается.
Начиная с 0.3.0 обложка повторно загружается после временного сбоя, а после четырёх
неудачных попыток доступен ручной повтор. Отозванный доступ требует нового неудачных попыток доступен ручной повтор. Отозванный доступ требует нового
сопряжения; автоматические попытки прекращаются. Если свернуть приложение во сопряжения; автоматические попытки прекращаются. Если свернуть приложение во
время сопряжения, повторите его со свежим QR (удалив созданную запись на ПК, время сопряжения, повторите его со свежим QR (удалив созданную запись на ПК,
+25
View File
@@ -0,0 +1,25 @@
# Original vector artwork, rendered to the opaque 1024px asset required by Xcode.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$iconFolder = Join-Path $PSScriptRoot '..\MusicBridge\Assets.xcassets\AppIcon.appiconset'
[System.IO.Directory]::CreateDirectory($iconFolder) | Out-Null
$bitmap = [System.Drawing.Bitmap]::new(1024, 1024, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$background = [System.Drawing.Drawing2D.LinearGradientBrush]::new(
[System.Drawing.Point]::new(0, 0), [System.Drawing.Point]::new(1024, 1024),
[System.Drawing.Color]::FromArgb(59, 43, 164), [System.Drawing.Color]::FromArgb(23, 145, 158))
$graphics.FillRectangle($background, 0, 0, 1024, 1024)
$ink = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
$heights = @(176, 316, 472, 316, 176)
for ($index = 0; $index -lt 5; $index++) {
$barX = 204 + $index * 132
$barHeight = $heights[$index]
$barY = 512 - $barHeight / 2
$graphics.FillRectangle($ink, $barX, $barY + 42, 88, $barHeight - 84)
$graphics.FillEllipse($ink, $barX, $barY, 88, 88)
$graphics.FillEllipse($ink, $barX, $barY + $barHeight - 88, 88, 88)
}
$bitmap.Save((Join-Path $iconFolder 'AppIcon.png'), [System.Drawing.Imaging.ImageFormat]::Png)
$ink.Dispose(); $background.Dispose(); $graphics.Dispose(); $bitmap.Dispose()
Write-Output 'Generated original MusicBridge icon: 1024x1024 RGB PNG.'
+5 -2
View File
@@ -35,6 +35,9 @@ def source_group(folder):
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge") app_group, app_sources = source_group("MusicBridge")
assets = add("assets", "PBXFileReference", lastKnownFileType="folder.assetcatalog", path="Assets.xcassets", sourceTree="<group>")
objects[app_group]["children"].append(assets)
assets_build = add("assets-build", "PBXBuildFile", fileRef=assets)
test_group, test_sources = source_group("MusicBridgeTests") test_group, test_sources = source_group("MusicBridgeTests")
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>") fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
objects[test_group]["children"].append(fixture) objects[test_group]["children"].append(fixture)
@@ -50,7 +53,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
products.append(product) products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0), add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build, tls_fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)] add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".tests" if is_test else ""), settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos", SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
@@ -61,7 +64,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge") proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
else: else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="4", MARKETING_VERSION="0.3.0") settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="5", MARKETING_VERSION="0.4.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps)) buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))