feat: media player with reconciliation and pinned media proxy (Phase 5)

- MediaAPI client for all 10 media endpoints + Codable models
- PinnedMediaResourceLoader: AVAssetResourceLoaderDelegate proxying
  https media through the pinned-TLS session (AVPlayer ignores registered
  URLProtocol subclasses and can't trust the self-signed cert)
- MediaPlayerController: AVPlayer + 1.5s poll reconciliation ported from
  web app.js (track swap, late audio_url, desired state, seek_hint,
  reporting, enter-media-scope carry-over, post-talk resume)
- MediaPanelView UI + music.note toolbar entry
- VoiceSession wiring (enterMediaScope/endConversation)

Playback of the library's webm tracks is blocked by an iOS platform
limitation (AVPlayer cannot decode webm); TLS/Range proxy, reconciliation,
polling, reporting and UI are all verified working in the simulator.
This commit is contained in:
snowspeeder committed 2026-08-30 08:21:08 -04:00
1 parent f2022f8a96
commit 0af944e60d
8 files changed
+964 -5

No files matched your search

+6
View File
@@ -33,6 +33,7 @@ final class VoiceSession: ObservableObject {
let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler()
let stateModel = ConversationStateModel()
let mediaController: MediaPlayerController
private let config: ServerConfig
private let tokenClient: TokenClient
private let settings: SettingsStore
@@ -44,6 +45,9 @@ final class VoiceSession: ObservableObject {
self.config = config
self.tokenClient = tokenClient
self.settings = settings
let mediaAPI = MediaAPI(config: config)
self.mediaController = MediaPlayerController(api: mediaAPI, settings: settings)
mediaController.attach(session: self)
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
@@ -97,6 +101,7 @@ final class VoiceSession: ObservableObject {
do {
let token = try await tokenClient.fetchToken()
logger.log("token ok room=\(token.room, privacy: .public)")
await mediaController.enterMediaScope(roomName: token.room)
try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(())
} catch {
@@ -282,6 +287,7 @@ final class VoiceSession: ObservableObject {
reconnectAttempted = false
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await mediaController.endConversation()
await teardownRoom()
}