feat: hardening, error states, interruption handling, a11y (Phase 8)

This commit is contained in:
snowspeeder committed 2026-08-30 09:37:05 -04:00
1 parent 9f1f26ba54
commit 12ded179eb
6 files changed
+233 -38

No files matched your search

+33 -4
View File
@@ -16,6 +16,7 @@ final class MediaPlayerController: ObservableObject {
@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()
@@ -34,6 +35,11 @@ final class MediaPlayerController: ObservableObject {
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
@@ -52,6 +58,25 @@ final class MediaPlayerController: ObservableObject {
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) {
@@ -63,6 +88,7 @@ final class MediaPlayerController: ObservableObject {
/// 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
@@ -83,6 +109,7 @@ final class MediaPlayerController: ObservableObject {
/// hand the room back to the player.
func endConversation() async {
stopPolling()
pollGeneration += 1
if wasPlayingBeforeTalk {
wasPlayingBeforeTalk = false
guard let room else { return }
@@ -120,12 +147,17 @@ final class MediaPlayerController: ObservableObject {
// 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)"
}
}
@@ -246,10 +278,6 @@ final class MediaPlayerController: ObservableObject {
}
}
@objc private func handleInterruption(_ notification: Notification) {
applyVolume()
}
// MARK: Reporting + publishing
private func report(_ state: String) {
@@ -344,6 +372,7 @@ final class MediaPlayerController: ObservableObject {
player.replaceCurrentItem(with: nil)
currentTime = 0
duration = 0
stateError = nil
}
// MARK: Volume