- 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.
213 lines
7.5 KiB
Swift
213 lines
7.5 KiB
Swift
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))"
|
|
}
|
|
}
|