Archived
status
This commit is contained in:
1 parent
c9c25f86eb
commit
40aaf4fea7
56 files changed
+5683
-179
No files matched your search
@@ -182,16 +182,11 @@ final class MediaAPI {
|
||||
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)
|
||||
/// Returns the progressive-media endpoint URL for a track. The `/stream/{id}`
|
||||
/// path serves raw media bytes (not a text URL), so this constructs the
|
||||
/// endpoint without downloading or decoding the body.
|
||||
func streamURL(trackID: String) -> URL {
|
||||
baseURL.appendingPathComponent("stream").appending(path: trackID)
|
||||
}
|
||||
|
||||
func healthz() async throws -> Bool {
|
||||
@@ -218,11 +213,24 @@ final class MediaAPI {
|
||||
return data
|
||||
}
|
||||
|
||||
/// Fire-and-forget POST that only validates HTTP success. The media server
|
||||
/// returns `{"ok": true, ...snapshot}` for /play, /control, /report and
|
||||
/// /streams — callers don't need the body, so decoding it (as Data or any
|
||||
/// typed model) would be wasted work at best and a spurious failure mode.
|
||||
private func post(_ path: String, body: [String: Any]) async throws {
|
||||
_ = try await postDecoded(path, body: body, as: Data.self)
|
||||
_ = try await postRaw(path, body: body)
|
||||
}
|
||||
|
||||
private func postDecoded<T: Decodable>(_ path: String, body: [String: Any], as type: T.Type) async throws -> T {
|
||||
let data = try await postRaw(path, body: body)
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw MediaAPIError.decodeFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func postRaw(_ path: String, body: [String: Any]) async throws -> Data {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
@@ -236,10 +244,6 @@ final class MediaAPI {
|
||||
}
|
||||
throw MediaAPIError.httpStatus(http.statusCode, detail)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw MediaAPIError.decodeFailed
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,14 @@ final class MediaPlayerController: ObservableObject {
|
||||
private var room: String?
|
||||
private var currentTrackID: String?
|
||||
private var loadedAudioPath: String?
|
||||
private var currentAsset: AVURLAsset?
|
||||
private var appliedSeekTs: Double = 0
|
||||
private var wasPlayingBeforeTalk = false
|
||||
private var lastReportDate = Date.distantPast
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var timeObserver: Any?
|
||||
private var statusCancellable: AnyCancellable?
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
|
||||
/// Bumped on every room-scope change so in-flight /state polls from the
|
||||
/// previous scope cannot reconcile into the new one (port of the web UI's
|
||||
@@ -55,22 +57,110 @@ final class MediaPlayerController: ObservableObject {
|
||||
if let timeObserver {
|
||||
player.removeTimeObserver(timeObserver)
|
||||
}
|
||||
rateObservation?.invalidate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
/// Whether an audio session interruption is currently active. While true,
|
||||
/// reconciliation must not call player.play() (the next poll would restart
|
||||
/// audio during a phone call or Siri interaction).
|
||||
private var isInterruptionActive = false
|
||||
|
||||
/// Separate resume-suppression flag: set when an interruption ends without
|
||||
/// `.shouldResume` and the pause control to the server has not yet been
|
||||
/// confirmed. While true, reconciliation must not call player.play() even
|
||||
/// after `isInterruptionActive` is cleared. Cleared only by an explicit user
|
||||
/// play/resume action or a confirmed coherent paused state from the server.
|
||||
private var resumeSuppressed = false
|
||||
|
||||
/// Whether playback was actually active (rate > 0) when the interruption
|
||||
/// began. Used to decide whether a `.shouldResume` end should actually
|
||||
/// resume (a track that was already paused before the interruption must not
|
||||
/// be force-resumed).
|
||||
private var wasPlayingBeforeInterruption = false
|
||||
|
||||
/// Sequence number for media interruption operations. Incremented on every
|
||||
/// `.began`; a resume task captures the sequence and rechecks it immediately
|
||||
/// before `player.play()`. A newer `.began` makes an old resume task
|
||||
/// incapable of playing (Iteration 07 Item 5).
|
||||
private var mediaInterruptionSequence = 0
|
||||
|
||||
/// The retained operation chain for media interruption control work. Both
|
||||
/// pause and resume operations are assigned here so a newer begin cancels
|
||||
/// and supersedes prior control work.
|
||||
private var mediaInterruptionTask: Task<Void, Never>?
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard let info = notification.userInfo else { return }
|
||||
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
// Increment the sequence so any in-flight resume task from a prior
|
||||
// interruption sees a stale sequence and cannot play.
|
||||
mediaInterruptionSequence += 1
|
||||
let seq = mediaInterruptionSequence
|
||||
// Cancel/supersede prior control work (resume or pause) so it cannot
|
||||
// complete out of order.
|
||||
mediaInterruptionTask?.cancel()
|
||||
isInterruptionActive = true
|
||||
wasPlayingBeforeInterruption = !player.rate.isZero
|
||||
let wasPlaying = wasPlayingBeforeInterruption
|
||||
player.pause()
|
||||
logger.log("audio interruption began, paused media")
|
||||
logger.log("audio interruption began, paused media (wasPlaying=\(wasPlaying), seq=\(seq))")
|
||||
case .ended:
|
||||
defer { isInterruptionActive = false }
|
||||
applyVolume()
|
||||
if currentTrackID != nil, desired == "playing", loadedAudioPath != nil {
|
||||
player.play()
|
||||
logger.log("audio interruption ended, resumed media")
|
||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume)
|
||||
if shouldResume, wasPlayingBeforeInterruption, currentTrackID != nil, desired == "playing", loadedAudioPath != nil {
|
||||
// Synchronize the server before resuming local playback so the
|
||||
// two never diverge. Capture generation/room/sequence before the
|
||||
// async hop. The task is retained in mediaInterruptionTask so a
|
||||
// newer .began can cancel it.
|
||||
let gen = pollGeneration
|
||||
let targetRoom = room
|
||||
let seq = mediaInterruptionSequence
|
||||
mediaInterruptionTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if let targetRoom {
|
||||
do {
|
||||
try await self.api.control(room: targetRoom, action: "resume")
|
||||
logger.log("interruption resume: server synchronized (seq=\(seq))")
|
||||
} catch {
|
||||
logger.log("interruption resume: server sync failed (poll will retry): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
// Recheck the sequence immediately before play: a newer .began
|
||||
// increments mediaInterruptionSequence, making this stale task
|
||||
// incapable of restarting audio during the newer interruption.
|
||||
guard !Task.isCancelled,
|
||||
self.mediaInterruptionSequence == seq,
|
||||
!self.isInterruptionActive,
|
||||
self.pollGeneration == gen,
|
||||
self.room == targetRoom else { return }
|
||||
self.player.play()
|
||||
logger.log("audio interruption ended, resumed media (seq=\(seq))")
|
||||
}
|
||||
} else {
|
||||
// No-resume path: keep suppression even if the pause control
|
||||
// fails. The flag is cleared only by an explicit user play/resume
|
||||
// action or a confirmed coherent paused state from the server.
|
||||
resumeSuppressed = true
|
||||
let seq = mediaInterruptionSequence
|
||||
if let room {
|
||||
mediaInterruptionTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.api.control(room: room, action: "pause")
|
||||
logger.log("audio interruption ended without resume, sent pause to server (seq=\(seq))")
|
||||
} catch {
|
||||
// Keep suppression even on failure — the next poll
|
||||
// will see desired=="paused" and clear it.
|
||||
logger.log("audio interruption ended without resume, pause control failed (suppression retained): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
@@ -82,6 +172,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
func attach(session: VoiceSession) {
|
||||
self.session = session
|
||||
observePlayer()
|
||||
observePlaybackState()
|
||||
applyVolume()
|
||||
}
|
||||
|
||||
@@ -106,7 +197,10 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
/// Called when the conversation ends. If music was playing before the talk,
|
||||
/// hand the room back to the player.
|
||||
/// hand the room back to the player. The previous scope (room name) is kept
|
||||
/// so the panel can resume polling it via `startDefaultPolling()`; a fresh
|
||||
/// conversation re-scopes via `enterMediaScope`. Polling restarts here so
|
||||
/// the resumed state stays visible without the panel being open.
|
||||
func endConversation() async {
|
||||
stopPolling()
|
||||
pollGeneration += 1
|
||||
@@ -120,6 +214,16 @@ final class MediaPlayerController: ObservableObject {
|
||||
logger.log("post-talk resume failed (poll will retry): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/// Drop the room scope entirely (app-level teardown). After this, polling
|
||||
/// targets the default room again.
|
||||
func resetScope() {
|
||||
stopPolling()
|
||||
pollGeneration += 1
|
||||
room = nil
|
||||
wasPlayingBeforeTalk = false
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
@@ -137,10 +241,11 @@ final class MediaPlayerController: ObservableObject {
|
||||
pollTask = nil
|
||||
}
|
||||
|
||||
/// Start polling the default room when no session is active. Called from
|
||||
/// the Media Panel so play/stop state is visible without a voice session.
|
||||
/// Resume polling whenever the panel becomes visible and nothing is being
|
||||
/// polled yet — whether that's the default room (no session) or a previous
|
||||
/// conversation's room scope (kept after `endConversation`).
|
||||
func startDefaultPolling() {
|
||||
guard room == nil else { return }
|
||||
guard pollTask == nil else { return }
|
||||
startPolling()
|
||||
}
|
||||
|
||||
@@ -161,9 +266,16 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcile(_ s: MediaState) {
|
||||
/// Reconciles the local player against a server state snapshot. Exposed
|
||||
/// as internal (not private) so tests can drive deterministic state
|
||||
/// transitions without a live server.
|
||||
func reconcile(_ s: MediaState) {
|
||||
choices = s.choices
|
||||
pendingJobs = s.pending
|
||||
// Keep the published desired state in sync with the server so the UI's
|
||||
// play/pause icon and status text reflect reality (the server is the
|
||||
// source of truth for per-room desired state).
|
||||
desired = s.desired ?? "stopped"
|
||||
|
||||
let track = s.track
|
||||
if let track, track.id != currentTrackID {
|
||||
@@ -189,14 +301,37 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
private func applyDesiredState(_ s: MediaState, track: MediaTrack?) {
|
||||
if s.desired == "playing" {
|
||||
// audio_url presence means a playable stream exists — including a
|
||||
// still-growing download, which plays progressively.
|
||||
guard let desiredValue = s.desired else { return }
|
||||
switch desiredValue {
|
||||
case "playing":
|
||||
// Suppress play during an active audio interruption (phone call,
|
||||
// Siri). Without this guard the next 1.5s poll would restart audio
|
||||
// immediately after .began paused it. The player.rate.isZero check
|
||||
// makes this idempotent: once playing, subsequent polls are no-ops.
|
||||
// This also handles the late-audio case where desired=playing
|
||||
// arrives before audio_url is set; when the source is later wired
|
||||
// via wireSource(), the next poll retries play because rate is
|
||||
// still zero.
|
||||
// Also suppress while resumeSuppressed is true (no-resume
|
||||
// interruption ended, server not yet confirmed paused).
|
||||
guard !isInterruptionActive, !resumeSuppressed else { return }
|
||||
if let track, track.audioURL != nil, loadedAudioPath != nil, player.rate.isZero {
|
||||
player.play()
|
||||
}
|
||||
} else if !player.rate.isZero {
|
||||
player.pause()
|
||||
case "paused", "stopped":
|
||||
if !player.rate.isZero {
|
||||
player.pause()
|
||||
}
|
||||
// The server has confirmed a coherent paused/stopped state. If we
|
||||
// were suppressing resume due to a no-resume interruption, the
|
||||
// pause control succeeded (or the user paused independently), so
|
||||
// it is safe to clear the suppression flag.
|
||||
if resumeSuppressed {
|
||||
resumeSuppressed = false
|
||||
logger.log("resume suppression cleared: server confirmed \(desiredValue)")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +343,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
} else {
|
||||
// Downloading or failed: no stream yet. Reconcile wires the stream
|
||||
// URL in via polling as soon as one is available.
|
||||
clearCurrentAsset()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
@@ -216,13 +352,27 @@ final class MediaPlayerController: ObservableObject {
|
||||
guard let url = URL(string: path) else { return }
|
||||
loadedAudioPath = path
|
||||
let pinned = mediaLoader.rewrite(url)
|
||||
// Remove the previous asset's loader mapping before wiring a new one so
|
||||
// the loaderURLs dictionary stays bounded (Finding 11 / Item 7).
|
||||
clearCurrentAsset()
|
||||
let asset = mediaLoader.assetWithPinnedURL(pinned)
|
||||
currentAsset = asset
|
||||
logger.log("wiring source \(pinned.absoluteString, privacy: .public)")
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
observeItem(item)
|
||||
player.replaceCurrentItem(with: item)
|
||||
}
|
||||
|
||||
/// Removes the loader mapping for the current asset (if any) and clears the
|
||||
/// reference. Called before wiring a new source or clearing media so the
|
||||
/// resource loader's internal dictionaries stay bounded.
|
||||
private func clearCurrentAsset() {
|
||||
if let asset = currentAsset {
|
||||
mediaLoader.removeLoader(asset)
|
||||
currentAsset = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Player events
|
||||
|
||||
private func observePlayer() {
|
||||
@@ -248,6 +398,38 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback state observation
|
||||
|
||||
/// Observe the player's actual rate so every real play/pause transition is
|
||||
/// reported promptly, mirroring the web UI's `play`/`pause` listeners. The
|
||||
/// KVO block runs on an arbitrary queue; hop to MainActor before touching
|
||||
/// any controller state.
|
||||
private func observePlaybackState() {
|
||||
rateObservation = player.observe(\.rate, options: [.initial, .new]) { [weak self] player, _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.currentTrackID != nil else { return }
|
||||
if player.rate > 0 {
|
||||
// Actual playback began (user play, interruption resume, or
|
||||
// reconcile-driven play). Report + publish the transition.
|
||||
self.lastReportDate = Date()
|
||||
self.report("playing")
|
||||
self.publishMediaState("playing")
|
||||
} else if player.currentItem != nil {
|
||||
// Rate dropped to zero with an item still loaded: a pause
|
||||
// that didn't originate from a user action (interruption,
|
||||
// reconcile). User-initiated pauses already report before
|
||||
// this fires; the lastReportDate guard makes duplicates no-ops.
|
||||
if Date().timeIntervalSince(self.lastReportDate) >= 1 {
|
||||
self.lastReportDate = Date()
|
||||
self.report("paused")
|
||||
self.publishMediaState("paused")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observeItem(_ item: AVPlayerItem?) {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: .AVPlayerItemDidPlayToEndTime, object: nil
|
||||
@@ -270,6 +452,7 @@ final class MediaPlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
@objc private func playerItemDidEnd(_ notification: Notification) {
|
||||
lastReportDate = Date()
|
||||
report("stopped")
|
||||
publishMediaState("stopped")
|
||||
Task { [weak self] in
|
||||
@@ -280,29 +463,38 @@ final class MediaPlayerController: ObservableObject {
|
||||
|
||||
// MARK: Reporting + publishing
|
||||
|
||||
private func report(_ state: String) {
|
||||
private func report(_ state: String, trackID: String? = nil) {
|
||||
guard let room else { return }
|
||||
let position = currentTime.isFinite ? currentTime : 0
|
||||
var durationValue: Double?
|
||||
if duration.isFinite, duration > 0 {
|
||||
durationValue = duration
|
||||
}
|
||||
let resolvedTrackID = trackID ?? currentTrackID
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.api.report(room: room, state: state, trackID: self.currentTrackID, position: position, duration: durationValue)
|
||||
try await self.api.report(room: room, state: state, trackID: resolvedTrackID, position: position, duration: durationValue)
|
||||
} catch {
|
||||
logger.log("report failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func publishMediaState(_ state: String) {
|
||||
private func publishMediaState(_ state: String, trackID: String? = nil) {
|
||||
guard let session else { return }
|
||||
// Capture the exact room and generation before spawning so a delayed
|
||||
// publish from an old lifecycle cannot escape into a new conversation
|
||||
// room (Finding 10).
|
||||
let targetRoom = session.room
|
||||
let generation = session.sessionGeneration
|
||||
let pollGen = pollGeneration
|
||||
let resolvedTrackID = trackID ?? currentTrackID
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.pollGeneration == pollGen else { return }
|
||||
guard session.sessionGeneration == generation, session.room === targetRoom else { return }
|
||||
do {
|
||||
try await session.controlChannel.sendMediaState(state, trackID: self.currentTrackID, room: session.room)
|
||||
try await session.controlChannel.sendMediaState(state, trackID: resolvedTrackID, room: targetRoom)
|
||||
} catch {
|
||||
logger.log("media_state publish failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
@@ -336,9 +528,17 @@ final class MediaPlayerController: ObservableObject {
|
||||
func togglePlayPause() async {
|
||||
guard currentTrackID != nil, loadedAudioPath != nil else { return }
|
||||
if player.rate.isZero {
|
||||
// Explicit user play: clear any resume suppression (the user is
|
||||
// deliberately overriding the no-resume interruption state).
|
||||
resumeSuppressed = false
|
||||
// The rate observer reports + publishes the actual transition;
|
||||
// the control call updates server desired state.
|
||||
player.play()
|
||||
if let room { try? await api.control(room: room, action: "resume") }
|
||||
} else {
|
||||
lastReportDate = Date()
|
||||
report("paused")
|
||||
publishMediaState("paused")
|
||||
player.pause()
|
||||
if let room { try? await api.control(room: room, action: "pause") }
|
||||
}
|
||||
@@ -350,24 +550,31 @@ final class MediaPlayerController: ObservableObject {
|
||||
if target.isFinite {
|
||||
seek(to: target)
|
||||
}
|
||||
report(player.rate.isZero ? "paused" : "playing")
|
||||
}
|
||||
|
||||
func seek(to position: Double) {
|
||||
player.seek(to: CMTime(seconds: position, preferredTimescale: 600))
|
||||
currentTime = position
|
||||
report(player.rate.isZero ? "paused" : "playing")
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
// Capture the track ID before clearing so the async report/publish
|
||||
// tasks can read a non-nil value even after clearMedia() runs.
|
||||
let trackID = currentTrackID
|
||||
report("stopped", trackID: trackID)
|
||||
clearMedia()
|
||||
if let room { try? await api.control(room: room, action: "stop") }
|
||||
publishMediaState("stopped")
|
||||
publishMediaState("stopped", trackID: trackID)
|
||||
}
|
||||
|
||||
private func clearMedia() {
|
||||
currentTrackID = nil
|
||||
track = nil
|
||||
loadedAudioPath = nil
|
||||
// Remove the loader mapping for the asset being cleared so the
|
||||
// resource loader's internal dictionaries stay bounded (Item 7).
|
||||
clearCurrentAsset()
|
||||
observeItem(nil)
|
||||
player.replaceCurrentItem(with: nil)
|
||||
currentTime = 0
|
||||
|
||||
@@ -9,17 +9,52 @@ private let logger = Logger(subsystem: "dev.shane.voice", category: "media")
|
||||
/// (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.
|
||||
///
|
||||
/// **Lifecycle invariants (Finding 11 / Iteration 06 Item 7):**
|
||||
/// - Tasks are keyed by `ObjectIdentifier(loadingRequest)` directly, not a
|
||||
/// derived hash, so two distinct requests can never collide.
|
||||
/// - All mutable state (`loaderURLs`, `activeTasks`) is accessed only on the
|
||||
/// main queue: the AVAssetResourceLoader delegate is set with `queue: .main`
|
||||
/// and the pinned URLSession uses `delegateQueue: .main`, so both the
|
||||
/// resource-loading callbacks and the data-task completion handlers run on
|
||||
/// the same serialization domain. This is enforced in code, not by comment.
|
||||
/// - For HTTP 206, `contentLength` is set only when `Content-Range` provides a
|
||||
/// known total; it never falls back to the range's `expectedContentLength`.
|
||||
/// - For HTTP 200, `expectedContentLength` is already the full resource size.
|
||||
/// - Cleanup (task removal) occurs on success, error, cancellation, and item
|
||||
/// replacement via `removeLoader`.
|
||||
final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
|
||||
static let scheme = "pinned-media"
|
||||
|
||||
private let base: URL
|
||||
/// Maps each asset's resource loader to the real https URL it proxies.
|
||||
/// Accessed only on the main queue (the delegate queue).
|
||||
private var loaderURLs: [ObjectIdentifier: URL] = [:]
|
||||
/// Maps each loading request's identity to its upstream data task so
|
||||
/// cancellation can cancel the in-flight download. Keyed directly by
|
||||
/// `ObjectIdentifier(loadingRequest)` — no hash derivation, no collisions.
|
||||
private var activeTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
||||
/// Shared pinned session, created once and invalidated on deinit.
|
||||
/// Uses `delegateQueue: .main` so all completion handlers (and therefore
|
||||
/// all mutations of `activeTasks`) run on the main queue — the same
|
||||
/// serialization domain as the AVAssetResourceLoader delegate callbacks
|
||||
/// (which are also dispatched to `.main`). This makes map access truly
|
||||
/// serialized in code, not just by convention.
|
||||
private lazy var pinnedSession: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 30
|
||||
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: .main)
|
||||
}()
|
||||
|
||||
init(base: URL) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
deinit {
|
||||
pinnedSession.invalidateAndCancel()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -47,17 +82,38 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
return c.url ?? url
|
||||
}
|
||||
|
||||
/// Creates an AVURLAsset that routes through this loader's delegate. The
|
||||
/// mapping from the asset's resource loader to the real URL is recorded so
|
||||
/// `resourceLoader(_:shouldWaitForLoadingOfRequestedResource:)` can resolve
|
||||
/// it. All mutations happen on the main queue (the delegate queue).
|
||||
func assetWithPinnedURL(_ pinned: URL) -> AVURLAsset {
|
||||
let asset = AVURLAsset(url: pinned)
|
||||
loaderURLs[ObjectIdentifier(asset.resourceLoader)] = resolve(pinned)
|
||||
let realURL = resolve(pinned)
|
||||
// The delegate queue is .main, so this runs on the main thread.
|
||||
loaderURLs[ObjectIdentifier(asset.resourceLoader)] = realURL
|
||||
asset.resourceLoader.setDelegate(self, queue: .main)
|
||||
return asset
|
||||
}
|
||||
|
||||
/// Removes the loader mapping and any in-flight tasks for an asset that is
|
||||
/// no longer needed. Called by MediaPlayerController when replacing or
|
||||
/// clearing media items to bound the dictionaries (Finding 11).
|
||||
func removeLoader(_ asset: AVURLAsset) {
|
||||
let key = ObjectIdentifier(asset.resourceLoader)
|
||||
loaderURLs[key] = nil
|
||||
// Cancel any in-flight tasks. URLSessionDataTask has no isCancelled
|
||||
// property; calling cancel() on an already-cancelled task is a no-op.
|
||||
for (_, task) in activeTasks {
|
||||
task.cancel()
|
||||
}
|
||||
activeTasks.removeAll()
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
// All state access is on the main queue (the delegate queue).
|
||||
guard let dataRequest = loadingRequest.dataRequest else { return false }
|
||||
guard let url = loaderURLs[ObjectIdentifier(resourceLoader)] else {
|
||||
logger.log("no resolved URL for resource loader")
|
||||
@@ -75,7 +131,16 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
}
|
||||
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
|
||||
// Key the task by the loading request's identity directly — no hash
|
||||
// derivation, so two distinct requests can never collide.
|
||||
let requestKey = ObjectIdentifier(loadingRequest)
|
||||
let task = pinnedSession.dataTask(with: upstream) { [weak self] data, response, error in
|
||||
guard let self else { return }
|
||||
// Cleanup: remove the task from the active map regardless of outcome.
|
||||
// Runs on .main (the session's delegateQueue), same domain as all
|
||||
// other map mutations.
|
||||
self.activeTasks[requestKey] = nil
|
||||
|
||||
if let error {
|
||||
logger.log("fetch failed \(url.absoluteString, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
loadingRequest.finishLoading(with: error)
|
||||
@@ -85,10 +150,33 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
loadingRequest.finishLoading(with: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
// Reject non-200/206 responses (404, 500, etc.) instead of feeding
|
||||
// error JSON into AVPlayer as media bytes.
|
||||
guard http.statusCode == 200 || http.statusCode == 206 else {
|
||||
logger.log("unexpected status \(http.statusCode) for \(url.absoluteString, privacy: .public)")
|
||||
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)
|
||||
// Content-length handling (Iteration 05 Item 7):
|
||||
// - For 206: set full content length ONLY when Content-Range
|
||||
// provides a known total. Never fall back to the range's
|
||||
// expectedContentLength (that would tell AVPlayer the total
|
||||
// resource is only as large as this one range).
|
||||
// - For 200: expectedContentLength is already the full size.
|
||||
if http.statusCode == 206 {
|
||||
if let total = Self.parseTotalLength(http), total >= 0 {
|
||||
info.contentLength = total
|
||||
}
|
||||
// If Content-Range has no known total (e.g. "bytes 0-1023/*"),
|
||||
// do NOT set contentLength — AVPlayer will handle it as
|
||||
// unknown-length, which is correct for a range response.
|
||||
} else {
|
||||
// 200: expectedContentLength is the full resource size.
|
||||
if http.expectedContentLength >= 0 {
|
||||
info.contentLength = Int64(http.expectedContentLength)
|
||||
}
|
||||
}
|
||||
info.isByteRangeAccessSupported = true
|
||||
}
|
||||
@@ -97,6 +185,7 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
}
|
||||
loadingRequest.finishLoading()
|
||||
}
|
||||
activeTasks[requestKey] = task
|
||||
task.resume()
|
||||
return true
|
||||
}
|
||||
@@ -105,6 +194,27 @@ final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
||||
) {
|
||||
logger.log("resource loader cancelled request")
|
||||
// Cancel the upstream data task so a seek or item swap doesn't keep
|
||||
// downloading cancelled ranges. Keyed by ObjectIdentifier directly.
|
||||
let key = ObjectIdentifier(loadingRequest)
|
||||
activeTasks[key]?.cancel()
|
||||
activeTasks[key] = nil
|
||||
logger.log("resource loader cancelled request, upstream task cancelled")
|
||||
}
|
||||
|
||||
/// Parses the total resource length from an HTTP 206 response's
|
||||
/// `Content-Range` header (e.g. "bytes 0-1023/4096" → 4096). Returns nil
|
||||
/// for non-206 responses, missing headers, unknown totals ("*"), or
|
||||
/// malformed values.
|
||||
static func parseTotalLength(_ response: HTTPURLResponse) -> Int64? {
|
||||
if response.statusCode == 206,
|
||||
let contentRange = response.value(forHTTPHeaderField: "Content-Range") {
|
||||
// Format: "bytes <start>-<end>/<total>" or "bytes <start>-<end>/*"
|
||||
let afterSlash = contentRange.split(separator: "/").last?.trimmingCharacters(in: .whitespaces)
|
||||
if let afterSlash, afterSlash != "*", let total = Int64(afterSlash) {
|
||||
return total
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user