Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,55 @@ the public-API contract.

## [Unreleased]

### Added

- **`LoadOptions.initialSubtitleTrackID` names the subtitle track to activate at load.**
A host that already knows the track it wants, such as a server's default subtitle index, had to
language-match `subtitleTracks` itself or call `selectSubtitleTrack` after the load. The id (an
embedded stream index, or `externalSubtitleTrackIDBase` plus the position in `externalSubtitles`)
now activates on the same post-load path as `preferredSubtitleLanguages` and wins over it. An id
that names no track falls through to the language preference. See `docs/api.md`.

### Fixed

- **`selectChapter` on a disc title lands on the chapter, not one container start late.** The software route
seeks on the session axis and the host adds the clock's session zero back, so adding the whole container
start as well applied it twice whenever the start was past the 2 s anchor tolerance. Only the part of the
start the zero did not take is added now, and a native disc title no longer adds the playlist shift on top
of `seek(to:)`.

- **A 24.000 fps source is programmed as 24 Hz, not 23.976 Hz (AE#884, thanks @akhanwav).** `FrameRateSnap` sent
the whole 23.5 to 24.05 window to 23.976, so Match Frame Rate ran 24p content one frame in 1001 fast and the
panel repeated a frame about every 42 s. A probe within 0.005 of 24.000 now snaps to 24; a Matroska track with
DefaultDuration probes as an exact 24/1 or 24000/1001, so 23.976 sources and noisier probes keep 23.976.

- **A refused video-session rebuild is retried at the next keyframes in the foreground.** When the system
invalidated the hardware session and VideoToolbox refused the rebuild, the decoder dropped every packet
and the picture froze beside live audio. The host now retries at each keyframe fed while the session is
still lost: four attempts at least two seconds apart, then one attempt every 30 seconds, so a session the
system gives back later recovers without a reload. Nothing is retried while the app is in the background
without picture-in-picture, where VideoToolbox refuses a rebuild by design; a foreground return starts a
fresh quick budget. The session is not moved to `.error` for this: that state is terminal until the next
load, so a rebuild that succeeds later could not clear it.

- **The in-band HEVC parameter-set scan stops at 16 MiB of input.** A stream that carries no parameter sets,
or one whose packets are very large, could be read far past what the packet budget implies before the
load gave up. The scan now also stops at a byte budget, the same ceiling is armed on the demuxer input so
one oversized read cannot overshoot it, and the record is left as it was.

- **The software host recovers when the system flushes or fails its video queue or audio renderer.** The
video renderer reports `requiresFlushToResumeDecoding` (the app lost the right to decode, for instance
after backgrounding) and either renderer can end up failed; nothing observed any of it, so the picture
stayed frozen until the user seeked. The host now flushes the failed queue and the video decoder and the
picture resumes at the next keyframe while the audio plays on. Only when that keyframe lies further away
than the audio already queued, or none has been read yet, does it seek to the playhead instead. A video
failure while only audio plays in the background is paid on the foreground return, a live source with no
rewind ring only flushes, and a queue that is still failing after a recovery is flushed once more and then
left alone until a frame the clock has passed gets through, with a short pause between two seeks to the
playhead. After a flush the video decoder is fed again only from the next keyframe, so no delta frame
decodes against nothing. An audio renderer that keeps failing is flushed at most three times in ten
seconds, and one that flushes itself on a route change keeps the behaviour it had.

## [7.48.0] - 2026-10-26

### Added
Expand Down
4 changes: 2 additions & 2 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2739,8 +2739,8 @@ extension AetherEngine {
// stopInternal zeroed sourceStartSeconds; recapture the software-path chapter-seek base from
// the reopened demuxer so a DVD chapter seek after an audio switch / custom reload still lands
// (the native base self-heals via onPlaylistShiftChanged, this one does not). (#67)
let st = pre.formatStartTime
reopenedStartSeconds = st > 0 ? Double(st) / Double(AV_TIME_BASE) : 0
// Same resolved origin as the first load (see sourceStartSeconds).
reopenedStartSeconds = pre.resolvedSourceStartOrigin
}
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/AetherEngine/AetherEngine+Probe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,22 @@ extension AetherEngine {
return Int32(tracks[index].id)
}

/// The subtitle track to activate at the end of a load: `LoadOptions.initialSubtitleTrackID` when it
/// names a track in `tracks`, else the best `preferredSubtitleLanguages` match, else nil (subtitles
/// off). The explicit id wins because a host that names a track has already made the choice the
/// language ranking approximates; an id that matches nothing is ignored rather than treated as an
/// error, so a stale server index cannot fail a load. Pure and nonisolated, like `selectSubtitleIndex`.
nonisolated static func resolveInitialSubtitleIndex(
tracks: [TrackInfo],
initialTrackID: Int?,
preferredLanguages: [String]
) -> Int32? {
if let initialTrackID, tracks.contains(where: { $0.id == initialTrackID }) {
return Int32(initialTrackID)
}
return selectSubtitleIndex(tracks: tracks, preferredLanguages: preferredLanguages)
}

/// Lower rank wins. The descriptor axis (full > SDH > forced > commentary, from container dispositions)
/// dominates; text-vs-bitmap is a tiebreaker (text preferred, since host styling only applies to text
/// cues). Sourced from `TrackInfo` disposition flags rather than title strings, so it is locale-robust.
Expand Down
2 changes: 1 addition & 1 deletion Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ enum SessionOptionCorrection {
"preserveASSMarkup", "prepareNativeSubtitles", "eagerNativeSubtitleReaders", "serveIFramePlaylist", "confirmAtmos",
"nativeSubtitlePreferredLanguages", "sequentialOrigin", "maxConcurrentSourceRequests", "heldSourceConnection",
"declaredDurationSeconds", "probesize", "maxAnalyzeDuration", "preferredAudioLanguages",
"preferredSubtitleLanguages", "externalSubtitles", "forwardBufferSegments", "sessionCacheByteBudget", "softwarePacketCacheStorage", "progressiveSegmentDelivery", "autoplay",
"preferredSubtitleLanguages", "externalSubtitles", "initialSubtitleTrackID", "forwardBufferSegments", "sessionCacheByteBudget", "softwarePacketCacheStorage", "progressiveSegmentDelivery", "autoplay",
"audioDelaySeconds", "teletextPage", "deinterlaceMode", "deinterlaceFieldRate", "preferredDecodePath",
"escalatesToSoftwarePath", "sharedOutputRole", "isLiveRejoin", "subtitleSessionCarryover",
]
Expand Down
28 changes: 16 additions & 12 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,22 +144,25 @@ extension AetherEngine {
)
}

/// Apply `LoadOptions.preferredSubtitleLanguages` at the end of a successful load: activate the best-ranked
/// subtitle track whose language matches a preference (scanned in order; see `selectSubtitleIndex`), else
/// leave subtitles off (the default). Uses the host-overlay path (equivalent to a `selectSubtitleTrack`
/// call); `startAnchor` is the
/// Apply `LoadOptions.initialSubtitleTrackID`, else `LoadOptions.preferredSubtitleLanguages`, at the end of a
/// successful load: activate the track the id names, else the best-ranked subtitle track whose language
/// matches a preference (scanned in order; see `selectSubtitleIndex`), else leave subtitles off (the default).
/// Uses the host-overlay path (equivalent to a `selectSubtitleTrack` call); `startAnchor` is the
/// load's resume position so a mid-file resume seeks the side demuxer to the playhead instead of byte 0.
/// A no-op when the list is empty, no track matches, or the host already activated a subtitle. The resolved
/// A no-op when neither an id nor a language list is set, nothing matches, or the host already activated a subtitle. The resolved
/// index is published via `activeSubtitleTrackIndex`. Independent of `prepareNativeSubtitles`. (#73)
func applyPreferredSubtitleSelection(startAnchor: Double?, sourceDuration: Double?) {
guard !loadedOptions.preferredSubtitleLanguages.isEmpty, !isSubtitleActive,
!hostExplicitSubtitleAction else { return }
guard let index = Self.selectSubtitleIndex(
guard loadedOptions.initialSubtitleTrackID != nil || !loadedOptions.preferredSubtitleLanguages.isEmpty,
!isSubtitleActive, !hostExplicitSubtitleAction else { return }
guard let index = Self.resolveInitialSubtitleIndex(
tracks: subtitleTracks,
initialTrackID: loadedOptions.initialSubtitleTrackID,
preferredLanguages: loadedOptions.preferredSubtitleLanguages
) else { return }
EngineLog.emit(
"[AetherEngine] preferred-subtitle auto-select stream=\(index) langs=\(loadedOptions.preferredSubtitleLanguages)",
"[AetherEngine] preferred-subtitle auto-select stream=\(index) "
+ "initialID=\(loadedOptions.initialSubtitleTrackID.map(String.init) ?? "nil") "
+ "langs=\(loadedOptions.preferredSubtitleLanguages)",
category: .engine
)
// Bound the anchor to the probe duration (synchronously known here; the published `duration` is set
Expand Down Expand Up @@ -1393,9 +1396,10 @@ extension AetherEngine {
/// Register an external subtitle file as a first-class track (AetherEngine#88): it appears in
/// `subtitleTracks` with a synthetic id and `isExternal == true` and is selectable via
/// `selectSubtitleTrack(index:)`. Overlay-only (no native WebVTT rendition / PiP); declare via
/// `LoadOptions.externalSubtitles` for rendition eligibility. If `preferredSubtitleLanguages`
/// is set, nothing is active, and the host made no explicit choice yet, the preference re-runs
/// so a late-added matching track auto-activates.
/// `LoadOptions.externalSubtitles` for rendition eligibility. If `initialSubtitleTrackID` or
/// `preferredSubtitleLanguages` is set, nothing is active, and the host made no explicit choice
/// yet, the selection re-runs so a late-added track that matches the id or the preference
/// auto-activates.
@discardableResult
public func addExternalSubtitleTrack(_ track: ExternalSubtitleTrack) -> TrackInfo {
let info = registerExternalSubtitleTrack(track)
Expand Down
47 changes: 33 additions & 14 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -760,9 +760,11 @@ public final class AetherEngine: ObservableObject {
/// background-resume reopens (a URL-disc reopen with no id would silently revert to the main title).
var activeDiscTitleID: Int?

/// Source container start PTS (seconds) from the probe (AVFormatContext.start_time). The software-path
/// playback clock begins here (the native path's content base is `playlistShiftSeconds`), so a DVD
/// chapter seek adds it to the chapter's title-relative (0-based) target. 0 when unknown (#67).
/// Source start PTS (seconds) the software host anchors its clock on: the demuxer's resolved origin
/// (`Demuxer.resolvedSourceStartOrigin`: the container start, backed up by the video stream's own
/// start when the container states none). `PresentationAxis.discChapterSeekTarget` needs the same
/// origin the host derives its session zero from, so the part of it the zero did not take can be
/// added to a DVD chapter's title-relative (0-based) target. 0 when unknown (#67).
var sourceStartSeconds: Double = 0

/// Active playback backend: `.native` (AVPlayer) or `.software` (SoftwarePlaybackHost/dav1d/libavcodec).
Expand Down Expand Up @@ -944,6 +946,7 @@ public final class AetherEngine: ObservableObject {
// SW-PiP Phase C: flip the frame compositor with the PiP state so subtitles appear in the
// window and never double-draw under the fullscreen host overlay.
softwareHost?.updateSubtitleCompositor(cues: subtitleCues + secondarySubtitleCues, enabled: pictureInPictureActive, delaySeconds: softwareSubtitleDelaySeconds)
noteVideoPresentationToHost()
#if os(tvOS)
// PiP window closed while backgrounded: nothing keeps the app running anymore, so the
// wedge-safe teardown is due before idle suspension (mirrors the iOS pause-while-backgrounded path).
Expand Down Expand Up @@ -4963,10 +4966,10 @@ public final class AetherEngine: ObservableObject {
republishMediaChapters()
activeDiscTitleID = probeOpened ? probe.selectedDiscTitleID : nil
selectedDiscTitle = activeDiscTitleID.flatMap { id in discTitles.first { $0.id == id } }
// Content start PTS for the software-path chapter-seek base (see sourceStartSeconds). start_time is
// AV_NOPTS_VALUE (Int64.min) when unknown; only a positive value is a real offset.
let probedStartTime = probeOpened ? probe.formatStartTime : 0
sourceStartSeconds = probedStartTime > 0 ? Double(probedStartTime) / Double(AV_TIME_BASE) : 0
// Source start PTS for the software-path chapter-seek base (see sourceStartSeconds): the same
// resolved origin the software host predicts its session zero from, so engine and host agree even
// when the container states no start and only the video stream carries one.
sourceStartSeconds = probeOpened ? probe.resolvedSourceStartOrigin : 0
// Assemble SourceProbe now while the demuxer is open; ownership transfers to loadNative/loadSoftware
// after which streams are gone (AetherEngine#28).
let sourceProbe: SourceProbe? = probeOpened
Expand Down Expand Up @@ -7771,6 +7774,10 @@ public final class AetherEngine: ObservableObject {

EngineLog.emit("[AetherEngine] selectTitle: scheduling switch to title \(id)", category: .engine)
let gen = loadGeneration
// Stream indices are per title, so an initial subtitle track id named for the old title could
// activate an unrelated track on the new one. Drop it here (both reload paths read loadedOptions);
// the language preference decides on the new title.
loadedOptions.initialSubtitleTrackID = nil
let options = loadedOptions
let custom = isCustomSource
Task { @MainActor [weak self] in
Expand Down Expand Up @@ -7836,15 +7843,18 @@ public final class AetherEngine: ObservableObject {
return
}
// discChapters are title-relative (0-based: chapter 1 = 0), matching the disc timeline and the
// 0-based title duration. The engine clock and seek(to:) run on the source-PTS axis, which begins at
// the title's content start; that base differs by backend (native re-times onto a 0-based playlist
// shifted by playlistShiftSeconds; the software path's raw clock begins at the container start,
// sourceStartSeconds). Add it so the seek lands on the chapter, not the base seconds early.
let base = (playbackBackend == .software) ? sourceStartSeconds : playlistShiftSeconds
let target = chapter.startSeconds + base
// 0-based title duration. `seek(to:)` takes the published axis of the active backend, which differs
// by backend (native re-times onto a 0-based playlist shifted by playlistShiftSeconds; the software
// path's published axis is the clock's session zero off the raw container start, so only the part of
// sourceStartSeconds the zero did not take is added, see `PresentationAxis.discChapterSeekTarget`).
let target = PresentationAxis.discChapterSeekTarget(
chapterStart: chapter.startSeconds, backend: playbackBackend,
sourceStartSeconds: sourceStartSeconds, playlistShiftSeconds: playlistShiftSeconds,
softwareClockZero: softwareClockSessionZero)
let base = target - chapter.startSeconds
EngineLog.emit(
"[AetherEngine] selectChapter: seeking to chapter \(id) @ title-relative "
+ "\(String(format: "%.2f", chapter.startSeconds))s -> source \(String(format: "%.2f", target))s "
+ "\(String(format: "%.2f", chapter.startSeconds))s -> seek target \(String(format: "%.2f", target))s "
+ "(base \(String(format: "%.2f", base))s, backend \(playbackBackend))",
category: .engine
)
Expand Down Expand Up @@ -8309,6 +8319,7 @@ public final class AetherEngine: ObservableObject {
self.isBackgrounded = false
if returnedFromBackground { Self.clearPanelRefusalOnForegroundReturn() }
self.backgroundActionOwed = false
self.noteVideoPresentationToHost()
}
}
lifecycleObservers.append(fgObserver)
Expand Down Expand Up @@ -8457,6 +8468,14 @@ public final class AetherEngine: ObservableObject {
func noteDidEnterBackground() {
isBackgrounded = true
backgroundActionOwed = backgroundDecisionMustWait
noteVideoPresentationToHost()
}

/// Tells the software host whether a refused video rebuild may be retried now. A backgrounded app with
/// picture-in-picture on still shows the picture, and the software host keeps decoding for it, so only
/// a background with no picture window counts as "nothing is showing video".
private func noteVideoPresentationToHost() {
softwareHost?.noteAppInBackground(isBackgrounded && !pictureInPictureActive)
}

/// Audit LIF-105: the PiP window closed while backgrounded, so nothing keeps the app running and
Expand Down
9 changes: 6 additions & 3 deletions Sources/AetherEngine/Audio/AudioOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ final class AudioOutput: @unchecked Sendable {
/// best practice: the notification arrives on an arbitrary thread, so a buffer enqueued
/// concurrently with it survives, and a survivor sits in the queue stamped far ahead of the
/// timebase, muting the session for as long as it takes the clock to reach it. Re-feeding from
/// the timebase is deliberately NOT attempted: the demuxer stands at the audio lead by then and
/// the sources this happens to are exactly the ones that cannot seek backwards, so the honest
/// outcome is a gap of up to that lead, and then sync as before.
/// the timebase is deliberately NOT attempted, on any source: the demuxer stands at the audio lead
/// by then, so it would take a seek, which cuts the audio that is still playing, to put the dropped
/// lead back. The outcome is a gap of up to that lead, and then sync as before. The video queue is
/// not touched by this flush, so the host does not react to it; it repositions only when its own
/// video queue was flushed and the picture would stay frozen for longer than the audio lead
/// (`FlushRecoveryPolicy`).
///
/// The line is also the witness the field log lacked. Across an automatic flush the timebase
/// keeps RUNNING at its rate, so a session that froze did not freeze because of this, and only a
Expand Down
Loading
Loading