import AVFoundation import Combine import Foundation import os private let logger = Logger(subsystem: "dev.shane.voice", category: "media") /// AVPlayer-backed media player. The media server holds per-room desired state; /// this controller polls /state and reconciles the local player against it, /// porting the web UI's reconciliation rules (app.js reconcileMedia). @MainActor final class MediaPlayerController: ObservableObject { @Published private(set) var track: MediaTrack? @Published private(set) var desired: String = "stopped" @Published private(set) var currentTime: Double = 0 @Published private(set) var duration: Double = 0 @Published private(set) var pendingJobs: [MediaPendingJob] = [] @Published private(set) var choices: [MediaChoice] = [] @Published private(set) var stateError: String? let player = AVPlayer() private let api: MediaAPI private let settings: SettingsStore private let mediaLoader: PinnedMediaResourceLoader private weak var session: VoiceSession? private var room: String? private var currentTrackID: String? private var loadedAudioPath: String? private var appliedSeekTs: Double = 0 private var wasPlayingBeforeTalk = false private var lastReportDate = Date.distantPast private var pollTask: Task? private var timeObserver: Any? private var statusCancellable: AnyCancellable? /// Bumped on every room-scope change so in-flight /state polls from the /// previous scope cannot reconcile into the new one (port of the web UI's /// `=== room` checks). private var pollGeneration = 0 init(api: MediaAPI, settings: SettingsStore) { self.api = api self.settings = settings self.mediaLoader = PinnedMediaResourceLoader(base: api.serverBase) player.volume = Float(settings.volume) NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: nil ) } deinit { if let timeObserver { player.removeTimeObserver(timeObserver) } NotificationCenter.default.removeObserver(self) } @objc private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo else { return } guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: player.pause() logger.log("audio interruption began, paused media") case .ended: applyVolume() if currentTrackID != nil, desired == "playing", loadedAudioPath != nil { player.play() logger.log("audio interruption ended, resumed media") } @unknown default: break } } // MARK: Lifecycle func attach(session: VoiceSession) { self.session = session observePlayer() applyVolume() } /// Called just before joining the LiveKit room. Carries whatever is loaded /// into the new room-scoped player state, paused (talking pauses music). func enterMediaScope(roomName: String) async { pollGeneration += 1 let wasPlaying = currentTrackID != nil && !player.rate.isZero wasPlayingBeforeTalk = wasPlaying let carriedTrack = currentTrackID room = roomName if let carriedTrack { do { try await api.play(room: roomName, trackID: carriedTrack, startPaused: true) logger.log("media scope carry-over: \(carriedTrack, privacy: .public) start_paused") } catch { logger.log("media scope transfer failed: \(String(describing: error), privacy: .public)") } player.pause() } startPolling() } /// Called when the conversation ends. If music was playing before the talk, /// hand the room back to the player. func endConversation() async { stopPolling() pollGeneration += 1 if wasPlayingBeforeTalk { wasPlayingBeforeTalk = false guard let room else { return } do { try await api.control(room: room, action: "resume") logger.log("resumed playback after talk ended") } catch { logger.log("post-talk resume failed (poll will retry): \(String(describing: error), privacy: .public)") } } } private func startPolling() { guard pollTask == nil else { return } pollTask = Task { [weak self] in while !Task.isCancelled { await self?.pollState() try? await Task.sleep(for: .milliseconds(1500)) } } } private func stopPolling() { pollTask?.cancel() pollTask = nil } /// Start polling the default room when no session is active. Called from /// the Media Panel so play/stop state is visible without a voice session. func startDefaultPolling() { guard room == nil else { return } startPolling() } // MARK: Poll + reconcile (ported from app.js pollMediaState/reconcileMedia) private func pollState() async { let generation = pollGeneration let scope = room ?? "default" do { let state = try await api.state(room: scope) guard generation == pollGeneration else { return } stateError = nil reconcile(state) } catch { guard generation == pollGeneration else { return } logger.log("poll failed: \(String(describing: error), privacy: .public)") stateError = "Media server unreachable: \(error.localizedDescription)" } } private func reconcile(_ s: MediaState) { choices = s.choices pendingJobs = s.pending let track = s.track if let track, track.id != currentTrackID { loadTrack(track) } else if let track, let audioURL = track.audioURL, loadedAudioPath != audioURL { // Track was loaded before it had a stream (still downloading) and // now has one — wire it up so playback can start without a reload. wireSource(audioURL) } if track == nil, currentTrackID != nil, s.desired == "stopped" { clearMedia() } applyDesiredState(s, track: track) if let hint = s.seekHint, hint.ts != appliedSeekTs, duration.isFinite, duration > 0 { appliedSeekTs = hint.ts let target = min(hint.position, duration) if target.isFinite { seek(to: max(0, target)) } } } private func applyDesiredState(_ s: MediaState, track: MediaTrack?) { if s.desired == "playing" { // audio_url presence means a playable stream exists — including a // still-growing download, which plays progressively. if let track, track.audioURL != nil, loadedAudioPath != nil, player.rate.isZero { player.play() } } else if !player.rate.isZero { player.pause() } } private func loadTrack(_ track: MediaTrack) { currentTrackID = track.id self.track = track if let audioURL = track.audioURL { wireSource(audioURL) } else { // Downloading or failed: no stream yet. Reconcile wires the stream // URL in via polling as soon as one is available. player.replaceCurrentItem(with: nil) } } private func wireSource(_ path: String) { guard let url = URL(string: path) else { return } loadedAudioPath = path let pinned = mediaLoader.rewrite(url) let asset = mediaLoader.assetWithPinnedURL(pinned) logger.log("wiring source \(pinned.absoluteString, privacy: .public)") let item = AVPlayerItem(asset: asset) observeItem(item) player.replaceCurrentItem(with: item) } // MARK: Player events private func observePlayer() { if timeObserver == nil { timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in Task { @MainActor [weak self] in guard let self else { return } self.currentTime = time.seconds if let itemDuration = self.player.currentItem?.duration { let seconds = itemDuration.seconds if seconds.isFinite { self.duration = seconds } } if !self.player.rate.isZero, Date().timeIntervalSince(self.lastReportDate) >= 5 { self.lastReportDate = Date() self.report("playing") } } } } } private func observeItem(_ item: AVPlayerItem?) { NotificationCenter.default.removeObserver( self, name: .AVPlayerItemDidPlayToEndTime, object: nil ) guard let item else { return } NotificationCenter.default.addObserver( self, selector: #selector(playerItemDidEnd(_:)), name: .AVPlayerItemDidPlayToEndTime, object: item ) statusCancellable = item.publisher(for: \.status).sink { status in switch status { case .readyToPlay: logger.log("item readyToPlay") case .failed: logger.log("item failed: \(String(describing: item.error), privacy: .public)") default: break } } } @objc private func playerItemDidEnd(_ notification: Notification) { report("stopped") publishMediaState("stopped") Task { [weak self] in guard let self, let room = self.room else { return } try? await self.api.control(room: room, action: "stop") } } // MARK: Reporting + publishing private func report(_ state: String) { guard let room else { return } let position = currentTime.isFinite ? currentTime : 0 var durationValue: Double? if duration.isFinite, duration > 0 { durationValue = duration } Task { [weak self] in guard let self else { return } do { try await self.api.report(room: room, state: state, trackID: self.currentTrackID, position: position, duration: durationValue) } catch { logger.log("report failed: \(String(describing: error), privacy: .public)") } } } private func publishMediaState(_ state: String) { guard let session else { return } Task { [weak self] in guard let self else { return } do { try await session.controlChannel.sendMediaState(state, trackID: self.currentTrackID, room: session.room) } catch { logger.log("media_state publish failed: \(String(describing: error), privacy: .public)") } } } // MARK: Play requests (search results, choices, library) func playVideo(videoID: String?, title: String?, channel: String?) async { let targetRoom = room ?? "default" do { try await api.play(room: targetRoom, videoID: videoID, title: title, channel: channel) logger.log("play requested video_id=\(videoID ?? "nil", privacy: .public) room=\(targetRoom, privacy: .public)") } catch { logger.log("play failed: \(String(describing: error), privacy: .public)") } } func playTrack(id: String) async { let targetRoom = room ?? "default" do { try await api.play(room: targetRoom, trackID: id) logger.log("play requested track_id=\(id, privacy: .public) room=\(targetRoom, privacy: .public)") } catch { logger.log("play failed: \(String(describing: error), privacy: .public)") } } // MARK: Transport controls (parity with web UI play/pause/seek/stop) func togglePlayPause() async { guard currentTrackID != nil, loadedAudioPath != nil else { return } if player.rate.isZero { player.play() if let room { try? await api.control(room: room, action: "resume") } } else { player.pause() if let room { try? await api.control(room: room, action: "pause") } } } func seek(by delta: Double) async { guard loadedAudioPath != nil else { return } let target = max(0, min(duration > 0 ? duration - 0.5 : .infinity, currentTime + delta)) if target.isFinite { seek(to: target) } report(player.rate.isZero ? "paused" : "playing") } func seek(to position: Double) { player.seek(to: CMTime(seconds: position, preferredTimescale: 600)) currentTime = position } func stop() async { clearMedia() if let room { try? await api.control(room: room, action: "stop") } publishMediaState("stopped") } private func clearMedia() { currentTrackID = nil track = nil loadedAudioPath = nil observeItem(nil) player.replaceCurrentItem(with: nil) currentTime = 0 duration = 0 stateError = nil } // MARK: Volume func applyVolume() { player.volume = Float(settings.volume) } func setVolume(_ volume: Double) { settings.volume = volume player.volume = Float(volume) } }