import SwiftUI import Observation import AVFoundation // MARK: - PlaybackViewModel /// Manages audio playback state, timeline scrubbing, and event filtering /// for session detail/review screens using AVAudioPlayer for real audio output. @Observable final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate { // MARK: - Playback State /// Current playback position in milliseconds. var currentPlaybackTimeMs: Double = 0 /// Whether audio is currently playing. private(set) var isPlaying: Bool = false /// A user-facing reason why the selected recording cannot be played. private(set) var playbackError: String? /// Total session duration in milliseconds. var totalDurationMs: Double = 0 // MARK: - Silence Skip State /// Whether auto-skip silence is enabled var isSilenceSkipEnabled: Bool = false /// Whether background silence analysis is active var isAnalyzingSilence: Bool = false /// Detected silent intervals var silentRanges: [SilenceRange] = [] // MARK: - Audio Player Private State private var audioPlayer: AVAudioPlayer? private var activeAudioURL: URL? // MARK: - Computed Properties /// Formats the current playback position as `MM:SS`. var currentTimeFormatted: String { let totalSeconds = Int(currentPlaybackTimeMs / 1000) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } /// Total duration formatted as `MM:SS`. var totalTimeFormatted: String { let totalSeconds = Int(totalDurationMs / 1000) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } /// Playback progress as a normalized value (0.0 – 1.0). var progress: Double { guard totalDurationMs > 0 else { return 0 } return min(max(currentPlaybackTimeMs / totalDurationMs, 0), 1) } // MARK: - Private /// Tick timer that advances playback position at ~50ms intervals. private var playbackTimer: Timer? /// Tick interval in seconds (≈20 ticks/sec for smooth scrubber movement). private let tickInterval: TimeInterval = 0.05 // MARK: - Init / Deinit override init() { super.init() } deinit { playbackTimer?.invalidate() audioPlayer?.stop() } // MARK: - Player Preparation /// Prepares the audio player for the given local recording file. func preparePlayer(path: String?, durationMs: Double = 0) { stopPlaybackTimer() audioPlayer?.stop() audioPlayer = nil activeAudioURL = nil playbackError = nil guard let targetURL = AudioPathHelper.resolveURL(for: path) else { print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")") playbackError = "未找到本地录音文件,请先从云端同步或重新录制" if durationMs > 0 { self.totalDurationMs = durationMs } return } self.activeAudioURL = targetURL setupAudioSession() do { let player = try AVAudioPlayer(contentsOf: targetURL) player.delegate = self player.prepareToPlay() player.volume = 1.0 self.audioPlayer = player if durationMs <= 0 { self.totalDurationMs = player.duration * 1000.0 } else { self.totalDurationMs = durationMs } } catch { print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)") playbackError = "录音文件无法播放:\(error.localizedDescription)" } } private func setupAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker]) try session.setActive(true) } catch { print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)") } } // MARK: - Playback Controls /// Starts or resumes playback from the current position. func play() { guard !isPlaying else { return } guard let player = audioPlayer else { playbackError = "当前记录没有可播放的本地录音文件" return } setupAudioSession() player.currentTime = currentPlaybackTimeMs / 1000.0 guard player.play() else { playbackError = "录音播放启动失败" return } isPlaying = true startPlaybackTimer() } /// Pauses playback at the current position. func pause() { guard isPlaying else { return } audioPlayer?.pause() isPlaying = false stopPlaybackTimer() } /// Toggles between playing and paused states. func togglePlayback() { if isPlaying { pause() } else { play() } } /// Seeks to a specific position in the timeline. /// - Parameter timeMs: The target position in milliseconds. func seekTo(timeMs: Double) { let target = min(max(timeMs, 0), totalDurationMs) currentPlaybackTimeMs = target if let player = audioPlayer { player.currentTime = target / 1000.0 } } /// Convenience overload accepting Int64. func seekTo(timeMs: Int64) { seekTo(timeMs: Double(timeMs)) } /// Seeks to a normalized progress value (0.0 – 1.0). /// - Parameter progress: Normalized position. func seekToProgress(_ progress: Double) { let clampedProgress = min(max(progress, 0), 1) seekTo(timeMs: clampedProgress * totalDurationMs) } /// Resets playback to the beginning. func reset() { pause() seekTo(timeMs: 0.0) } // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { isPlaying = false currentPlaybackTimeMs = totalDurationMs stopPlaybackTimer() } // MARK: - Private Timer & Silence Detection private func startPlaybackTimer() { playbackTimer?.invalidate() playbackTimer = Timer.scheduledTimer( withTimeInterval: tickInterval, repeats: true ) { [weak self] _ in guard let self else { return } self.tick() } if let timer = playbackTimer { RunLoop.main.add(timer, forMode: .common) } } private func stopPlaybackTimer() { playbackTimer?.invalidate() playbackTimer = nil } /// Runs silence detection asynchronously. func analyzeSilence(audioURL: URL?) { let targetPath = audioURL?.path ?? activeAudioURL?.path guard let url = AudioPathHelper.resolveURL(for: targetPath) else { silentRanges = [] isAnalyzingSilence = false return } isAnalyzingSilence = true Task { let ranges = await SilenceDetector.detectSilence(in: url) await MainActor.run { self.silentRanges = ranges self.isAnalyzingSilence = false } } } private func checkForSilenceAndSkip() { guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return } let currentTimeSec = currentPlaybackTimeMs / 1000.0 for range in silentRanges { if currentTimeSec >= range.start && currentTimeSec < range.end { let targetMs = range.end * 1000.0 seekTo(timeMs: targetMs) print("[PlaybackViewModel] Auto-skipped silence from \(range.start)s to \(range.end)s") break } } } /// Advances playback by one tick interval, syncing with audioPlayer if active. private func tick() { guard let player = audioPlayer, player.isPlaying else { isPlaying = false stopPlaybackTimer() return } currentPlaybackTimeMs = player.currentTime * 1000.0 checkForSilenceAndSkip() if currentPlaybackTimeMs >= totalDurationMs { currentPlaybackTimeMs = totalDurationMs pause() } } // MARK: - Event Queries func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] { events.sorted { $0.relativeTimeMs < $1.relativeTimeMs } } func sortedEvents(from session: CelestiaSession) -> [CelestiaTimelineEvent] { sortedEvents(from: session.events) } func filteredEvents( from events: [CelestiaTimelineEvent], type: String? ) -> [CelestiaTimelineEvent] { let filtered: [CelestiaTimelineEvent] if let type { filtered = events.filter { $0.eventType == type } } else { filtered = events } return filtered.sorted { $0.relativeTimeMs < $1.relativeTimeMs } } func nearestEvent( from events: [CelestiaTimelineEvent], toleranceMs: Double = 500 ) -> CelestiaTimelineEvent? { events .min(by: { abs(Double($0.relativeTimeMs) - currentPlaybackTimeMs) < abs(Double($1.relativeTimeMs) - currentPlaybackTimeMs) }) .flatMap { event in abs(Double(event.relativeTimeMs) - currentPlaybackTimeMs) <= toleranceMs ? event : nil } } }