import AVFoundation import IndieSync import SwiftUI /// Plays back a voice note's audio. Two sources: a fresh recording still on disk /// (`localURL`, used by the composer) or a saved note whose bytes come back /// through `SyncEngine.loadAudioData` (fail-fast, so an evicted file shows a /// "downloading" retry state instead of stalling). `AVAudioPlayer` is not /// Sendable, so the playback model stays main-actor confined. struct AudioPlayerView: View { enum Source: Equatable { case localURL(URL) case note(id: ULID, duration: TimeInterval) } let source: Source @Environment(SyncEngine.self) private var syncEngine @State private var model = AudioPlaybackModel() var body: some View { Group { switch model.loadState { case .idle, .loading: HStack { ProgressView().controlSize(.small) Text("Loading audio…").foregroundStyle(.secondary) } .font(.callout) case .downloading: VStack(alignment: .leading, spacing: 6) { Label("Downloading from iCloud…", systemImage: "icloud.and.arrow.down") .foregroundStyle(.secondary) Button("Retry") { Task { await load() } } .buttonStyle(.bordered) .controlSize(.small) } .font(.callout) case .failed: Label("Couldn't play audio", systemImage: "exclamationmark.triangle") .foregroundStyle(.secondary) .font(.callout) case .ready: player } } .task { await load() } .onDisappear { model.stop() } } private var player: some View { HStack(spacing: 12) { Button { model.togglePlay() } label: { Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.largeTitle) .foregroundStyle(.tint) } .buttonStyle(.plain) VStack(spacing: 2) { Slider( value: Binding(get: { model.progress }, set: { model.seek(toFraction: $0) }), in: 0...1 ) HStack { Text(Self.timeString(model.currentTime)) Spacer() Text(Self.timeString(model.duration)) } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } } } private func load() async { switch source { case .localURL(let url): model.loadLocal(url) case .note(let id, let duration): await model.loadNote(id: id, fallbackDuration: duration, syncEngine: syncEngine) } } static func timeString(_ interval: TimeInterval) -> String { let total = Int(interval.rounded()) return String(format: "%d:%02d", total / 60, total % 60) } } /// Owns the `AVAudioPlayer` and a lightweight progress ticker. Main-actor /// confined because `AVAudioPlayer` isn't Sendable. @Observable @MainActor final class AudioPlaybackModel { enum LoadState: Equatable { case idle, loading, ready, downloading, failed } private(set) var loadState: LoadState = .idle private(set) var isPlaying = false /// 0…1 position, bound to the scrubber. private(set) var progress: Double = 0 private(set) var duration: TimeInterval = 0 var currentTime: TimeInterval { duration * progress } private var player: AVAudioPlayer? private var ticker: Task? func loadLocal(_ url: URL) { loadState = .loading do { let player = try AVAudioPlayer(contentsOf: url) player.prepareToPlay() adopt(player) } catch { loadState = .failed } } /// Fetch a saved note's audio (fail-fast). A miss means the file is evicted: /// show the downloading state and warm it up for the retry. func loadNote(id: ULID, fallbackDuration: TimeInterval, syncEngine: SyncEngine) async { loadState = .loading duration = fallbackDuration guard let data = await syncEngine.loadAudioData(for: id) else { loadState = .downloading await syncEngine.requestAudioDownload(for: id) return } do { let player = try AVAudioPlayer(data: data) player.prepareToPlay() adopt(player) } catch { loadState = .failed } } private func adopt(_ player: AVAudioPlayer) { self.player = player duration = player.duration progress = 0 loadState = .ready } func togglePlay() { guard let player else { return } if player.isPlaying { player.pause() isPlaying = false ticker?.cancel() } else { activatePlaybackSession() player.play() isPlaying = true startTicker() } } func seek(toFraction fraction: Double) { guard let player else { return } progress = min(max(fraction, 0), 1) player.currentTime = progress * duration } func stop() { player?.stop() isPlaying = false ticker?.cancel() ticker = nil #if os(iOS) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) #endif } private func startTicker() { ticker?.cancel() ticker = Task { [weak self] in while !Task.isCancelled { guard let self, let player = self.player else { return } if player.isPlaying { self.progress = self.duration > 0 ? player.currentTime / self.duration : 0 } else { // Reached the end (or was paused elsewhere): reset to the start. self.isPlaying = false self.progress = 0 return } try? await Task.sleep(for: .milliseconds(50)) } } } private func activatePlaybackSession() { #if os(iOS) let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .spokenAudio) try? session.setActive(true) #endif } }