From 1e02247cbe16cabcb2f8a798580c1d53f7c1272a Mon Sep 17 00:00:00 2001 From: ares Date: Sun, 13 Sep 2026 11:40:23 +0300 Subject: [PATCH] Control PC playback through interactive Live Activity intents --- MusicBridge.xcodeproj/project.pbxproj | 65 ++++++++++++++---- MusicBridge/BridgeClient.swift | 6 +- MusicBridge/ContentView.swift | 4 +- MusicBridge/KeychainStore.swift | 2 +- MusicBridge/LiveActivityCommands.swift | 68 +++++++++++++++++++ MusicBridge/LiveActivityManager.swift | 6 +- MusicBridge/PlayerStore.swift | 1 - .../LiveActivityCommandTests.swift | 54 +++++++++++++++ MusicBridgeWidgets/MusicBridgeWidgets.swift | 5 +- Shared/MusicActivityAttributes.swift | 1 + Shared/MusicActivityViews.swift | 32 +++++++-- Shared/MusicControlIntent.swift | 28 ++++++++ tools/generate_project.py | 6 +- tools/tls_fixture.py | 20 ++++++ 14 files changed, 266 insertions(+), 32 deletions(-) create mode 100644 MusicBridge/LiveActivityCommands.swift create mode 100644 MusicBridgeTests/LiveActivityCommandTests.swift create mode 100644 Shared/MusicControlIntent.swift diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index 40605f5..f85f16f 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -55,6 +55,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "76FB6BFFC478599567424755"; }; + "581AD2E9199DAED730DC0F6E" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "LiveActivityCommands.swift"; + "sourceTree" = ""; + }; + "D6D68067600EEBE36972550E" = { + "isa" = "PBXBuildFile"; + "fileRef" = "581AD2E9199DAED730DC0F6E"; + }; "EE2357BD5CA3755AFEB3171A" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -119,7 +129,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridge"; "sourceTree" = ""; - "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); + "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); }; "7C5E653CCD928B143AB68F09" = { "isa" = "PBXFileReference"; @@ -141,11 +151,21 @@ "isa" = "PBXBuildFile"; "fileRef" = "7C0FE298B442263CA281ABB7"; }; + "6EDC1D9A7204CF776CE3B281" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicControlIntent.swift"; + "sourceTree" = ""; + }; + "962BED0D8E17961AD72EC24F" = { + "isa" = "PBXBuildFile"; + "fileRef" = "6EDC1D9A7204CF776CE3B281"; + }; "E3C4B39D6D5013477C98CD58" = { "isa" = "PBXGroup"; "path" = "Shared"; "sourceTree" = ""; - "children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",); + "children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",); }; "ADFE24674E0FB119E649EC54" = { "isa" = "PBXFileReference"; @@ -171,6 +191,10 @@ "isa" = "PBXBuildFile"; "fileRef" = "7C0FE298B442263CA281ABB7"; }; + "41D7F12D1C7D61DAF319E724" = { + "isa" = "PBXBuildFile"; + "fileRef" = "6EDC1D9A7204CF776CE3B281"; + }; "8BF729FFE074CAEE622C0292" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "folder.assetcatalog"; @@ -191,6 +215,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "C9F705D432CA45B6056BC572"; }; + "65CE96DA4162DE4255412BC7" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "LiveActivityCommandTests.swift"; + "sourceTree" = ""; + }; + "E87BA6EF1948C84AC29D8EA8" = { + "isa" = "PBXBuildFile"; + "fileRef" = "65CE96DA4162DE4255412BC7"; + }; "9819FF7CC21CAAD1BDB8D1B8" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -235,7 +269,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); + "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -267,7 +301,7 @@ "72B74C8A2143F64FD53D71E5" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",); + "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",); "runOnlyForDeploymentPostprocessing" = 0; }; "734C4650C7A5604092763C32" = { @@ -324,8 +358,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "6"; - "MARKETING_VERSION" = "0.5.0"; + "CURRENT_PROJECT_VERSION" = "7"; + "MARKETING_VERSION" = "0.6.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; @@ -346,8 +380,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "6"; - "MARKETING_VERSION" = "0.5.0"; + "CURRENT_PROJECT_VERSION" = "7"; + "MARKETING_VERSION" = "0.6.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; }; @@ -379,7 +413,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); + "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { @@ -471,7 +505,7 @@ "FF6BDEA70DDE9A3C7EEA9DCF" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",); + "files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",); "runOnlyForDeploymentPostprocessing" = 0; }; "49C806A88B3E70B1004AB10D" = { @@ -500,14 +534,14 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; - "CURRENT_PROJECT_VERSION" = "6"; - "MARKETING_VERSION" = "0.5.0"; + "CURRENT_PROJECT_VERSION" = "7"; + "MARKETING_VERSION" = "0.6.0"; "APPLICATION_EXTENSION_API_ONLY" = "YES"; "SKIP_INSTALL" = "YES"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; - "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET"; }; }; "6A281BFCCFF96276806F5934" = { @@ -524,12 +558,13 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; - "CURRENT_PROJECT_VERSION" = "6"; - "MARKETING_VERSION" = "0.5.0"; + "CURRENT_PROJECT_VERSION" = "7"; + "MARKETING_VERSION" = "0.6.0"; "APPLICATION_EXTENSION_API_ONLY" = "YES"; "SKIP_INSTALL" = "YES"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET"; }; }; "5DCAC255CDC0CA72231320F4" = { diff --git a/MusicBridge/BridgeClient.swift b/MusicBridge/BridgeClient.swift index 175529d..475700f 100644 --- a/MusicBridge/BridgeClient.swift +++ b/MusicBridge/BridgeClient.swift @@ -59,11 +59,11 @@ final class BridgeClient { private let session: URLSession private var socket: URLSessionWebSocketTask? - init(_ connection: Connection) { + init(_ connection: Connection, timeout: TimeInterval = 10) { self.connection = connection let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 10 - config.timeoutIntervalForResource = 15 + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + 5 config.urlCache = nil config.httpCookieStorage = nil session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil) diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index f3aecb1..a9a0578 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -23,11 +23,11 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 10) { Button { if liveActivity.running { liveActivity.end() } - else if let state = store.state { liveActivity.start(state) } + else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) } } label: { Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle") }.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true)) - Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.") + Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.") .font(.caption).foregroundStyle(.secondary) if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) } }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() diff --git a/MusicBridge/KeychainStore.swift b/MusicBridge/KeychainStore.swift index 7bb0d7e..3c44a53 100644 --- a/MusicBridge/KeychainStore.swift +++ b/MusicBridge/KeychainStore.swift @@ -22,7 +22,7 @@ enum KeychainStore { static func save(_ connection: Connection) throws { let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection), - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] var status = SecItemUpdate(query as CFDictionary, values as CFDictionary) if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil) diff --git a/MusicBridge/LiveActivityCommands.swift b/MusicBridge/LiveActivityCommands.swift new file mode 100644 index 0000000..1689194 --- /dev/null +++ b/MusicBridge/LiveActivityCommands.swift @@ -0,0 +1,68 @@ +import ActivityKit +import Foundation + +@MainActor +enum LiveActivityCommands { + private static var busy = false + static let bindingKey = "MusicBridge.liveActivityPC" + + static func perform(_ action: MusicRemoteAction, activityID: String) async { + guard !busy, let activity = Activity.activities.first(where: { $0.id == activityID }), + activity.activityState == .active || activity.activityState == .stale else { return } + busy = true + defer { busy = false } + do { + guard let saved = try KeychainStore.load(), + UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else { + throw BridgeError.message("Откройте приложение и включите карточку заново.") + } + let client = BridgeClient(saved, timeout: 5) + defer { client.close() } + let fresh = try await execute(action, client: client) { + (activity.activityState == .active || activity.activityState == .stale) && + UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint + } + guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return } + await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15))) + } catch { + if case BridgeError.unauthorized = error { + await activity.end(nil, dismissalPolicy: .immediate) + return + } + var value = activity.content.state + value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error)) + await activity.update(ActivityContent(state: value, staleDate: Date())) + } + } + + // A fresh capability check, one command at most, then a fresh snapshot. No command retries. + static func execute(_ action: MusicRemoteAction, client: BridgeClient, + stillValid: () -> Bool = { true }) async throws -> MediaState { + let before = try await client.state() + guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") } + if action == .refresh { return before } + guard before.hasSession else { return before } + let command = try command(action, state: before) + do { try await client.command(Command(type: command, value: nil)) } + catch { + if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") } + throw error + } + do { return try await client.state() } + catch { + if case BridgeError.unauthorized = error { throw error } + throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.") + } + } + + static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String { + switch action { + case .previous where state.capabilities.previous: return "previous" + case .next where state.capabilities.next: return "next" + case .toggle where state.capabilities.toggle: return "toggle" + case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause" + case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play" + default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.") + } + } +} diff --git a/MusicBridge/LiveActivityManager.swift b/MusicBridge/LiveActivityManager.swift index 1a4d178..4acfe6a 100644 --- a/MusicBridge/LiveActivityManager.swift +++ b/MusicBridge/LiveActivityManager.swift @@ -32,18 +32,21 @@ final class LiveActivityManager: ObservableObject { duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true) } - func start(_ state: MediaState) { + func start(_ state: MediaState, connection: Connection) { guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { error = "Разрешите Live Activities в настройках MusicBridge на iPhone." return } do { + // Migrate existing pairing so an explicit lock-screen action can read it after first unlock. + try KeychainStore.save(connection) let value = Self.content(state) let new = try Activity.request(attributes: MusicActivityAttributes(), content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)), pushType: nil) activity = new + UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id) last = value running = true error = nil @@ -72,6 +75,7 @@ final class LiveActivityManager: ObservableObject { func end() { guard let activity else { return } + UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id) self.activity = nil running = false last = nil diff --git a/MusicBridge/PlayerStore.swift b/MusicBridge/PlayerStore.swift index 68247fb..a218705 100644 --- a/MusicBridge/PlayerStore.swift +++ b/MusicBridge/PlayerStore.swift @@ -257,7 +257,6 @@ final class PlayerStore: ObservableObject { } private func stop() { - liveActivity?.unavailable() generation = UUID() runner?.cancel() runner = nil diff --git a/MusicBridgeTests/LiveActivityCommandTests.swift b/MusicBridgeTests/LiveActivityCommandTests.swift new file mode 100644 index 0000000..5c90f39 --- /dev/null +++ b/MusicBridgeTests/LiveActivityCommandTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import MusicBridge + +@MainActor +final class LiveActivityCommandTests: XCTestCase { + private func client(_ token: String) throws -> BridgeClient { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json")) + let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url)) + let f = try XCTUnwrap(fixtures["valid"]) + return BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: token)) + } + + func testNextUsesOneCommandAndReturnsUpdatedTrack() async throws { + let client = try client("live-next"); defer { client.close() } + let result = try await LiveActivityCommands.execute(.next, client: client) + XCTAssertEqual(result.title, "Track 1") + } + + func testRefreshDoesNotSendCommand() async throws { + let client = try client("live-refresh"); defer { client.close() } + let result = try await LiveActivityCommands.execute(.refresh, client: client) + XCTAssertEqual(result.title, "Track 0") + } + + func testClosedActivityDoesNotSendCommand() async throws { + let client = try client("live-closed"); defer { client.close() } + do { _ = try await LiveActivityCommands.execute(.next, client: client, stillValid: { false }); XCTFail("Must reject") } + catch { } + let state = try await client.state() + XCTAssertEqual(state.title, "Track 0") + } + + func testUnsupportedCommandIsNotSent() async throws { + let client = try client("live-unsupported"); defer { client.close() } + do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") } + catch { } + let state = try await client.state() + XCTAssertEqual(state.title, "Track 0") + } + + func testRejectedCommandIsNotRetried() async throws { + let client = try client("live-reject"); defer { client.close() } + do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") } + catch { } + let state = try await client.state() + XCTAssertEqual(state.title, "Track 1") + } + + func testFailedReadAfterSuccessReportsCommandCompleted() async throws { + let client = try client("live-refresh-fail"); defer { client.close() } + do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must report refresh failure") } + catch { XCTAssertTrue(error.localizedDescription.contains("Команда выполнена")) } + } +} diff --git a/MusicBridgeWidgets/MusicBridgeWidgets.swift b/MusicBridgeWidgets/MusicBridgeWidgets.swift index 7f0d05d..eb46fe2 100644 --- a/MusicBridgeWidgets/MusicBridgeWidgets.swift +++ b/MusicBridgeWidgets/MusicBridgeWidgets.swift @@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle { struct MusicBridgeLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: MusicActivityAttributes.self) { context in - MusicActivityCard(state: context.state, stale: context.isStale) + MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID) .activityBackgroundTint(Color(.secondarySystemBackground)) .activitySystemActionForegroundColor(.indigo) .widgetURL(URL(string: "musicbridge://player")) @@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget { TrackLabels(state: context.state) ProgressView(value: context.state.progress).tint(.cyan) ActivityStatus(state: context.state, stale: context.isStale) - Link("Открыть плеер", destination: URL(string: "musicbridge://player")!) - .font(.caption.weight(.semibold)).foregroundStyle(.cyan) + MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan) }.padding(.bottom, 4) } } compactLeading: { diff --git a/Shared/MusicActivityAttributes.swift b/Shared/MusicActivityAttributes.swift index 6726bc0..bbafbc2 100644 --- a/Shared/MusicActivityAttributes.swift +++ b/Shared/MusicActivityAttributes.swift @@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes { var duration: Double var updatedAt: Date var connected: Bool + var message: String? = nil // Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit. static func bounded(_ text: String) -> String { diff --git a/Shared/MusicActivityViews.swift b/Shared/MusicActivityViews.swift index df74f4e..08af0f8 100644 --- a/Shared/MusicActivityViews.swift +++ b/Shared/MusicActivityViews.swift @@ -1,8 +1,10 @@ import SwiftUI +import AppIntents struct MusicActivityCard: View { let state: MusicActivityAttributes.ContentState let stale: Bool + var activityID: String = "" var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 12) { @@ -14,7 +16,7 @@ struct MusicActivityCard: View { } ProgressView(value: state.progress).tint(.indigo) ActivityStatus(state: state, stale: stale) - Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold)) + MusicActivityControls(activityID: activityID) }.padding(16) } } @@ -34,13 +36,35 @@ struct ActivityStatus: View { let state: MusicActivityAttributes.ContentState let stale: Bool var body: some View { - if stale || !state.connected { - Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary) + if let message = state.message { + Text(message).font(.caption).lineLimit(2) + } else if !state.connected { + Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary) } else { HStack(spacing: 4) { - Text("Снимок с ПК в") + Text("Обновлено в") Text(state.updatedAt, style: .time) }.font(.caption).foregroundStyle(.secondary) } } } + +struct MusicActivityControls: View { + let activityID: String + var body: some View { + HStack { + control(.previous, icon: "backward.end.fill", label: "Предыдущий трек") + Spacer(minLength: 4) + control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза") + Spacer(minLength: 4) + control(.next, icon: "forward.end.fill", label: "Следующий трек") + Spacer(minLength: 4) + control(.refresh, icon: "arrow.clockwise", label: "Обновить трек") + } + } + private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View { + Button(intent: MusicControlIntent(action, activityID: activityID)) { + Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44) + }.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty) + } +} diff --git a/Shared/MusicControlIntent.swift b/Shared/MusicControlIntent.swift new file mode 100644 index 0000000..84a3cde --- /dev/null +++ b/Shared/MusicControlIntent.swift @@ -0,0 +1,28 @@ +import AppIntents + +enum MusicRemoteAction: String, AppEnum { + case previous, toggle, next, refresh + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера" + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить" + ] +} + +struct MusicControlIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Управлять музыкой на ПК" + static var openAppWhenRun: Bool = false + static var isDiscoverable: Bool = false + static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + @Parameter(title: "Команда") var action: MusicRemoteAction + @Parameter(title: "Активность") var activityID: String + + init() {} + init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID } + + func perform() async throws -> some IntentResult { + #if !MUSICBRIDGE_WIDGET + await LiveActivityCommands.perform(action, activityID: activityID) + #endif + return .result() + } +} diff --git a/tools/generate_project.py b/tools/generate_project.py index 34a521f..91643e9 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -22,6 +22,8 @@ def configurations(name, settings): values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O") if mode == "Debug": values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG") + if name == "MusicBridgeWidgets": + values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET" configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values)) return add(name + "configs", "XCConfigurationList", buildConfigurations=configs, defaultConfigurationIsVisible=0, defaultConfigurationName="Release") @@ -70,11 +72,11 @@ 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") deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] elif is_widget: - settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", + settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") else: - settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") + settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0)) diff --git a/tools/tls_fixture.py b/tools/tls_fixture.py index a4e3504..5e6050c 100644 --- a/tools/tls_fixture.py +++ b/tools/tls_fixture.py @@ -67,11 +67,25 @@ class Handler(http.server.BaseHTTPRequestHandler): def snapshot(self, token): result = dict(state) + if token.startswith("live-"): + with counts_lock: + commands = counts.get((token, "command"), 0) + result["title"] = "Track " + str(commands) + if token == "live-unsupported": + result["capabilities"] = {key: False for key in result["capabilities"]} result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None return result def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) + token = self.headers.get("Authorization", "").removeprefix("Bearer ") + if token.startswith("live-"): + count(token, "command") + if token == "live-reject": + self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"}) + else: + self.reply(200, {"success": True, "code": "ok", "message": "OK"}) + return if self.path == "/v1/pair": code = body.get("code") if code == "33333333": @@ -86,6 +100,12 @@ class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): token = self.headers.get("Authorization", "").removeprefix("Bearer ") number = count(token, self.path) + if token == "live-refresh-fail" and self.path == "/v1/state": + with counts_lock: + commands = counts.get((token, "command"), 0) + if commands: + self.reply(503, {}) + return if token == "revoked": self.reply(401, {}) return