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
30 changes: 30 additions & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,10 @@ export enum Events {
// (undocumented)
MEDIA_ENDED = "hlsMediaEnded",
// (undocumented)
MEDIA_FRAGMENT_END = "hlsMediaFragmentEnd",
// (undocumented)
MEDIA_FRAGMENT_PARSED = "hlsMediaFragmentParsed",
// (undocumented)
NON_NATIVE_TEXT_TRACKS_FOUND = "hlsNonNativeTextTracksFound",
// (undocumented)
PLAYOUT_LIMIT_REACHED = "hlsPlayoutLimitReached",
Expand Down Expand Up @@ -2337,6 +2341,7 @@ export type HlsConfig = {
capLevelController: typeof CapLevelController;
errorController: typeof ErrorController;
fpsController: typeof FPSController;
mediaFragmentController: typeof MediaFragmentController;
progressive: boolean;
lowLatencyMode: boolean;
primarySessionId?: string;
Expand Down Expand Up @@ -2493,6 +2498,12 @@ export interface HlsListeners {
// (undocumented)
[Events.MEDIA_ENDED]: (event: Events.MEDIA_ENDED, data: MediaEndedData) => void;
// (undocumented)
[Events.MEDIA_FRAGMENT_END]: (event: Events.MEDIA_FRAGMENT_END, data: {}) => void;
// Warning: (ae-forgotten-export) The symbol "MediaFragmentParsedData" needs to be exported by the entry point hls.d.ts
//
// (undocumented)
[Events.MEDIA_FRAGMENT_PARSED]: (event: Events.MEDIA_FRAGMENT_PARSED, data: MediaFragmentParsedData) => void;
// (undocumented)
[Events.NON_NATIVE_TEXT_TRACKS_FOUND]: (event: Events.NON_NATIVE_TEXT_TRACKS_FOUND, data: NonNativeTextTracksData) => void;
// (undocumented)
[Events.PLAYOUT_LIMIT_REACHED]: (event: Events.PLAYOUT_LIMIT_REACHED, data: {}) => void;
Expand Down Expand Up @@ -4036,6 +4047,15 @@ export interface MediaFragment extends Fragment {
sn: number;
}

// Warning: (ae-missing-release-tag) "MediaFragmentController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class MediaFragmentController extends Logger implements ComponentAPI {
constructor(hls: Hls);
// (undocumented)
destroy(): void;
}

// Warning: (ae-missing-release-tag) "MediaKeyFunc" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
Expand Down Expand Up @@ -4850,6 +4870,16 @@ export class TaskLoop extends Logger {
tickImmediate(): void;
}

// Warning: (ae-missing-release-tag) "TemporalFragment" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TemporalFragment {
// (undocumented)
end?: number;
// (undocumented)
start?: number;
}

// Warning: (ae-missing-release-tag) "TimelineController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import EMEController from './controller/eme-controller';
import ErrorController from './controller/error-controller';
import FPSController from './controller/fps-controller';
import InterstitialsController from './controller/interstitials-controller';
import MediaFragmentController from './controller/media-fragment-controller';
import { SubtitleStreamController } from './controller/subtitle-stream-controller';
import SubtitleTrackController from './controller/subtitle-track-controller';
import { TimelineController } from './controller/timeline-controller';
Expand Down Expand Up @@ -338,6 +339,7 @@ export type HlsConfig = {
capLevelController: typeof CapLevelController;
errorController: typeof ErrorController;
fpsController: typeof FPSController;
mediaFragmentController: typeof MediaFragmentController;
progressive: boolean;
lowLatencyMode: boolean;
primarySessionId?: string;
Expand Down Expand Up @@ -434,6 +436,7 @@ export const hlsDefaultConfig: HlsConfig = {
capLevelController: CapLevelController,
errorController: ErrorController,
fpsController: FPSController,
mediaFragmentController: MediaFragmentController,
stretchShortVideoTrack: false, // used by mp4-remuxer
maxAudioFramesDrift: 1, // used by mp4-remuxer
forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer
Expand Down
161 changes: 161 additions & 0 deletions src/controller/media-fragment-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Events } from '../events';
import { Logger } from '../utils/logger';
import { parseMediaFragment } from '../utils/media-fragment-parser';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
import type {
ManifestLoadingData,
MediaAttachingData,
MediaDetachingData,
} from '../types/events';

/**
* MediaFragmentController
*
* Handles W3C Media Fragments URI temporal dimension (#t=start,end).
* - Parses fragment from URL
* - Sets start position
* - Pauses at end time (one-time)
* - Removes listeners after pause
*/
export default class MediaFragmentController
extends Logger
implements ComponentAPI
{
private hls: Hls;
private media: HTMLMediaElement | null = null;
private fragmentEnd: number | null = null;
private endReached: boolean = false;
private _boundOnTimeUpdate: () => void;
private _boundOnSeeked: () => void;

constructor(hls: Hls) {
super('media-fragment', hls.logger);
this.hls = hls;
this._boundOnTimeUpdate = this.onTimeUpdate.bind(this);
this._boundOnSeeked = this.onSeeked.bind(this);
this.registerListeners();
}

private registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}

private unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}

private onManifestLoading(
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData,
) {
// Reset state at top, before early return
this.fragmentEnd = null;
this.endReached = false;
this.detachMediaListeners();
Comment thread
robwalch marked this conversation as resolved.
if (!data.url.includes('#')) {
return;
}
const hashIndex = data.url.indexOf('#');
const fragment = data.url.substring(hashIndex + 1);
let decodedFragment: string;
try {
decodedFragment = decodeURIComponent(fragment);
} catch (e) {
return;
}
const { temporalFragment } = parseMediaFragment(
data.url.substring(0, hashIndex + 1) + decodedFragment,
);
if (temporalFragment) {
if (temporalFragment.start !== undefined) {
this.hls.config.startPosition = temporalFragment.start;
}
if (temporalFragment.end !== undefined) {
this.fragmentEnd = temporalFragment.end;
}
this.hls.trigger(Events.MEDIA_FRAGMENT_PARSED, {
start: temporalFragment.start,
end: temporalFragment.end,
});
if (this.media && this.fragmentEnd !== null) {
this.attachMediaListeners();
}
}
}

private onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
this.media = data.media;
if (this.fragmentEnd !== null && !this.endReached) {
this.attachMediaListeners();
}
}

private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.detachMediaListeners();
this.media = null;
}

private attachMediaListeners() {
if (!this.media) {
return;
}
this.media.addEventListener('timeupdate', this._boundOnTimeUpdate);
this.media.addEventListener('seeked', this._boundOnSeeked);
}

private detachMediaListeners() {
if (this.media) {
this.media.removeEventListener('timeupdate', this._boundOnTimeUpdate);
this.media.removeEventListener('seeked', this._boundOnSeeked);
}
}

private onTimeUpdate() {
this.checkFragmentEnd();
}

private onSeeked() {
const { media } = this;
if (media) {
this.checkFragmentEnd(media.currentTime);
Comment thread
robwalch marked this conversation as resolved.
}
}

private checkFragmentEnd(seekTime?: number) {
const { media, fragmentEnd, endReached } = this;
if (!media || fragmentEnd === null || endReached) {
return;
}
const time = seekTime ?? media.currentTime;
if (time >= fragmentEnd && (!media.paused || seekTime !== undefined)) {
this.log(
`Reached media fragment end at ${time.toFixed(3)} (end: ${fragmentEnd.toFixed(3)})`,
);
this.endReached = true;
media.pause();
this.detachMediaListeners();
this.hls.trigger(Events.MEDIA_FRAGMENT_END, {});
}
}

destroy() {
this.unregisterListeners();
this.detachMediaListeners();
this.media = null;
// @ts-ignore
this.hls = null;
}
}
13 changes: 13 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
MediaDetachedData,
MediaDetachingData,
MediaEndedData,
MediaFragmentParsedData,
NonNativeTextTracksData,
SteeringManifestLoadedData,
SubtitleFragProcessedData,
Expand Down Expand Up @@ -220,6 +221,10 @@ export enum Events {
PLAYOUT_LIMIT_REACHED = 'hlsPlayoutLimitReached',
// Event DateRange cue "enter" event dispatched
EVENT_CUE_ENTER = 'hlsEventCueEnter',
// Media fragment end event dispatched
MEDIA_FRAGMENT_END = 'hlsMediaFragmentEnd',
// Media fragment parsed event dispatched
MEDIA_FRAGMENT_PARSED = 'hlsMediaFragmentParsed',
}

/**
Expand Down Expand Up @@ -496,6 +501,14 @@ export interface HlsListeners {
data: {},
) => void;
[Events.EVENT_CUE_ENTER]: (event: Events.EVENT_CUE_ENTER, data: {}) => void;
[Events.MEDIA_FRAGMENT_END]: (
event: Events.MEDIA_FRAGMENT_END,
data: {},
) => void;
[Events.MEDIA_FRAGMENT_PARSED]: (
event: Events.MEDIA_FRAGMENT_PARSED,
data: MediaFragmentParsedData,
) => void;
}
export interface HlsEventEmitter {
on<E extends keyof HlsListeners, Context = undefined>(
Expand Down
2 changes: 2 additions & 0 deletions src/exports-named.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ContentSteeringController from './controller/content-steering-controller'
import EMEController from './controller/eme-controller';
import ErrorController from './controller/error-controller';
import FPSController from './controller/fps-controller';
import MediaFragmentController from './controller/media-fragment-controller';
import SubtitleTrackController from './controller/subtitle-track-controller';
import Hls from './hls';
import M3U8Parser from './loader/m3u8-parser';
Expand All @@ -33,6 +34,7 @@ export {
EMEController,
ErrorController,
FPSController,
MediaFragmentController,
SubtitleTrackController,
XhrLoader,
FetchLoader,
Expand Down
11 changes: 10 additions & 1 deletion src/hls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type ErrorController from './controller/error-controller';
import type FPSController from './controller/fps-controller';
import type InterstitialsController from './controller/interstitials-controller';
import type { InterstitialsManager } from './controller/interstitials-controller';
import type MediaFragmentController from './controller/media-fragment-controller';
import type { SubtitleStreamController } from './controller/subtitle-stream-controller';
import type SubtitleTrackController from './controller/subtitle-track-controller';
import type Decrypter from './crypt/decrypter';
Expand Down Expand Up @@ -104,6 +105,7 @@ export default class Hls implements HlsEventEmitter {
private audioTrackController?: AudioTrackController;
private subtitleTrackController?: SubtitleTrackController;
private interstitialsController?: InterstitialsController;
private mediaFragmentController?: MediaFragmentController;
private gapController: GapController;
private emeController?: EMEController;
private cmcdController?: CMCDController;
Expand Down Expand Up @@ -316,6 +318,11 @@ export default class Hls implements HlsEventEmitter {
coreComponents,
);

this.mediaFragmentController = this.createController(
config.mediaFragmentController,
coreComponents,
);

this.coreComponents = coreComponents;

// Error controller handles errors before and after all other controllers
Expand Down Expand Up @@ -525,7 +532,7 @@ export default class Hls implements HlsEventEmitter {
this.attachMedia(media);
}
// when attaching to a source URL, trigger a playlist load
this.trigger(Events.MANIFEST_LOADING, { url: url });
this.trigger(Events.MANIFEST_LOADING, { url });
}

/**
Expand Down Expand Up @@ -1303,6 +1310,7 @@ export type {
ErrorController,
FPSController,
InterstitialsController,
MediaFragmentController,
StreamController,
SubtitleStreamController,
SubtitleTrackController,
Expand Down Expand Up @@ -1569,3 +1577,4 @@ export type {
RationalTimestamp,
TimestampOffset,
} from './utils/timescale-conversion';
export type { TemporalFragment } from './utils/media-fragment-parser';
5 changes: 5 additions & 0 deletions src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,8 @@ export interface InterstitialsPrimaryResumed {
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
}

export interface MediaFragmentParsedData {
start?: number;
end?: number;
}
Loading
Loading