import SwiftUI import SwiftData import Observation // MARK: - RecordingViewModel /// Manages recording state and bridges the ObservableObject-based AudioRecorder /// into the iOS 17 Observation framework via Timer-based polling. @Observable final class RecordingViewModel { // MARK: - Published State private(set) var isRecording: Bool = false private(set) var initialDuration: TimeInterval = 0 private(set) var elapsedTime: TimeInterval = 0 private(set) var currentAmplitude: Float = 0 private(set) var waveformSamples: [Float] = [] private(set) var isPaused: Bool = false private(set) var sourceDisplayName: String private(set) var statusMessage: String = "正在准备" private(set) var errorMessage: String? // MARK: - Computed Properties /// Formats elapsed time as `HH:MM:SS`. var elapsedTimeFormatted: String { let totalSeconds = Int(elapsedTime) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } /// Local URL where the audio file is stored. var outputFileURL: URL? { recorder.outputFileURL } // MARK: - Private /// Concrete recorder instance conforming to AudioRecorderProtocol. private let recorder: any AudioRecorderProtocol /// Timer that syncs recorder state → ViewModel state at ~60 Hz. private var syncTimer: Timer? // MARK: - Init init() { self.recorder = RealAudioRecorder() self.sourceDisplayName = "iPhone 麦克风" } init(source: RecordingSourceChoice) { switch source { case .iPhone: self.recorder = RealAudioRecorder() case .spark(let deviceID, let displayName): self.recorder = SparkAudioRecorder(deviceID: deviceID, displayName: displayName) } self.sourceDisplayName = source.displayName } init(recorder: any AudioRecorderProtocol) { self.recorder = recorder self.sourceDisplayName = recorder.sourceDisplayName } deinit { syncTimer?.invalidate() } // MARK: - Recording Controls /// Starts recording and begins polling the recorder for state updates. /// - Parameter initialDuration: Prior recorded duration in seconds when continuing a session. func startRecording(initialDuration: TimeInterval = 0) { self.initialDuration = initialDuration self.elapsedTime = initialDuration recorder.startRecording() syncState() startSyncTimer() } /// Stops recording and cleans up the sync timer. func stopRecording() { recorder.stopRecording() isRecording = false stopSyncTimer() syncState() // Final sync } /// Stops the active source and reports whether the source confirmed that it /// is safe to finalize the session. Spark uses this to await PQ_DEV&STO. func stopRecording(completion: @escaping (Result) -> Void) { recorder.stopRecording { [weak self] result in guard let self else { return } self.isRecording = false self.stopSyncTimer() self.syncState() completion(result) } } /// Pauses the current recording session. func pauseRecording() { recorder.pauseRecording() syncState() } /// Resumes a paused recording session. func resumeRecording() { recorder.resumeRecording() syncState() } // MARK: - Timeline Event Creation /// Creates a PHOTO timeline event attached to the given session. /// - Parameter session: The active `CelestiaSession` to attach the event to. /// - Returns: The newly created `CelestiaTimelineEvent`. @discardableResult func addPhotoEvent(to session: CelestiaSession, localFilePath: String? = nil) -> CelestiaTimelineEvent { let relativeMs = Int64(elapsedTime * 1000) let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "PHOTO") event.localFilePath = localFilePath session.events.append(event) return event } /// Creates a NOTE timeline event attached to the given session. /// - Parameters: /// - session: The active `CelestiaSession` to attach the event to. /// - text: The note text content. /// - Returns: The newly created `CelestiaTimelineEvent`. @discardableResult func addNoteEvent(to session: CelestiaSession, text: String) -> CelestiaTimelineEvent { let relativeMs = Int64(elapsedTime * 1000) let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "NOTE") event.textContent = text session.events.append(event) return event } /// Creates a MARKER timeline event attached to the given session. /// - Parameters: /// - session: The active `CelestiaSession` to attach the event to. /// - text: The marker label text. /// - Returns: The newly created `CelestiaTimelineEvent`. @discardableResult func addMarkerEvent(to session: CelestiaSession, text: String) -> CelestiaTimelineEvent { let relativeMs = Int64(elapsedTime * 1000) let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "MARKER") event.textContent = text session.events.append(event) return event } // MARK: - Private Sync Timer 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 self?.syncState() } // Ensure timer fires during UI tracking (scrolling, etc.) if let timer = syncTimer { RunLoop.main.add(timer, forMode: .common) } } private func stopSyncTimer() { syncTimer?.invalidate() syncTimer = nil } /// Pulls current values from the ObservableObject recorder into @Observable properties. private func syncState() { isRecording = recorder.isRecording elapsedTime = initialDuration + recorder.elapsedTime currentAmplitude = recorder.currentAmplitude waveformSamples = recorder.waveformSamples isPaused = recorder.isPaused sourceDisplayName = recorder.sourceDisplayName statusMessage = recorder.statusMessage errorMessage = recorder.errorMessage } }