Pārlūkot izejas kodu

feat(live-activity): 支持锁屏/灵动岛 AppIntents 静默控制录音暂停与结束

bob.yuxinyang 1 mēnesi atpakaļ
vecāks
revīzija
47faccff34

+ 6 - 0
CelestiaTrace.xcodeproj/project.pbxproj

@@ -22,6 +22,8 @@
 		403219D9D8C972EC2AA8F84E /* EditProfileModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA13A08133A50094CBFFDDF9 /* EditProfileModalView.swift */; };
 		44732C9690578EEFC6840D05 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CB67D1B2BDECAA772F3935 /* DeviceListView.swift */; };
 		46F4F91D30D4C26A44A4D570 /* RecordingLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A48C59DD78DE74AF832D246 /* RecordingLiveActivityManager.swift */; };
+		71AA11110000000000000001 /* RecordingLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA11110000000000000000 /* RecordingLiveActivityIntent.swift */; };
+		71AA11110000000000000002 /* RecordingLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA11110000000000000000 /* RecordingLiveActivityIntent.swift */; };
 		490825791613AC52D06ECFA7 /* CelestiaSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CE4996BF0CC501E3B7C567 /* CelestiaSession.swift */; };
 		4F4476444CB6838B2BE6EE87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71787388F9456A4784534BCC /* ContentView.swift */; };
 		503E109CBBC69E3015AE37A3 /* SilenceDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */; };
@@ -103,6 +105,7 @@
 		0EBB5FC2AC0B9D31C7A04715 /* SessionListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = "<group>"; };
 		1189E47F735DDB40D1E741E6 /* LiveWaveformView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveWaveformView.swift; sourceTree = "<group>"; };
 		1A48C59DD78DE74AF832D246 /* RecordingLiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingLiveActivityManager.swift; sourceTree = "<group>"; };
+		71AA11110000000000000000 /* RecordingLiveActivityIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingLiveActivityIntent.swift; sourceTree = "<group>"; };
 		1DC9982D9E53E1A77CDB0470 /* MultiTrackTimeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiTrackTimeline.swift; sourceTree = "<group>"; };
 		1EF9EF0F961DB2B546B9C0F1 /* BoundDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BoundDevice.swift; sourceTree = "<group>"; };
 		24923AF5D038F81EB3248A4C /* RemoteNetworkService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteNetworkService.swift; sourceTree = "<group>"; };
@@ -247,6 +250,7 @@
 			isa = PBXGroup;
 			children = (
 				08B11040B6E3800958078529 /* RecordingActivityAttributes.swift */,
+				71AA11110000000000000000 /* RecordingLiveActivityIntent.swift */,
 			);
 			name = LiveActivity;
 			path = LiveActivity;
@@ -643,6 +647,7 @@
 				8FBEE86CDB9635710EF85F1B /* BLEManager.swift in Sources */,
 				46F4F91D30D4C26A44A4D570 /* RecordingLiveActivityManager.swift in Sources */,
 				BCC851C8A3DF4F885F179FCE /* RecordingActivityAttributes.swift in Sources */,
+				71AA11110000000000000001 /* RecordingLiveActivityIntent.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -653,6 +658,7 @@
 				C7FCC7859292F4917426A553 /* CelestiaTraceLiveActivityBundle.swift in Sources */,
 				7892677460CB60EACD115895 /* RecordingLiveActivityWidget.swift in Sources */,
 				96D67D2F80255A2EAEE6ECEC /* RecordingActivityAttributes.swift in Sources */,
+				71AA11110000000000000002 /* RecordingLiveActivityIntent.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 73 - 0
CelestiaTrace/LiveActivity/RecordingLiveActivityIntent.swift

@@ -0,0 +1,73 @@
+import AppIntents
+import Foundation
+
+enum RecordingLiveActivityCommand: String, Sendable {
+    case togglePause
+    case stop
+}
+
+extension Notification.Name {
+    static let recordingLiveActivityCommand = Notification.Name(
+        "com.celestia.trace.recording-live-activity-command"
+    )
+}
+
+private enum RecordingLiveActivityIntentDispatcher {
+    @MainActor
+    static func post(_ command: RecordingLiveActivityCommand, sessionID: String) {
+        NotificationCenter.default.post(
+            name: .recordingLiveActivityCommand,
+            object: nil,
+            userInfo: [
+                "sessionID": sessionID,
+                "command": command.rawValue
+            ]
+        )
+    }
+}
+
+struct TogglePauseRecordingIntent: LiveActivityIntent {
+    static var title: LocalizedStringResource = "暂停或继续录音"
+    static var description = IntentDescription("在锁屏或灵动岛上静默切换当前录音的暂停状态。")
+    static var openAppWhenRun = false
+
+    @Parameter(title: "记录 ID")
+    var sessionID: String
+
+    init() {}
+
+    init(sessionID: UUID) {
+        self.sessionID = sessionID.uuidString
+    }
+
+    func perform() async throws -> some IntentResult {
+        await RecordingLiveActivityIntentDispatcher.post(
+            .togglePause,
+            sessionID: sessionID
+        )
+        return .result()
+    }
+}
+
+struct StopRecordingIntent: LiveActivityIntent {
+    static var title: LocalizedStringResource = "结束录音"
+    static var description = IntentDescription("在锁屏或灵动岛上静默结束当前录音。")
+    static var openAppWhenRun = false
+
+    @Parameter(title: "记录 ID")
+    var sessionID: String
+
+    init() {}
+
+    init(sessionID: UUID) {
+        self.sessionID = sessionID.uuidString
+    }
+
+    func perform() async throws -> some IntentResult {
+        await RecordingLiveActivityIntentDispatcher.post(
+            .stop,
+            sessionID: sessionID
+        )
+        return .result()
+    }
+}

+ 18 - 0
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -149,6 +149,9 @@ struct ActiveRecordingView: View {
         .onOpenURL { url in
             handleRecordingURL(url)
         }
+        .onReceive(NotificationCenter.default.publisher(for: .recordingLiveActivityCommand)) {
+            handleLiveActivityCommand($0)
+        }
         .onDisappear {
             UIApplication.shared.isIdleTimerDisabled = false
             commitRecordName()
@@ -770,6 +773,21 @@ struct ActiveRecordingView: View {
         perform(route.action)
     }
 
+    private func handleLiveActivityCommand(_ notification: Notification) {
+        guard let sessionID = notification.userInfo?["sessionID"] as? String,
+              UUID(uuidString: sessionID) == session.id,
+              let rawCommand = notification.userInfo?["command"] as? String,
+              let command = RecordingLiveActivityCommand(rawValue: rawCommand) else {
+            return
+        }
+        switch command {
+        case .togglePause:
+            perform(.togglePause)
+        case .stop:
+            perform(.stop)
+        }
+    }
+
     private func performPendingInitialActionIfReady() {
         guard recordingVM.isRecording, let action = pendingInitialAction else { return }
         pendingInitialAction = nil

+ 52 - 8
CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift

@@ -1,4 +1,5 @@
 import ActivityKit
+import AppIntents
 import SwiftUI
 import WidgetKit
 
@@ -31,15 +32,19 @@ struct RecordingLiveActivityWidget: Widget {
                             .frame(maxWidth: .infinity, alignment: .leading)
 
                         HStack(spacing: 8) {
-                            islandAction(
+                            islandIntentAction(
                                 label: context.state.isPaused ? "继续" : "暂停",
                                 systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
-                                url: actionURL("toggle-pause", context.attributes)
+                                intent: TogglePauseRecordingIntent(
+                                    sessionID: context.attributes.sessionID
+                                )
                             )
-                            islandAction(
+                            islandIntentAction(
                                 label: "结束",
                                 systemImage: "stop.fill",
-                                url: actionURL("stop", context.attributes),
+                                intent: StopRecordingIntent(
+                                    sessionID: context.attributes.sessionID
+                                ),
                                 tint: .red
                             )
                             islandAction(
@@ -101,15 +106,19 @@ struct RecordingLiveActivityWidget: Widget {
 
             VStack(spacing: 8) {
                 HStack(spacing: 10) {
-                    quickAction(
+                    quickIntentAction(
                         title: context.state.isPaused ? "继续" : "暂停",
                         systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
-                        url: actionURL("toggle-pause", context.attributes)
+                        intent: TogglePauseRecordingIntent(
+                            sessionID: context.attributes.sessionID
+                        )
                     )
-                    quickAction(
+                    quickIntentAction(
                         title: "结束",
                         systemImage: "stop.fill",
-                        url: actionURL("stop", context.attributes),
+                        intent: StopRecordingIntent(
+                            sessionID: context.attributes.sessionID
+                        ),
                         tint: .red
                     )
                 }
@@ -162,6 +171,23 @@ struct RecordingLiveActivityWidget: Widget {
         }
     }
 
+    private func quickIntentAction<Intent: AppIntent>(
+        title: String,
+        systemImage: String,
+        intent: Intent,
+        tint: Color = .white
+    ) -> some View {
+        Button(intent: intent) {
+            Label(title, systemImage: systemImage)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(tint)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 8)
+                .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
+        }
+        .buttonStyle(.plain)
+    }
+
     private func islandAction(
         label: String,
         systemImage: String,
@@ -179,6 +205,24 @@ struct RecordingLiveActivityWidget: Widget {
         }
     }
 
+    private func islandIntentAction<Intent: AppIntent>(
+        label: String,
+        systemImage: String,
+        intent: Intent,
+        tint: Color = .white
+    ) -> some View {
+        Button(intent: intent) {
+            Image(systemName: systemImage)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(tint)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 7)
+                .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
+                .accessibilityLabel(label)
+        }
+        .buttonStyle(.plain)
+    }
+
     private func recordingURL(_ attributes: RecordingActivityAttributes) -> URL {
         URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)")!
     }

+ 6 - 3
generate_project.rb

@@ -121,10 +121,13 @@ test_target.add_dependency(app_target)
 add_files_recursively('CelestiaTrace', app_group, app_target, project)
 add_files_recursively('Tests/CelestiaTraceTests', tests_group, test_target, project)
 
-shared_activity_ref = project.files.find do |file|
-  file.path == 'RecordingActivityAttributes.swift'
+shared_live_activity_refs = project.files.select do |file|
+  %w[
+    RecordingActivityAttributes.swift
+    RecordingLiveActivityIntent.swift
+  ].include?(file.path)
 end
-live_activity_target.add_file_references([shared_activity_ref]) if shared_activity_ref
+live_activity_target.add_file_references(shared_live_activity_refs)
 
 # Add the asset catalog (AppIcon and launch-screen color) to the app target.
 assets_ref = app_group.new_file('Resources/Assets.xcassets')