Archived
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,243 @@
|
||||
import Foundation
|
||||
|
||||
struct MediaTrack: Decodable, Equatable {
|
||||
let id: String
|
||||
let kind: String?
|
||||
let videoID: String?
|
||||
let title: String?
|
||||
let channel: String?
|
||||
let duration: Double?
|
||||
let status: String?
|
||||
let error: String?
|
||||
let audioURL: String?
|
||||
let artURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, kind, title, channel, duration, status, error
|
||||
case videoID = "video_id"
|
||||
case audioURL = "audio_url"
|
||||
case artURL = "art_url"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaPendingJob: Decodable, Equatable {
|
||||
let jobID: String?
|
||||
let trackID: String?
|
||||
let stage: String?
|
||||
let progress: Double?
|
||||
let error: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stage, progress, error
|
||||
case jobID = "job_id"
|
||||
case trackID = "track_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaSeekHint: Decodable, Equatable {
|
||||
let ts: Double
|
||||
let position: Double
|
||||
}
|
||||
|
||||
struct MediaState: Decodable, Equatable {
|
||||
let room: String?
|
||||
let track: MediaTrack?
|
||||
let desired: String?
|
||||
let reported: MediaReported?
|
||||
let seekHint: MediaSeekHint?
|
||||
let choices: [MediaChoice]
|
||||
let pending: [MediaPendingJob]
|
||||
let version: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case room, track, desired, reported, choices, pending, version
|
||||
case seekHint = "seek_hint"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaReported: Decodable, Equatable {
|
||||
let trackID: String?
|
||||
let state: String?
|
||||
let position: Double?
|
||||
let duration: Double?
|
||||
let ts: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case state, position, duration, ts
|
||||
case trackID = "track_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaChoice: Decodable, Equatable {
|
||||
let videoID: String?
|
||||
let title: String?
|
||||
let channel: String?
|
||||
let duration: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, channel, duration
|
||||
case videoID = "video_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibrary: Decodable, Equatable {
|
||||
let tracks: [MediaTrack]
|
||||
let version: Int?
|
||||
}
|
||||
|
||||
struct MediaSearchResult: Decodable, Equatable {
|
||||
let videoID: String?
|
||||
let title: String?
|
||||
let channel: String?
|
||||
let duration: Double?
|
||||
let inLibrary: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, channel, duration
|
||||
case videoID = "video_id"
|
||||
case inLibrary = "in_library"
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaSearchResponse: Decodable, Equatable {
|
||||
let results: [MediaSearchResult]
|
||||
}
|
||||
|
||||
enum MediaAPIError: LocalizedError {
|
||||
case invalidServerURL(String)
|
||||
case httpStatus(Int, String)
|
||||
case decodeFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServerURL(let raw):
|
||||
return "Invalid server URL: \(raw)"
|
||||
case .httpStatus(let code, let detail):
|
||||
return "Media API request failed with HTTP \(code). \(detail)"
|
||||
case .decodeFailed:
|
||||
return "Unexpected response from the media endpoint."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaAPI {
|
||||
let serverBase: URL
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
||||
init(config: ServerConfig) {
|
||||
self.serverBase = config.baseURL
|
||||
self.baseURL = config.baseURL.appendingPathComponent("media/api")
|
||||
self.session = TLSPinning.session()
|
||||
}
|
||||
|
||||
func state(room: String) async throws -> MediaState {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("state"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "room", value: room)]
|
||||
return try await get(MediaState.self, url: components.url!)
|
||||
}
|
||||
|
||||
func report(room: String, state: String, trackID: String?, position: Double, duration: Double?) async throws {
|
||||
var body: [String: Any] = ["room": room, "state": state, "position": position]
|
||||
if let trackID { body["track_id"] = trackID }
|
||||
if let duration { body["duration"] = duration }
|
||||
try await post("report", body: body)
|
||||
}
|
||||
|
||||
func play(room: String, videoID: String? = nil, title: String? = nil, channel: String? = nil, trackID: String? = nil, startPaused: Bool? = nil) async throws {
|
||||
var body: [String: Any] = ["room": room]
|
||||
if let videoID { body["video_id"] = videoID }
|
||||
if let title { body["title"] = title }
|
||||
if let channel { body["channel"] = channel }
|
||||
if let trackID { body["track_id"] = trackID }
|
||||
if let startPaused { body["start_paused"] = startPaused }
|
||||
try await post("play", body: body)
|
||||
}
|
||||
|
||||
func control(room: String, action: String) async throws {
|
||||
try await post("control", body: ["room": room, "action": action])
|
||||
}
|
||||
|
||||
func search(room: String, query: String, limit: Int = 6) async throws -> MediaSearchResponse {
|
||||
try await postDecoded("search", body: ["room": room, "query": query, "limit": limit], as: MediaSearchResponse.self)
|
||||
}
|
||||
|
||||
func library() async throws -> MediaLibrary {
|
||||
try await get(MediaLibrary.self, url: baseURL.appendingPathComponent("library"))
|
||||
}
|
||||
|
||||
func deleteTrack(id: String) async throws {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("tracks").appending(path: id))
|
||||
request.httpMethod = "DELETE"
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
func addStream(room: String, name: String, url: String) async throws {
|
||||
try await post("streams", body: ["room": room, "name": name, "url": url])
|
||||
}
|
||||
|
||||
func streamURL(trackID: String) async throws -> URL {
|
||||
let data = try await getRaw(url: baseURL.appendingPathComponent("stream").appending(path: trackID))
|
||||
guard let raw = String(data: data, encoding: .utf8), !raw.isEmpty else {
|
||||
throw MediaAPIError.decodeFailed
|
||||
}
|
||||
if let url = URL(string: raw) { return url }
|
||||
if raw.hasPrefix("/") {
|
||||
return serverBase.appendingPathComponent(String(raw.dropFirst()))
|
||||
}
|
||||
return serverBase.appendingPathComponent(raw)
|
||||
}
|
||||
|
||||
func healthz() async throws -> Bool {
|
||||
let data = try await getRaw(url: baseURL.appendingPathComponent("healthz"))
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
|
||||
return json["ok"] as? Bool ?? false
|
||||
}
|
||||
|
||||
private func get<T: Decodable>(_ type: T.Type, url: URL) async throws -> T {
|
||||
let data = try await getRaw(url: url)
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw MediaAPIError.decodeFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func getRaw(url: URL) async throws -> Data {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func post(_ path: String, body: [String: Any]) async throws {
|
||||
_ = try await postDecoded(path, body: body, as: Data.self)
|
||||
}
|
||||
|
||||
private func postDecoded<T: Decodable>(_ path: String, body: [String: Any], as type: T.Type) async throws -> T {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
var detail = String(data: data, encoding: .utf8) ?? ""
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = json["error"] as? String {
|
||||
detail = error
|
||||
}
|
||||
throw MediaAPIError.httpStatus(http.statusCode, detail)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw MediaAPIError.decodeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user