refactor(web): extract WebTrackHandler and shared video.js types

This commit is contained in:
Kamil Moskała
2026-03-25 08:49:03 +01:00
parent e1c705311b
commit e6d02089b4
5 changed files with 191 additions and 162 deletions
@@ -17,47 +17,13 @@ import { MediaSessionHandler } from "./web/MediaSession";
import { WebEventEmitter } from "./web/WebEventEmitter";
import type { VideoTrack } from "./types/VideoTrack";
import type { QualityLevel } from "./types/QualityLevel";
type VideoJsPlayer = ReturnType<typeof videojs>;
// declared https://github.com/videojs/video.js/blob/main/src/js/tracks/track-list.js#L58
export type VideoJsTextTracks = {
length: number;
[i: number]: {
// declared: https://github.com/videojs/video.js/blob/main/src/js/tracks/track.js
id: string;
label: string;
language: string;
// declared https://github.com/videojs/video.js/blob/20f8d76cd24325a97ccedf0b013cd1a90ad0bcd7/src/js/tracks/text-track.js
default: boolean;
mode: "showing" | "disabled" | "hidden";
};
};
export type VideoJsTracks = {
length: number;
[i: number]: {
id: string;
label: string;
language: string;
enabled: boolean;
};
};
// declared https://github.com/videojs/videojs-contrib-quality-levels/blob/main/src/quality-level.js#L32
export type VideoJsQualityArray = {
length: number;
selectedIndex: number;
[i: number]: {
id: string;
label: string;
width: number;
height: number;
bitrate: number;
frameRate: number;
enabled: boolean;
};
};
import {
mapVideoJsTracks,
type VideoJsPlayer,
type VideoJsTextTracks,
type VideoJsTracks,
type VideoJsQualityArray,
} from "./web/WebVideoJsTypes";
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
protected video: HTMLVideoElement;
@@ -318,11 +284,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTextTracks = this.player.textTracks();
return [...Array(tracks.length)].map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.mode === "showing",
return mapVideoJsTracks(tracks, (track) => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.mode === "showing",
}));
}
@@ -347,11 +313,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTracks = this.player.audioTracks();
return [...Array(tracks.length)].map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.enabled,
return mapVideoJsTracks(tracks, (track) => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.enabled,
}));
}
@@ -374,11 +340,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTracks = this.player.videoTracks();
return [...Array(tracks.length)].map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.enabled,
return mapVideoJsTracks(tracks, (track) => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.enabled,
}));
}
@@ -400,11 +366,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
getAvailableQualities(): QualityLevel[] {
// @ts-expect-error this isn't typed
const levels: VideoJsQualityArray = this.player.qualityLevels();
return [...Array(levels.length)].map((_, i) => ({
id: levels[i]!.id,
width: levels[i]!.width,
height: levels[i]!.height,
bitrate: levels[i]!.bitrate,
return mapVideoJsTracks(levels, (level, i) => ({
id: level.id,
width: level.width,
height: level.height,
bitrate: level.bitrate,
selected: levels.selectedIndex === i,
}));
}
@@ -1,7 +1,5 @@
import type videojs from "video.js";
import type { CustomVideoMetadata } from "../types/VideoConfig";
type VideoJsPlayer = ReturnType<typeof videojs>;
import type { VideoJsPlayer } from "./WebVideoJsTypes";
function getMediaSession(): MediaSession | undefined {
if (typeof window === "undefined") return undefined;
@@ -1,4 +1,3 @@
import type videojs from "video.js";
import type {
BandwidthData,
onLoadData,
@@ -19,19 +18,19 @@ import {
} from "../types/VideoError";
import type { VideoPlayerStatus } from "../types/VideoPlayerStatus";
import type { AudioTrack } from "../types/AudioTrack";
import type { VideoJsTracks, VideoJsQualityArray } from "../VideoPlayer.web";
import type { VideoTrack } from "../types/VideoTrack";
import type { QualityLevel } from "../types/QualityLevel";
import type {
ListenerSubscription,
VideoPlayerEventEmitterBase,
} from "../types/EventEmitter";
type VideoJsPlayer = ReturnType<typeof videojs>;
import type { VideoJsPlayer } from "./WebVideoJsTypes";
import { attachTrackHandlers } from "./WebTrackHandler";
export class WebEventEmitter implements VideoPlayerEventEmitterBase {
private _isBuffering = false;
private _listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private detachTracks: () => void;
constructor(private player: VideoJsPlayer) {
// TODO: add `onBandwidthUpdate`
@@ -84,18 +83,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
this._onError = this._onError.bind(this);
this.player.on("error", this._onError);
this._onTextTrackChange = this._onTextTrackChange.bind(this);
this.player.textTracks().on("change", this._onTextTrackChange);
this._onAudioTrackChange = this._onAudioTrackChange.bind(this);
this.player.audioTracks().on("change", this._onAudioTrackChange);
this._onVideoTrackChange = this._onVideoTrackChange.bind(this);
this.player.videoTracks().on("change", this._onVideoTrackChange);
this._onQualityChange = this._onQualityChange.bind(this);
// @ts-expect-error this isn't typed
this.player.qualityLevels().on("change", this._onQualityChange);
this.detachTracks = attachTrackHandlers(player, this._emit.bind(this));
}
destroy() {
@@ -123,14 +111,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
this.player.off("error", this._onError);
this.player.textTracks().off("change", this._onTextTrackChange);
this.player.audioTracks().off("change", this._onAudioTrackChange);
this.player.videoTracks().off("change", this._onVideoTrackChange);
// @ts-expect-error this isn't typed
this.player.qualityLevels().off("change", this._onQualityChange);
this.detachTracks();
}
private _addListener(
@@ -288,25 +269,25 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
return this._addListener("onQualityChange", listener);
}
_onTimeUpdate() {
private _onTimeUpdate() {
this._emit("onProgress", {
currentTime: this.player.currentTime() ?? 0,
bufferDuration: this.player.bufferedEnd(),
});
}
_onCanPlay() {
private _onCanPlay() {
this._isBuffering = false;
this._emit("onBuffer", false);
this._emit("onStatusChange", "readyToPlay");
}
_onWaiting() {
private _onWaiting() {
this._isBuffering = true;
this._emit("onBuffer", true);
this._emit("onStatusChange", "loading");
}
_onDurationChange() {
private _onDurationChange() {
this._emit("onLoad", {
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? NaN,
@@ -316,12 +297,12 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
});
}
_onEnded() {
private _onEnded() {
this._emit("onEnd");
this._emit("onStatusChange", "idle");
}
_onLoadStart() {
private _onLoadStart() {
this._emit("onLoadStart", {
sourceType: "network",
source: {
@@ -346,40 +327,40 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
});
}
_onPlay() {
private _onPlay() {
this._emit("onPlaybackStateChange", {
isPlaying: true,
isBuffering: this._isBuffering,
});
}
_onPause() {
private _onPause() {
this._emit("onPlaybackStateChange", {
isPlaying: false,
isBuffering: this._isBuffering,
});
}
_onRateChange() {
private _onRateChange() {
this._emit("onPlaybackRateChange", this.player.playbackRate() ?? 1);
}
_onLoadedData() {
private _onLoadedData() {
this._emit("onReadyToDisplay");
}
_onSeeked() {
private _onSeeked() {
this._emit("onSeek", this.player.currentTime() ?? 0);
}
_onVolumeChange() {
private _onVolumeChange() {
this._emit("onVolumeChange", {
muted: this.player.muted() ?? false,
volume: this.player.volume() ?? 1,
});
}
_onError() {
private _onError() {
this._emit("onStatusChange", "error");
const err = this.player.error();
if (!err) {
@@ -402,65 +383,4 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
>;
this._emit("onError", new VideoError(codeMap[err.code] ?? "unknown/unknown", err.message));
}
_onTextTrackChange() {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTextTracks = this.player.textTracks();
const selected = [...Array(tracks.length)]
.map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.mode === "showing",
}))
.find((x) => x.selected);
this._emit("onTrackChange", selected ?? null);
}
_onAudioTrackChange() {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTracks = this.player.audioTracks();
const selected = [...Array(tracks.length)]
.map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.enabled,
}))
.find((x) => x.selected);
this._emit("onAudioTrackChange", selected ?? null);
}
_onVideoTrackChange() {
// @ts-expect-error they define length & index properties via prototype
const tracks: VideoJsTracks = this.player.videoTracks();
const selected = [...Array(tracks.length)]
.map((_, i) => ({
id: tracks[i]!.id,
label: tracks[i]!.label,
language: tracks[i]!.language,
selected: tracks[i]!.enabled,
}))
.find((x) => x.selected);
this._emit("onVideoTrackChange", selected ?? null);
}
_onQualityChange() {
// @ts-expect-error this isn't typed
const levels: VideoJsQualityArray = this.player.qualityLevels();
if (levels.selectedIndex < 0) return;
const quality = levels[levels.selectedIndex];
if (!quality) return;
this._emit("onQualityChange", {
id: quality.id,
width: quality.width,
height: quality.height,
bitrate: quality.bitrate,
selected: true,
});
}
}
@@ -0,0 +1,93 @@
import type { AudioTrack } from "../types/AudioTrack";
import type { QualityLevel } from "../types/QualityLevel";
import type { TextTrack } from "../types/TextTrack";
import type { VideoTrack } from "../types/VideoTrack";
import {
mapVideoJsTracks,
type VideoJsPlayer,
type VideoJsQualityArray,
type VideoJsTextTracks,
type VideoJsTracks,
} from "./WebVideoJsTypes";
type EmitFn = (event: string, ...args: unknown[]) => void;
/**
* Attaches track change listeners to a video.js player and returns a cleanup function.
* Handles text, audio, video track changes and quality level changes.
*/
export function attachTrackHandlers(
player: VideoJsPlayer,
emit: EmitFn,
): () => void {
const onTextTrackChange = () => {
// @ts-expect-error video.js defines length & index properties via prototype
const tracks: VideoJsTextTracks = player.textTracks();
const selected = mapVideoJsTracks(tracks, (track): TextTrack => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.mode === "showing",
})).find((x) => x.selected);
emit("onTrackChange", selected ?? null);
};
const onAudioTrackChange = () => {
// @ts-expect-error video.js defines length & index properties via prototype
const tracks: VideoJsTracks = player.audioTracks();
const selected = mapVideoJsTracks(tracks, (track): AudioTrack => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.enabled,
})).find((x) => x.selected);
emit("onAudioTrackChange", selected ?? null);
};
const onVideoTrackChange = () => {
// @ts-expect-error video.js defines length & index properties via prototype
const tracks: VideoJsTracks = player.videoTracks();
const selected = mapVideoJsTracks(tracks, (track): VideoTrack => ({
id: track.id,
label: track.label,
language: track.language,
selected: track.enabled,
})).find((x) => x.selected);
emit("onVideoTrackChange", selected ?? null);
};
const onQualityChange = () => {
// @ts-expect-error qualityLevels is from videojs-contrib-quality-levels plugin
const levels: VideoJsQualityArray = player.qualityLevels();
if (levels.selectedIndex < 0) return;
const quality = levels[levels.selectedIndex];
if (!quality) return;
emit("onQualityChange", {
id: quality.id,
width: quality.width,
height: quality.height,
bitrate: quality.bitrate,
selected: true,
} satisfies QualityLevel);
};
// Attach listeners
player.textTracks().on("change", onTextTrackChange);
player.audioTracks().on("change", onAudioTrackChange);
player.videoTracks().on("change", onVideoTrackChange);
// @ts-expect-error qualityLevels is from videojs-contrib-quality-levels plugin
player.qualityLevels().on("change", onQualityChange);
// Return cleanup function
return () => {
player.textTracks().off("change", onTextTrackChange);
player.audioTracks().off("change", onAudioTrackChange);
player.videoTracks().off("change", onVideoTrackChange);
// @ts-expect-error qualityLevels is from videojs-contrib-quality-levels plugin
player.qualityLevels().off("change", onQualityChange);
};
}
@@ -0,0 +1,52 @@
import type videojs from "video.js";
export type VideoJsPlayer = ReturnType<typeof videojs>;
export type VideoJsTextTracks = {
length: number;
[i: number]: {
id: string;
label: string;
language: string;
default: boolean;
mode: "showing" | "disabled" | "hidden";
};
};
export type VideoJsTracks = {
length: number;
[i: number]: {
id: string;
label: string;
language: string;
enabled: boolean;
};
};
export type VideoJsQualityArray = {
length: number;
selectedIndex: number;
[i: number]: {
id: string;
label: string;
width: number;
height: number;
bitrate: number;
frameRate: number;
enabled: boolean;
};
};
export function mapVideoJsTracks<T, R>(
tracks: { length: number; [i: number]: T | undefined },
mapper: (track: T, index: number) => R,
): R[] {
const result: R[] = [];
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (track) {
result.push(mapper(track, i));
}
}
return result;
}