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

@@ -172,7 +172,15 @@ struct ConversationView: View {
.padding()
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
MediaPanelView(playerController: session.mediaController, settings: settings)
} label: {
Image(systemName: "music.note")
}
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
+212
View File
@@ -0,0 +1,212 @@
import SwiftUI
struct MediaPanelView: View {
@ObservedObject var playerController: MediaPlayerController
@ObservedObject var settings: SettingsStore
var body: some View {
ScrollView {
VStack(spacing: 20) {
nowPlayingCard
transportControls
seekSection
volumeSection
pendingSection
}
.padding()
}
.navigationTitle("Media")
}
private var nowPlayingCard: some View {
HStack(spacing: 12) {
if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGray6))
}
}
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.frame(width: 72, height: 72)
.overlay(
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
.lineLimit(2)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
if playerController.track != nil {
Text(statusText)
.font(.caption)
.foregroundStyle(playerController.desired == "playing" ? Color.green : Color.secondary)
}
}
Spacer()
}
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
private var transportControls: some View {
HStack(spacing: 24) {
Button(action: seekBackward) {
Image(systemName: "gobackward.10")
.font(.title3)
}
.disabled(playerController.track == nil)
Button(action: togglePlayPause) {
Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 44))
.foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor)
}
.disabled(playerController.track == nil)
Button(action: seekForward) {
Image(systemName: "goforward.10")
.font(.title3)
}
.disabled(playerController.track == nil)
Button(role: .destructive, action: stopPlayback) {
Image(systemName: "stop.circle")
.font(.title2)
}
.disabled(playerController.track == nil)
}
}
private var seekSection: some View {
VStack(spacing: 4) {
if playerController.track != nil {
Slider(
value: Binding(
get: { playerController.currentTime },
set: { newValue in Task { playerController.seek(to: newValue) } }
),
in: 0...max(playerController.duration, 1)
)
HStack {
Text(fmtTime(playerController.currentTime))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(fmtTime(playerController.duration))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
} else {
Text("Nothing loaded")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
}
}
private var volumeSection: some View {
HStack(spacing: 12) {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(value: $settings.volume, in: 0...1)
.onChange(of: settings.volume) { _, newValue in
playerController.setVolume(newValue)
}
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var pendingSection: some View {
if !playerController.pendingJobs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(playerController.pendingJobs, id: \.jobID) { job in
HStack {
Text(job.stage == "queued" ? "Queued…" : "Downloading…")
.font(.subheadline)
Spacer()
Text("\(Int((job.progress ?? 0)))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
}
private var title: String {
playerController.track?.title ?? "Nothing loaded"
}
private var subtitle: String {
guard let track = playerController.track else { return "" }
if track.status == "failed" {
return "Download failed — pick another result or try again."
}
if track.status == "downloading" {
return track.channel.map { "\($0) · downloading" } ?? "Downloading…"
}
return track.channel ?? (track.kind == "stream" ? "Live stream" : "")
}
private var statusText: String {
switch playerController.desired {
case "playing": return "Playing"
case "paused": return "Paused"
default: return "Stopped"
}
}
private func absolute(_ path: String) -> String {
if path.hasPrefix("http") { return path }
return (path.hasPrefix("/") ? path : "/" + path)
}
private func togglePlayPause() {
Task { await playerController.togglePlayPause() }
}
private func seekBackward() {
Task { await playerController.seek(by: -10) }
}
private func seekForward() {
Task { await playerController.seek(by: 10) }
}
private func stopPlayback() {
Task { await playerController.stop() }
}
private func fmtTime(_ sec: Double) -> String {
guard sec.isFinite, sec >= 0 else { return "0:00" }
let total = Int(sec)
let s = total % 60
let m = (total / 60) % 60
let h = total / 3600
return h > 0 ? "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" : "\(m):\(String(format: "%02d", s))"
}
}