Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4620,6 +4620,8 @@ export interface SubtitleFragProcessedData {
// (undocumented)
frag: Fragment;
// (undocumented)
part: Part | null;
// (undocumented)
success: boolean;
}

Expand Down
4 changes: 0 additions & 4 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,10 +1127,6 @@ export default class BaseStreamController
// Buffer must be ahead of first part + duration of parts after last segment
// and playback must be at or past segment adjacent to part list
const firstPart = details.partList[0];
// Loading of VTT subtitle parts is not implemented in subtitle-stream-controller (#7460)
if (firstPart.fragment.type === PlaylistLevelType.SUBTITLE) {
return false;
}
const safePartStart =
firstPart.end + (details.fragmentHint?.duration || 0);
if (bufferEnd >= safePartStart) {
Expand Down
158 changes: 56 additions & 102 deletions src/controller/subtitle-stream-controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import BaseStreamController, { State } from './base-stream-controller';
import { findFragmentByPTS } from './fragment-finders';
import { FragmentState } from './fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
Expand Down Expand Up @@ -48,7 +47,7 @@ export class SubtitleStreamController
implements NetworkComponentAPI
{
private currentTrackId: number = -1;
private tracksBuffered: Array<TimeRange[]> = [];
private tracksBuffered: Array<TimeRange[] | undefined> = [];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anywhere where we are appending an undefined object to tracksBuffered? Do we suspect we need this?

Copy link
Collaborator Author

@robwalch robwalch Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't append undefined values, but we do not use noUncheckedIndexedAccess either. When using (@typescript-eslint/) no-unnecessary-condition (I use this offline, but not in the project proper), typing this way helps by signaling that the result to tracksBuffered[<missing-key-or-index>] may be undefined. Yes, it is bad typing, but simpler safety. I'm open to suggestions - short and long term.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I think this is fine for now. And agree maybe something to look into in the future.

private mainDetails: LevelDetails | null = null;

constructor(
Expand Down Expand Up @@ -128,13 +127,7 @@ export class SubtitleStreamController
event: Events.SUBTITLE_FRAG_PROCESSED,
data: SubtitleFragProcessed,
) {
const { frag, success } = data;
if (!this.fragContextChanged(frag)) {
if (isMediaFragment(frag)) {
this.fragPrevious = frag;
}
this.state = State.IDLE;
}
const { frag, part, success } = data;
if (!success) {
return;
}
Expand All @@ -147,28 +140,32 @@ export class SubtitleStreamController
// Create/update a buffered array matching the interface used by BufferHelper.bufferedInfo
// so we can re-use the logic used to detect how much has been buffered
let timeRange: TimeRange | undefined;
const fragStart = frag.start;
const start = (part || frag).start;
for (let i = 0; i < buffered.length; i++) {
if (fragStart >= buffered[i].start && fragStart <= buffered[i].end) {
if (start >= buffered[i].start && start <= buffered[i].end) {
timeRange = buffered[i];
break;
}
}

const fragEnd = frag.start + frag.duration;
const end = start + (part || frag).duration;
if (timeRange) {
timeRange.end = fragEnd;
timeRange.end = end;
} else {
timeRange = {
start: fragStart,
end: fragEnd,
};
timeRange = { start, end };
buffered.push(timeRange);
}
this.fragmentTracker.fragBuffered(frag as MediaFragment);
this.fragBufferedComplete(frag, null);
if (this.media) {
this.tick();
if (!part || end >= frag.end) {
this.fragmentTracker.fragBuffered(frag as MediaFragment);
if (!this.fragContextChanged(frag)) {
if (isMediaFragment(frag)) {
this.fragPrevious = frag;
}
}
this.fragBufferedComplete(frag, part);
if (this.media) {
this.tickImmediate();
}
}
}

Expand All @@ -184,6 +181,7 @@ export class SubtitleStreamController
}
data.endOffsetSubtitles = Math.max(0, endOffsetSubtitles);
this.tracksBuffered.forEach((buffered) => {
if (!buffered) return;
for (let i = 0; i < buffered.length; ) {
if (buffered[i].end <= endOffsetSubtitles) {
buffered.shift();
Expand Down Expand Up @@ -259,13 +257,13 @@ export class SubtitleStreamController
}

// Check if track has the necessary details to load fragments
const currentTrack = this.levels[this.currentTrackId];
if (currentTrack?.details) {
this.mediaBuffer = this.mediaBufferTimeRanges;
} else {
const currentTrack = this.levels[this.currentTrackId] as Level | undefined;
if (!currentTrack?.details) {
this.mediaBuffer = null;
return;
}
if (currentTrack && this.state !== State.STOPPED) {
this.mediaBuffer = this.mediaBufferTimeRanges;
if (this.state !== State.STOPPED) {
this.setInterval(TICK_INTERVAL);
}
}
Expand All @@ -281,7 +279,7 @@ export class SubtitleStreamController
this.warn(`Subtitle tracks were reset while loading level ${trackId}`);
return;
}
const track: Level = levels[trackId];
const track = levels[trackId] as Level | undefined;
if (trackId >= levels.length || !track) {
return;
}
Expand Down Expand Up @@ -346,26 +344,7 @@ export class SubtitleStreamController
});

// trigger handler right now
this.tick();

// If playlist is misaligned because of bad PDT or drift, delete details to resync with main on reload
if (
newDetails.live &&
!this.fragCurrent &&
this.media &&
this.state === State.IDLE
) {
const foundFrag = findFragmentByPTS(
null,
newDetails.fragments,
this.media.currentTime,
0,
);
if (!foundFrag) {
this.warn('Subtitle playlist not aligned with playback');
track.details = undefined;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for streaming subtitles in LL-HLS has become much more lenient 👍

Copy link
Collaborator Author

@robwalch robwalch Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional changes in #7482 will help to ensure proper playlist alignment. When combined with these changes, audio and subtitle segment loading will follow more of the same logic.

}
}
this.tickImmediate();
}

_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
Expand Down Expand Up @@ -423,18 +402,23 @@ export class SubtitleStreamController
}

doTick() {
if (!this.media) {
this.state = State.IDLE;
return;
}

if (this.state === State.IDLE) {
const { currentTrackId, levels } = this;
const track = levels?.[currentTrackId];
if (!track || !levels.length || !track.details) {
if (
!this.media &&
!this.primaryPrefetch &&
(this.startFragRequested || !this.config.startFragPrefetch)
) {
return;
}
if (this.waitForLive(track)) {
const { currentTrackId, levels } = this;
const track = levels?.[currentTrackId];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before there was a check to see if track is undefined, now it's removed here. I believe this may cause issues. We may need to keep what was here previously:
if (!track || !levels.length || !track.details) { return; }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're covered by track?.details and !trackDetails. No details, no track.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. that's true that the original code had that: !track.details

const trackDetails = track?.details;
if (
!trackDetails ||
this.waitForLive(track) ||
this.waitForCdnTuneIn(trackDetails)
) {
this.startFragRequested = false;
return;
}
const { config } = this;
Expand All @@ -445,62 +429,32 @@ export class SubtitleStreamController
config.maxBufferHole,
);
const { end: targetBufferTime, len: bufferLen } = bufferedInfo;
const trackDetails = track.details as LevelDetails;
const maxBufLen =
this.hls.maxBufferLength + trackDetails.levelTargetDuration;

if (bufferLen > maxBufLen) {
if (bufferLen > maxBufLen || (bufferLen && !this.buffering)) {
return;
}
const fragments = trackDetails.fragments;
const fragLen = fragments.length;
const end = trackDetails.edge;

let foundFrag: MediaFragment | null = null;
const fragPrevious = this.fragPrevious;
if (targetBufferTime < end) {
const tolerance = config.maxFragLookUpTolerance;
const lookupTolerance =
targetBufferTime > end - tolerance ? 0 : tolerance;
foundFrag = findFragmentByPTS(
fragPrevious,
fragments,
Math.max(fragments[0].start, targetBufferTime),
lookupTolerance,
);
if (
!foundFrag &&
fragPrevious &&
fragPrevious.start < fragments[0].start
) {
foundFrag = fragments[0];
}
} else {
foundFrag = fragments[fragLen - 1];
}
foundFrag = this.filterReplacedPrimary(foundFrag, track.details);
if (!foundFrag) {
let frag = this.getNextFragment(currentTime, trackDetails);
if (!frag) {
return;
}
// Load earlier fragment in same discontinuity to make up for misaligned playlists and cues that extend beyond end of segment
const curSNIdx = foundFrag.sn - trackDetails.startSN;
const prevFrag = fragments[curSNIdx - 1];
if (
prevFrag &&
prevFrag.cc === foundFrag.cc &&
this.fragmentTracker.getState(prevFrag) === FragmentState.NOT_LOADED
) {
foundFrag = prevFrag;
}
if (
this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED
) {
// only load if fragment is not loaded
const fragToLoad = this.mapToInitFragWhenRequired(foundFrag);
if (fragToLoad) {
this.loadFragment(fragToLoad, track, targetBufferTime);
if (isMediaFragment(frag)) {
const curSNIdx = frag.sn - trackDetails.startSN;
const prevFrag = trackDetails.fragments[curSNIdx - 1] as
| MediaFragment
| undefined;
if (
prevFrag &&
prevFrag.cc === frag.cc &&
!trackDetails.partList?.length &&
this.fragmentTracker.getState(prevFrag) === FragmentState.NOT_LOADED
) {
frag = prevFrag;
}
}
this.loadFragment(frag, track, targetBufferTime);
}
}

Expand Down
1 change: 0 additions & 1 deletion src/controller/subtitle-track-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,6 @@ class SubtitleTrackController extends BasePlaylistController {
// and we'll set subtitleTrack when onMediaAttached is triggered
if (!this.media) {
this.queuedDefaultTrack = newId;
return;
}

// exit if track id as already set or invalid
Expand Down
Loading
Loading