2 Commits 7b6b5fb5aa ... 6676676742

Autor SHA1 Mensagem Data
  bob.yuxinyang 6676676742 feat(audio/timeline): 优化多轨时间轴波形桶采样算法与网络层适配 há 1 mês atrás
  bob.yuxinyang 47faccff34 feat(live-activity): 支持锁屏/灵动岛 AppIntents 静默控制录音暂停与结束 há 1 mês atrás

+ 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()
+    }
+}

+ 1 - 1
CelestiaTrace/Services/Network/NetworkServiceProtocol.swift

@@ -4,7 +4,7 @@ protocol NetworkServiceProtocol {
     func fetchSessionIndex() async throws -> [RemoteSessionIndex]
     func fetchSession(sessionID: String) async throws -> RemoteSession
     func syncSession(
-        _ session: CelestiaSession,
+        _ session: SyncSessionSnapshot,
         deletedEventClientIDs: [String]
     ) async throws -> RemoteSession
     func deleteSession(sessionID: String) async throws

Diff do ficheiro suprimidas por serem muito extensas
+ 567 - 147
CelestiaTrace/Services/Network/RemoteNetworkService.swift


+ 3 - 2
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -172,8 +172,9 @@ final class RecordingViewModel {
 
     private func startSyncTimer() {
         syncTimer?.invalidate()
-        // ~60 Hz polling to bridge ObservableObject → @Observable
-        syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
+        // 20 Hz is sufficient for the amplitude display while avoiding a
+        // permanent 60 Hz main-run-loop wakeup during multi-hour recordings.
+        syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 20.0, repeats: true) { [weak self] _ in
             self?.syncState()
         }
         // Ensure timer fires during UI tracking (scrolling, etc.)

+ 73 - 18
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -543,32 +543,87 @@ private struct PlaybackWaveformCanvas: View, Equatable {
             )
 
             guard barCount > 0, !waveformSamples.isEmpty else { return }
-            for index in 0..<barCount {
-                let sampleStart = index * waveformSamples.count / barCount
-                let sampleEnd = max(
-                    sampleStart + 1,
-                    (index + 1) * waveformSamples.count / barCount
-                )
-                let upperBound = min(sampleEnd, waveformSamples.count)
-                guard sampleStart < upperBound else { continue }
-
-                let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
-                let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
-                let rect = CGRect(
-                    x: startX + CGFloat(index) * barSlot,
-                    y: centerY - barHeight / 2,
-                    width: barWidth,
-                    height: barHeight
+            let envelopes = WaveformMinMaxDownsampler.envelopes(
+                waveformSamples,
+                targetBucketCount: barCount
+            )
+            guard !envelopes.isEmpty else { return }
+            let bucketWidth = waveformWidth / CGFloat(envelopes.count)
+
+            for (index, envelope) in envelopes.enumerated() {
+                let maximumHeight = max(
+                    CGFloat(max(envelope.maximum, 0)) * size.height * 0.75,
+                    1
                 )
+                let x = startX + CGFloat(index) * bucketWidth
                 context.fill(
-                    Path(rect),
-                    with: .color(Color.primary.opacity(0.85))
+                    Path(CGRect(
+                        x: x,
+                        y: centerY - maximumHeight / 2,
+                        width: min(barWidth, bucketWidth),
+                        height: maximumHeight
+                    )),
+                    with: .color(Color.primary.opacity(0.58))
                 )
+
+                let minimumHeight = CGFloat(max(envelope.minimum, 0)) * size.height * 0.75
+                if minimumHeight >= 1 {
+                    context.fill(
+                        Path(CGRect(
+                            x: x,
+                            y: centerY - minimumHeight / 2,
+                            width: min(barWidth, bucketWidth),
+                            height: minimumHeight
+                        )),
+                        with: .color(Color.primary.opacity(0.9))
+                    )
+                }
             }
         }
     }
 }
 
+private struct WaveformEnvelope {
+    let minimum: Float
+    let maximum: Float
+}
+
+/// Reduces an arbitrarily long recording to at most one min/max envelope per
+/// drawable screen bucket. Peaks survive zooming and scrolling without
+/// creating one SwiftUI element (or one Canvas draw) per source sample.
+private enum WaveformMinMaxDownsampler {
+    static func envelopes(
+        _ samples: [Float],
+        targetBucketCount: Int
+    ) -> [WaveformEnvelope] {
+        guard !samples.isEmpty, targetBucketCount > 0 else { return [] }
+        let bucketCount = min(samples.count, targetBucketCount)
+        var result: [WaveformEnvelope] = []
+        result.reserveCapacity(bucketCount)
+
+        for bucket in 0..<bucketCount {
+            let lowerBound = bucket * samples.count / bucketCount
+            let upperBound = max(
+                lowerBound + 1,
+                (bucket + 1) * samples.count / bucketCount
+            )
+            var minimum = Float.greatestFiniteMagnitude
+            var maximum = -Float.greatestFiniteMagnitude
+            for sample in samples[lowerBound..<min(upperBound, samples.count)] {
+                minimum = min(minimum, sample)
+                maximum = max(maximum, sample)
+            }
+            result.append(
+                WaveformEnvelope(
+                    minimum: minimum.isFinite ? minimum : 0,
+                    maximum: maximum.isFinite ? maximum : 0
+                )
+            )
+        }
+        return result
+    }
+}
+
 // MARK: - Preview
 
 #Preview {

+ 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')

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff