import SwiftUI import Observation import AVFoundation private final class WeakPlaybackViewModelReference: @unchecked Sendable { weak var value: PlaybackViewModel? init(_ value: PlaybackViewModel) { self.value = value } } // MARK: - PlaybackViewModel /// Manages audio playback state, timeline scrubbing, and event filtering /// for session detail/review screens using asynchronously prepared AVPlayer media. @Observable final class PlaybackViewModel: NSObject { // 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? /// Whether the local media asset is still being prepared. private(set) var isPreparingPlayback: Bool = false /// Whether the player is ready to accept playback and seek commands. private(set) var isPlaybackReady: Bool = false /// Whether a slider or timeline playhead drag is active. private(set) var isScrubbing: Bool = false /// Total session duration in milliseconds. var totalDurationMs: Double = 0 // MARK: - Silence Skip State /// Whether auto-skip silence is enabled var isSilenceSkipEnabled: Bool = true /// Whether background silence analysis is active var isAnalyzingSilence: Bool = false /// Detected silent intervals var silentRanges: [SilenceRange] = [] /// Amplitude levels extracted from the playback file on the live-recording scale. var waveformSamples: [Float] = [] /// Cheap invalidation token for the otherwise static waveform drawing layer. private(set) var waveformRevision: Int = 0 /// Whether playback waveform and silence analysis is active. var isAnalyzingAudio: Bool = false // MARK: - Audio Player Private State private var player: AVPlayer? private var playerItem: AVPlayerItem? private var activeAudioURL: URL? private var playerPreparationTask: Task? private var audioAnalysisTask: Task? private var audioAnalysisSession: AudioAnalysisSession? private var playbackTimeObserver: Any? private var playbackEndObserver: NSObjectProtocol? private var shouldPlayWhenReady = false private var shouldResumeAfterScrubbing = false private var isSilenceSkipSeekInFlight = false // 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 // MARK: - Init / Deinit override init() { super.init() } deinit { playerPreparationTask?.cancel() audioAnalysisTask?.cancel() audioAnalysisSession?.cancel() removePlayerObservers() player?.pause() } // MARK: - Player Preparation /// Prepares the audio player for the given local recording file. func preparePlayer(path: String?, durationMs: Double = 0) { playerPreparationTask?.cancel() removePlayerObservers() player?.pause() player = nil playerItem = nil activeAudioURL = nil playbackError = nil isPlaying = false isPlaybackReady = false isPreparingPlayback = false shouldPlayWhenReady = false currentPlaybackTimeMs = 0 if durationMs > 0 { totalDurationMs = durationMs } guard let targetURL = AudioPathHelper.resolveURL(for: path) else { print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")") playbackError = "未找到本地录音文件,请先从云端同步或重新录制" return } self.activeAudioURL = targetURL isPreparingPlayback = true // AVURLAsset creation is lightweight. Properties that may touch a long // AAC file are loaded asynchronously so navigation never waits for them. playerPreparationTask = Task { @MainActor [weak self] in let asset = AVURLAsset(url: targetURL) do { let duration = try await asset.load(.duration) let isPlayable = try await asset.load(.isPlayable) try Task.checkCancellation() guard let self else { return } guard self.activeAudioURL == targetURL else { return } guard isPlayable else { self.isPreparingPlayback = false self.playbackError = "录音文件无法播放" return } let item = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: item) player.automaticallyWaitsToMinimizeStalling = true self.playerItem = item self.player = player self.installPlayerObservers(player: player, item: item) let seconds = CMTimeGetSeconds(duration) if seconds.isFinite && seconds > 0 { self.totalDurationMs = seconds * 1_000 } self.isPreparingPlayback = false self.isPlaybackReady = true self.performSeek(to: self.currentPlaybackTimeMs) { [weak self] _ in guard let self, self.shouldPlayWhenReady else { return } self.startPreparedPlayer() } } catch is CancellationError { return } catch { guard let self, self.activeAudioURL == targetURL else { return } print("[PlaybackViewModel] Failed to prepare AVPlayer: \(error.localizedDescription)") self.isPreparingPlayback = false self.playbackError = "录音文件无法播放:\(error.localizedDescription)" } } } private func setupAudioSession() { let session = AVAudioSession.sharedInstance() do { // `.playback` already routes through the speaker by default. // `.defaultToSpeaker` is valid only with `.playAndRecord`. try session.setCategory(.playback, mode: .default) 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 } shouldPlayWhenReady = true guard isPlaybackReady, player != nil else { if isPreparingPlayback { return } playbackError = "当前记录没有可播放的本地录音文件" return } setupAudioSession() startPreparedPlayer() } /// Pauses playback at the current position. func pause() { shouldPlayWhenReady = false player?.pause() isPlaying = false } /// 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 audioAnalysisSession?.prioritize(time: target / 1_000) performSeek(to: target) } /// Begins an interactive seek. Playback pauses once and no decoder seeks /// occur until the gesture ends. func beginScrubbing() { guard !isScrubbing else { return } isScrubbing = true shouldResumeAfterScrubbing = isPlaying || shouldPlayWhenReady pause() } /// Updates only the visual position while an interactive seek is active. func previewScrub(to timeMs: Double) { let target = min(max(timeMs, 0), totalDurationMs) currentPlaybackTimeMs = target audioAnalysisSession?.prioritize(time: target / 1_000) } /// Commits one accurate player seek, then restores playback only when it /// was active before the drag began. func endScrubbing(at timeMs: Double) { let target = min(max(timeMs, 0), totalDurationMs) currentPlaybackTimeMs = target isScrubbing = false let shouldResume = shouldResumeAfterScrubbing shouldResumeAfterScrubbing = false performSeek(to: target) { [weak self] finished in guard let self, shouldResume else { return } if finished || self.isPreparingPlayback { self.play() } } } /// 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: - Private Player Coordination & Audio Analysis private func startPreparedPlayer() { guard let player else { return } shouldPlayWhenReady = true player.play() isPlaying = true } private func performSeek( to timeMs: Double, completion: ((Bool) -> Void)? = nil ) { guard let player, isPlaybackReady else { completion?(false) return } let time = CMTime(seconds: timeMs / 1_000, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in DispatchQueue.main.async { completion?(finished) } } } private func installPlayerObservers(player: AVPlayer, item: AVPlayerItem) { playbackTimeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.1, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self, !self.isScrubbing else { return } let seconds = CMTimeGetSeconds(time) guard seconds.isFinite else { return } self.currentPlaybackTimeMs = min(max(seconds * 1_000, 0), self.totalDurationMs) self.checkForSilenceAndSkip() } playbackEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in guard let self else { return } self.isPlaying = false self.shouldPlayWhenReady = false self.currentPlaybackTimeMs = self.totalDurationMs } } private func removePlayerObservers() { if let playbackTimeObserver, let player { player.removeTimeObserver(playbackTimeObserver) } playbackTimeObserver = nil if let playbackEndObserver { NotificationCenter.default.removeObserver(playbackEndObserver) } playbackEndObserver = nil } /// Runs silence detection asynchronously. func analyzeSilence(audioURL: URL?) { analyzeAudio(audioURL: audioURL) } /// Extracts the real waveform and silence ranges in one background pass. func analyzeAudio(audioURL: URL?) { audioAnalysisSession?.cancel() audioAnalysisTask?.cancel() let targetPath = audioURL?.path ?? activeAudioURL?.path guard let url = AudioPathHelper.resolveURL(for: targetPath) else { waveformSamples = [] silentRanges = [] waveformRevision &+= 1 isAnalyzingSilence = false isAnalyzingAudio = false return } waveformSamples = [] silentRanges = [] waveformRevision &+= 1 isAnalyzingSilence = true isAnalyzingAudio = true let session = AudioAnalysisSession(audioURL: url) audioAnalysisSession = session session.prioritize(time: currentPlaybackTimeMs / 1_000) let reference = WeakPlaybackViewModelReference(self) audioAnalysisTask = Task { await session.run { update in await MainActor.run { guard let owner = reference.value, owner.audioAnalysisSession === session else { return } owner.applyAnalysisUpdate(update) } } await MainActor.run { guard let owner = reference.value, owner.audioAnalysisSession === session else { return } if !Task.isCancelled { owner.isAnalyzingSilence = false owner.isAnalyzingAudio = false } } } } func cancelAudioAnalysis() { audioAnalysisSession?.cancel() audioAnalysisSession = nil audioAnalysisTask?.cancel() audioAnalysisTask = nil isAnalyzingSilence = false isAnalyzingAudio = false } private func applyAnalysisUpdate(_ update: AudioAnalysisProgress) { if waveformSamples.count != update.totalSampleCount { waveformSamples = Array(repeating: 0, count: update.totalSampleCount) } let lowerBound = min(max(update.sampleOffset, 0), waveformSamples.count) let upperBound = min(lowerBound + update.waveformSamples.count, waveformSamples.count) if lowerBound < upperBound { waveformSamples.replaceSubrange( lowerBound..= range.start && currentTimeSec < range.end { let targetMs = range.end * 1000.0 isSilenceSkipSeekInFlight = true currentPlaybackTimeMs = targetMs performSeek(to: targetMs) { [weak self] _ in self?.isSilenceSkipSeekInFlight = false } break } } } // 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 } } }