Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ the public-API contract.

### Added

- Opt-in external subtitle preparation and single-use commit/discard: decode a bounded local text sidecar before changing the active selection. Configurable preparation limits and owner/session/selection-intent checks preserve the previous selection on failure; commit performs no file read.

- Secondary ASS sidecars honor `preserveASSMarkup` and publish their own `secondarySidecarASSHeader`, independently cleared with the channel and rebuilt on reload. Default text/rich-text output is unchanged.

- **`LiveTelemetry.objectAudio`: how many Atmos objects play, and how many overhead.** While
`objectAudioRendering` plays TrueHD Atmos or DTS:X (a build with the `TrueHDObjectAudio` /
`DTSXObjectAudio` traits; without them it stays nil), the 1 Hz snapshot carries an
Expand Down
20 changes: 17 additions & 3 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ extension AetherEngine {
/// no xsub decoder, so such a track selects and then decodes to nothing; it is deliberately not
/// claimed anywhere in the documentation.
public func selectSubtitleTrack(index: Int) {
externalSubtitleIntentGeneration &+= 1
hostExplicitSubtitleAction = true
selectSubtitleTrack(index: index, startAt: sourceTime)
// Sodalite#156: while the picture is off this device the RENDITION is the display, so a pick
Expand Down Expand Up @@ -169,6 +170,7 @@ extension AetherEngine {

/// Activate an embedded subtitle stream as the secondary companion track (issue #47). Text-only; bitmap codecs are rejected. Runs a second side demuxer concurrently. External ids (#88) route onto the secondary sidecar decode.
public func selectSecondarySubtitleTrack(index: Int) {
externalSubtitleIntentGeneration &+= 1
selectSecondarySubtitleTrack(index: index, startAt: sourceTime)
}

Expand Down Expand Up @@ -196,6 +198,7 @@ extension AetherEngine {
// own channel; the immediate tick backfills it.
isSecondarySubtitleActive = true
secondarySubtitleCues = []
secondarySidecarASSHeader = nil
pgsStaleArrivalGates[.secondary]?.reset() // #100
activeSecondaryEmbeddedSubtitleStreamIndex = Int32(index)
subtitleDrainTargets[.secondary] = Int32(index)
Expand Down Expand Up @@ -1506,6 +1509,7 @@ extension AetherEngine {
/// Unregister an external track: delist + drop the registry entry; an active selection
/// (primary or secondary) is cleared. Embedded ids no-op.
public func removeExternalSubtitleTrack(id: Int) {
externalSubtitleIntentGeneration &+= 1
guard externalSubtitleRegistry.removeValue(forKey: id) != nil else { return }
subtitleTracks.removeAll { $0.id == id }
if activeSubtitleTrackIndex == id { clearSubtitle() }
Expand All @@ -1514,6 +1518,7 @@ extension AetherEngine {

/// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil forwards `LoadOptions.httpHeaders` (same auth as the media, #32). Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use.
public func selectSidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) {
externalSubtitleIntentGeneration &+= 1
hostExplicitSubtitleAction = true
startSidecarDecode(url: url, httpHeaders: httpHeaders, externalTrackID: nil)
}
Expand Down Expand Up @@ -1548,14 +1553,15 @@ extension AetherEngine {

let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders
// ASS/SSA sidecars honour preserveASSMarkup so hosts can drive a styled renderer. SRT/VTT fall back to plain text regardless.
let preparationPolicy = externalTrackID.flatMap { externalSubtitleRegistry[$0] }?.preparationPolicy
let preserveASS = loadedOptions.preserveASSMarkup
sidecarTask = Task { [weak self] in
let result: SidecarDecodeResult
do {
result = try await SubtitleDecoder.decodeFile(
url: url, httpHeaders: effectiveHeaders,
preserveASSMarkup: preserveASS,
sourceStreamIndex: sourceStreamIndex
sourceStreamIndex: sourceStreamIndex, preparationPolicy: preparationPolicy
)
} catch {
EngineLog.emit("[AetherEngine] sidecar decode failed: \(error)", category: .engine)
Expand Down Expand Up @@ -1595,6 +1601,7 @@ extension AetherEngine {

/// Decode a sidecar as the secondary companion track (issue #47), independent of the primary.
public func selectSecondarySidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) {
externalSubtitleIntentGeneration &+= 1
hostExplicitSubtitleAction = true
cancelSidecarTask(channel: .secondary)
clearSubtitleDrainTarget(channel: .secondary, reason: .secondarySidecarSelected) // #112 rework
Expand All @@ -1609,16 +1616,19 @@ extension AetherEngine {
loadedSecondarySidecarURL = url
isSecondarySubtitleActive = true
secondarySubtitleCues = []
secondarySidecarASSHeader = nil
pgsStaleArrivalGates[.secondary]?.reset() // #100
isLoadingSecondarySubtitles = true

let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders
let preserveASS = loadedOptions.preserveASSMarkup
let preparationPolicy = activeSecondaryExternalSubtitleTrackID.flatMap { externalSubtitleRegistry[$0] }?.preparationPolicy
secondarySidecarTask = Task { [weak self] in
let result: SidecarDecodeResult
do {
// Secondary is plain text only (never drives libass, mirroring embedded secondary #47).
result = try await SubtitleDecoder.decodeFile(
url: url, httpHeaders: effectiveHeaders, sourceStreamIndex: sourceStreamIndex)
url: url, httpHeaders: effectiveHeaders, preserveASSMarkup: preserveASS,
sourceStreamIndex: sourceStreamIndex, preparationPolicy: preparationPolicy)
} catch {
EngineLog.emit("[AetherEngine] secondary sidecar decode failed: \(error)", category: .engine)
await MainActor.run {
Expand All @@ -1630,6 +1640,7 @@ extension AetherEngine {
await MainActor.run {
guard !Task.isCancelled, let self = self else { return }
guard self.isSecondarySubtitleActive else { return }
self.secondarySidecarASSHeader = result.assHeader
self.secondarySubtitleCues = result.cues
self.isLoadingSecondarySubtitles = false
}
Expand All @@ -1638,6 +1649,7 @@ extension AetherEngine {

/// Disable primary subtitles, clear cues, cancel sidecar task + side demuxer, cancel multi-decode reader, clear native mov_text stores (#55, all-tracks). `nativeSubtitleTracks` is NOT cleared: the host needs the list to re-select after an audio/subtitle switch; only `stop()` / `load()` reset it.
public func clearSubtitle() {
externalSubtitleIntentGeneration &+= 1
hostExplicitSubtitleAction = true
// AE#359: subtitles off ends the rendition poll. The renditions themselves stay listed, only
// the fetching stops, so re-selecting the track starts fresh from the current window.
Expand Down Expand Up @@ -1706,6 +1718,7 @@ extension AetherEngine {
/// Turn the secondary subtitle off and clear its cues. Tears down
/// the secondary sidecar decode task and the secondary side reader.
public func clearSecondarySubtitle() {
externalSubtitleIntentGeneration &+= 1
hostExplicitSubtitleAction = true
cancelSidecarTask(channel: .secondary)
clearSubtitleDrainTarget(channel: .secondary, reason: .subtitlesCleared) // #112 rework
Expand All @@ -1714,6 +1727,7 @@ extension AetherEngine {
loadedSecondarySidecarURL = nil
isSecondarySubtitleActive = false
secondarySubtitleCues = []
secondarySidecarASSHeader = nil
pgsStaleArrivalGates[.secondary]?.reset() // #100
isLoadingSecondarySubtitles = false
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,10 @@ public final class AetherEngine: ObservableObject {
/// Nil for SRT/VTT and when markup preservation is off.
@Published public internal(set) var sidecarASSHeader: String? = nil

/// ASS script header for the active secondary sidecar when preserveASSMarkup is enabled.
/// Independent of the primary header; nil for other formats and after clearing or stopping.
@Published public internal(set) var secondarySidecarASSHeader: String? = nil

/// Cues for the secondary subtitle track (#47). Text-only (bitmap rejected); independent of primary.
@Published public internal(set) var secondarySubtitleCues: [SubtitleCue] = []
@Published public internal(set) var isLoadingSecondarySubtitles: Bool = false
Expand Down Expand Up @@ -2121,6 +2125,8 @@ public final class AetherEngine: ObservableObject {
/// every suspension point. Without it, a load suspended at the probe/criteria/session-start could resume
/// after a newer load, orphan the successor's producer+loopback server, and resurrect playback after
/// dismissal. A superseded load throws CancellationError at the first checkpoint.
/// Shared primary/secondary selection intent invalidates prepared candidates.
var externalSubtitleIntentGeneration: UInt64 = 0
var loadGeneration: UInt64 = 0

/// Distinct across engine instances and refreshed whenever teardown invalidates preview work.
Expand Down Expand Up @@ -7819,6 +7825,7 @@ public final class AetherEngine: ObservableObject {
subtitleCues = []
pgsStaleArrivalGates = [:] // #100: both channels; a hold never survives the session
sidecarASSHeader = nil
secondarySidecarASSHeader = nil
isLoadingSubtitles = false
nativeSubtitleTrackTable = []
nativeSubtitleReapplyOrdinal = nil
Expand Down
Loading
Loading