- 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.
244 lines
8.3 KiB
Swift
244 lines
8.3 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|