Archived
- 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.
111 lines
4.6 KiB
Swift
111 lines
4.6 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import os
|
|
|
|
private let logger = Logger(subsystem: "dev.shane.voice", category: "media")
|
|
|
|
/// Proxies {base}/media/* bytes to AVPlayer over the pinned-TLS session, since
|
|
/// AVPlayer cannot trust the self-signed LAN cert. AVPlayer loads https natively
|
|
/// (ignoring registered URLProtocol subclasses), so the asset uses a custom
|
|
/// `pinned-media` scheme and this delegate answers every data request by
|
|
/// fetching the real https URL through TLSPinning.session(), forwarding Range.
|
|
final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
|
static let scheme = "pinned-media"
|
|
|
|
private let base: URL
|
|
private var loaderURLs: [ObjectIdentifier: URL] = [:]
|
|
|
|
init(base: URL) {
|
|
self.base = base
|
|
}
|
|
|
|
/// Maps a media URL (absolute https or /media/... path) to the pinned-media
|
|
/// scheme so AVPlayer routes it through this delegate instead of loading
|
|
/// https natively. Built as a string because URLComponents.url returns nil
|
|
/// for schemes not in Foundation's known-scheme list.
|
|
func rewrite(_ url: URL) -> URL {
|
|
let absolute: String
|
|
if let scheme = url.scheme?.lowercased(), scheme.hasPrefix("http") {
|
|
var c = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
|
c.scheme = Self.scheme
|
|
return c.url!
|
|
} else {
|
|
let path = url.absoluteString.hasPrefix("/") ? String(url.absoluteString.dropFirst()) : url.absoluteString
|
|
guard let host = base.host, !host.isEmpty else { return url }
|
|
let port = base.port.map { ":\($0)" } ?? ""
|
|
absolute = "\(Self.scheme)://\(host)\(port)/\(path)"
|
|
}
|
|
return URL(string: absolute) ?? url
|
|
}
|
|
|
|
/// Maps a pinned-media URL back to the real https URL it proxies.
|
|
func resolve(_ url: URL) -> URL {
|
|
guard let scheme = url.scheme?.lowercased(), scheme == Self.scheme else { return url }
|
|
var c = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
|
c.scheme = "https"
|
|
return c.url ?? url
|
|
}
|
|
|
|
func assetWithPinnedURL(_ pinned: URL) -> AVURLAsset {
|
|
let asset = AVURLAsset(url: pinned)
|
|
loaderURLs[ObjectIdentifier(asset.resourceLoader)] = resolve(pinned)
|
|
asset.resourceLoader.setDelegate(self, queue: .main)
|
|
return asset
|
|
}
|
|
|
|
func resourceLoader(
|
|
_ resourceLoader: AVAssetResourceLoader,
|
|
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
|
) -> Bool {
|
|
guard let dataRequest = loadingRequest.dataRequest else { return false }
|
|
guard let url = loaderURLs[ObjectIdentifier(resourceLoader)] else {
|
|
logger.log("no resolved URL for resource loader")
|
|
loadingRequest.finishLoading(with: URLError(.badURL))
|
|
return false
|
|
}
|
|
|
|
var upstream = URLRequest(url: url)
|
|
let offset = dataRequest.requestedOffset
|
|
let length = Int64(dataRequest.requestedLength)
|
|
if length > 0 {
|
|
upstream.setValue("bytes=\(offset)-\(offset + length - 1)", forHTTPHeaderField: "Range")
|
|
} else {
|
|
upstream.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
|
}
|
|
logger.log("fetching \(url.absoluteString, privacy: .public) range=\(upstream.value(forHTTPHeaderField: "Range") ?? "-", privacy: .public)")
|
|
|
|
let task = TLSPinning.session().dataTask(with: upstream) { data, response, error in
|
|
if let error {
|
|
logger.log("fetch failed \(url.absoluteString, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
|
loadingRequest.finishLoading(with: error)
|
|
return
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
loadingRequest.finishLoading(with: URLError(.badServerResponse))
|
|
return
|
|
}
|
|
if let info = loadingRequest.contentInformationRequest {
|
|
info.contentType = http.mimeType ?? "application/octet-stream"
|
|
if http.expectedContentLength >= 0 {
|
|
info.contentLength = Int64(http.expectedContentLength)
|
|
}
|
|
info.isByteRangeAccessSupported = true
|
|
}
|
|
if let data {
|
|
dataRequest.respond(with: data)
|
|
}
|
|
loadingRequest.finishLoading()
|
|
}
|
|
task.resume()
|
|
return true
|
|
}
|
|
|
|
func resourceLoader(
|
|
_ resourceLoader: AVAssetResourceLoader,
|
|
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
|
) {
|
|
logger.log("resource loader cancelled request")
|
|
}
|
|
}
|