| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- 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
- // 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()
- }
- init(recorder: any AudioRecorderProtocol) {
- self.recorder = recorder
- }
- 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()
- isRecording = true
- startSyncTimer()
- }
- /// Stops recording and cleans up the sync timer.
- func stopRecording() {
- recorder.stopRecording()
- isRecording = false
- stopSyncTimer()
- syncState() // Final sync
- }
- /// 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
- }
- }
|