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:
1 parent
f2022f8a96
commit
0af944e60d
8 files changed
+964
-5
No files matched your search
@@ -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))"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user