Compare commits
3 Commits
bce04f5d96
...
2ca6201ad6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca6201ad6 | |||
| aa0b0f0d8e | |||
| cbd3c0f6f4 |
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
+102
-31
@@ -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()
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
if prepared {
|
||||||
DisclosureGroup("Найти ПК в сети") {
|
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) {
|
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 {
|
||||||
|
|||||||
@@ -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) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (удалив созданную запись на ПК,
|
||||||
|
|||||||
@@ -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.'
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user