Add quality selector

This commit is contained in:
2025-10-20 01:24:18 +02:00
parent 2e856249b3
commit aa2921c8b4
6 changed files with 131 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ import { createSource } from "./utils/sourceFactory";
import { VideoPlayerEvents } from "./VideoPlayerEvents"; import { VideoPlayerEvents } from "./VideoPlayerEvents";
import type { AudioTrack } from "./types/AudioTrack"; import type { AudioTrack } from "./types/AudioTrack";
import type { VideoTrack } from "./types/VideoTrack"; import type { VideoTrack } from "./types/VideoTrack";
import type { QualityLevel } from "./types/QualityLevel";
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
protected player: VideoPlayerImpl; protected player: VideoPlayerImpl;
@@ -308,6 +309,22 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
get selectedVideoTrack(): VideoTrack | undefined { get selectedVideoTrack(): VideoTrack | undefined {
return undefined; return undefined;
} }
// quality
getAvailableQualities(): QualityLevel[] {
return [];
}
selectQuality(_: QualityLevel | null): void {}
get currentQuality(): QualityLevel | undefined {
return undefined;
}
get autoQualityEnabled(): boolean {
return true;
}
} }
export { VideoPlayer }; export { VideoPlayer };

View File

@@ -17,6 +17,7 @@ import { VideoPlayerEvents } from "./VideoPlayerEvents";
import { MediaSessionHandler } from "./web/MediaSession"; import { MediaSessionHandler } from "./web/MediaSession";
import { WebEventEmiter } from "./web/WebEventEmiter"; import { WebEventEmiter } from "./web/WebEventEmiter";
import type { VideoTrack } from "./types/VideoTrack"; import type { VideoTrack } from "./types/VideoTrack";
import type { QualityLevel } from "./types/QualityLevel";
type VideoJsPlayer = ReturnType<typeof videojs>; type VideoJsPlayer = ReturnType<typeof videojs>;
@@ -44,6 +45,21 @@ export type VideoJsTracks = {
}; };
}; };
// 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;
};
};
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
protected video: HTMLVideoElement; protected video: HTMLVideoElement;
public player: VideoJsPlayer; public player: VideoJsPlayer;
@@ -52,7 +68,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
constructor(source: VideoSource | VideoConfig | VideoPlayerSource) { constructor(source: VideoSource | VideoConfig | VideoPlayerSource) {
const video = document.createElement("video"); const video = document.createElement("video");
const player = videojs(video); const player = videojs(video, { qualityLevels: true });
super(new WebEventEmiter(player)); super(new WebEventEmiter(player));
@@ -355,6 +371,43 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
get selectedVideoTrack(): VideoTrack | undefined { get selectedVideoTrack(): VideoTrack | undefined {
return this.getAvailableVideoTracks().find((x) => x.selected); return this.getAvailableVideoTracks().find((x) => x.selected);
} }
// quality
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,
selected: levels.selectedIndex === i,
}));
}
selectQuality(quality: QualityLevel | null): void {
// @ts-expect-error this isn't typed
const levels: VideoJsQualityArray = this.player.qualityLevels();
for (let i = 0; i < levels.length; i++) {
// if quality is null, enable back auto-quality switch (so enable all lvls)
levels[i]!.enabled = !quality || levels[i]!.id === quality.id;
}
}
get currentQuality(): QualityLevel | undefined {
return this.getAvailableQualities().find((x) => x.selected);
}
get autoQualityEnabled(): boolean {
// @ts-expect-error this isn't typed
const levels: VideoJsQualityArray = this.player.qualityLevels();
// if we have a quality disabled that means we manually disabled it & disabled auto quality
for (let i = 0; i < levels.length; i++) {
if (!levels[i]!.enabled) return false;
}
return true;
}
} }
export { VideoPlayer }; export { VideoPlayer };

View File

@@ -1,4 +1,5 @@
import type { AudioTrack } from './AudioTrack'; import type { AudioTrack } from './AudioTrack';
import type { QualityLevel } from './QualityLevel';
import type { TextTrack } from './TextTrack'; import type { TextTrack } from './TextTrack';
import type { VideoRuntimeError } from './VideoError'; import type { VideoRuntimeError } from './VideoError';
import type { VideoOrientation } from './VideoOrientation'; import type { VideoOrientation } from './VideoOrientation';
@@ -70,6 +71,10 @@ export interface VideoPlayerEvents {
* Called when the player progress changes. * Called when the player progress changes.
*/ */
onProgress: (data: onProgressData) => void; onProgress: (data: onProgressData) => void;
/**
* Called when the player quality changes.
*/
onQualityChange: (quality: QualityLevel) => void;
/** /**
* Called when the video is ready to display. * Called when the video is ready to display.
*/ */
@@ -273,6 +278,7 @@ export const ALL_PLAYER_EVENTS: (keyof AllPlayerEvents)[] =
'onPlaybackStateChange', 'onPlaybackStateChange',
'onPlaybackRateChange', 'onPlaybackRateChange',
'onProgress', 'onProgress',
'onQualityChange',
'onReadyToDisplay', 'onReadyToDisplay',
'onSeek', 'onSeek',
'onTimedMetadata', 'onTimedMetadata',

View File

@@ -0,0 +1,26 @@
export interface QualityLevel {
/**
* Unique identifier for the quality
*/
id: string;
/**
* Width of the quality
*/
width: number;
/**
* Height of the quality
*/
height: number;
/**
* Bitrate of the quality
*/
bitrate: number;
/**
* Whether this quality is currently selected
*/
selected: boolean;
}

View File

@@ -20,8 +20,9 @@ import {
} from "../types/VideoError"; } from "../types/VideoError";
import type { VideoPlayerStatus } from "../types/VideoPlayerStatus"; import type { VideoPlayerStatus } from "../types/VideoPlayerStatus";
import type { AudioTrack } from "../types/AudioTrack"; import type { AudioTrack } from "../types/AudioTrack";
import type { VideoJsTracks } from "../VideoPlayer.web"; import type { VideoJsTracks, VideoJsQualityArray } from "../VideoPlayer.web";
import type { VideoTrack } from "../types/VideoTrack"; import type { VideoTrack } from "../types/VideoTrack";
import type { QualityLevel } from "../types/QualityLevel";
type VideoJsPlayer = ReturnType<typeof videojs>; type VideoJsPlayer = ReturnType<typeof videojs>;
@@ -84,6 +85,10 @@ export class WebEventEmiter implements PlayerEvents {
this._onVideoTrackChange = this._onVideoTrackChange.bind(this); this._onVideoTrackChange = this._onVideoTrackChange.bind(this);
this.player.videoTracks().on("change", this._onVideoTrackChange); 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);
} }
destroy() { destroy() {
@@ -112,6 +117,10 @@ export class WebEventEmiter implements PlayerEvents {
this.player.audioTracks().off("change", this._onAudioTrackChange); this.player.audioTracks().off("change", this._onAudioTrackChange);
this.player.videoTracks().off("change", this._onVideoTrackChange); this.player.videoTracks().off("change", this._onVideoTrackChange);
this._onQualityChange = this._onQualityChange.bind(this);
// @ts-expect-error this isn't typed
this.player.qualityLevels().off("change", this._onQualityChange);
} }
_onTimeUpdate() { _onTimeUpdate() {
@@ -259,6 +268,20 @@ export class WebEventEmiter implements PlayerEvents {
this.onVideoTrackChange(selected ?? null); this.onVideoTrackChange(selected ?? null);
} }
_onQualityChange() {
// @ts-expect-error this isn't typed
const levels: VideoJsQualityArray = this.player.qualityLevels();
const quality = levels[levels.selectedIndex]!;
this.onQualityChange({
id: quality.id,
width: quality.width,
height: quality.height,
bitrate: quality.bitrate,
selected: true,
});
}
NOOP = () => {}; NOOP = () => {};
onError: (error: VideoRuntimeError) => void = this.NOOP; onError: (error: VideoRuntimeError) => void = this.NOOP;
@@ -284,4 +307,5 @@ export class WebEventEmiter implements PlayerEvents {
onVolumeChange: (data: onVolumeChangeData) => void = this.NOOP; onVolumeChange: (data: onVolumeChangeData) => void = this.NOOP;
onStatusChange: (status: VideoPlayerStatus) => void = this.NOOP; onStatusChange: (status: VideoPlayerStatus) => void = this.NOOP;
onVideoTrackChange: (track: VideoTrack | null) => void = this.NOOP; onVideoTrackChange: (track: VideoTrack | null) => void = this.NOOP;
onQualityChange: (quality: QualityLevel) => void = this.NOOP;
} }

View File

@@ -5,6 +5,9 @@ export type { IgnoreSilentSwitchMode } from "./core/types/IgnoreSilentSwitchMode
export type { MixAudioMode } from "./core/types/MixAudioMode"; export type { MixAudioMode } from "./core/types/MixAudioMode";
export type { ResizeMode } from "./core/types/ResizeMode"; export type { ResizeMode } from "./core/types/ResizeMode";
export type { TextTrack } from "./core/types/TextTrack"; export type { TextTrack } from "./core/types/TextTrack";
export type { AudioTrack } from "./core/types/AudioTrack";
export type { VideoTrack } from "./core/types/VideoTrack";
export type { QualityLevel } from "./core/types/QualityLevel";
export type { VideoConfig, VideoSource } from "./core/types/VideoConfig"; export type { VideoConfig, VideoSource } from "./core/types/VideoConfig";
export type { export type {
LibraryError, LibraryError,