feat(core): add quality selection state (#1693)

This commit is contained in:
Sam Potts
2026-06-18 07:20:13 +10:00
committed by GitHub
parent bce79ec424
commit 74da6821d7
15 changed files with 743 additions and 0 deletions
+7
View File
@@ -11,6 +11,7 @@ import type {
MediaSourceCapability,
MediaStreamTypeCapability,
MediaTextTrackCapability,
MediaVideoRenditionCapability,
MediaVolumeCapability,
} from '../../core/media/types';
import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from './constants';
@@ -77,6 +78,12 @@ export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrack
return !isUndefined(media.textTracks) && media.textTracks !== EMPTY_TEXT_TRACKS;
}
export function isMediaVideoRenditionCapable(value: unknown): value is MediaVideoRenditionCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
return !isUndefined(media.videoRenditions);
}
export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
+2
View File
@@ -8,6 +8,7 @@ import type {
MediaPictureInPictureState,
MediaPlaybackRateState,
MediaPlaybackState,
MediaQualityState,
MediaRemotePlaybackState,
MediaSourceState,
MediaTextTrackState,
@@ -42,6 +43,7 @@ export type AnyPlayerStore = Store<PlayerTarget, object>;
export type VideoFeatures = [
PlayerFeature<MediaPlaybackState>,
PlayerFeature<MediaPlaybackRateState>,
PlayerFeature<MediaQualityState>,
PlayerFeature<MediaVolumeState>,
PlayerFeature<MediaTimeState>,
PlayerFeature<MediaSourceState>,
@@ -6,6 +6,7 @@ import { orientationLockFeature } from './orientation-lock';
import { pipFeature } from './pip';
import { playbackFeature } from './playback';
import { playbackRateFeature } from './playback-rate';
import { qualityFeature } from './quality';
import { remotePlaybackFeature } from './remote-playback';
import { sourceFeature } from './source';
import { streamTypeFeature } from './stream-type';
@@ -25,6 +26,7 @@ export {
pipFeature as pip,
playbackFeature as playback,
playbackRateFeature as playbackRate,
qualityFeature as quality,
remotePlaybackFeature as remotePlayback,
sourceFeature as source,
streamTypeFeature as streamType,
@@ -9,6 +9,7 @@ export * from './pip';
export * from './playback';
export * from './playback-rate';
export * from './presets';
export * from './quality';
export * from './remote-playback';
export * from './source';
export * from './stream-type';
@@ -13,6 +13,7 @@ import { liveFeature } from './live';
import { pipFeature } from './pip';
import { playbackFeature } from './playback';
import { playbackRateFeature } from './playback-rate';
import { qualityFeature } from './quality';
import { remotePlaybackFeature } from './remote-playback';
import { sourceFeature } from './source';
import { textTrackFeature } from './text-track';
@@ -22,6 +23,7 @@ import { volumeFeature } from './volume';
export const videoFeatures: VideoFeatures = [
playbackFeature,
playbackRateFeature,
qualityFeature,
volumeFeature,
timeFeature,
sourceFeature,
@@ -0,0 +1,83 @@
import { listen } from '@videojs/utils/dom';
import type { MediaQualityState, MediaVideoRendition } from '../../../core/media/state';
import type { VideoRenditionLike, VideoRenditionListLike } from '../../../core/media/types';
import { definePlayerFeature } from '../../feature';
import { isMediaVideoRenditionCapable } from '../../media/predicate';
const QUALITY_AUTO_VALUE = 'auto';
function getRenditionValue(rendition: VideoRenditionLike, index: number): string {
return rendition.id || String(index);
}
function toMediaRendition(rendition: VideoRenditionLike): MediaVideoRendition {
return {
...(rendition.id !== undefined && { id: rendition.id }),
...(rendition.width !== undefined && { width: rendition.width }),
...(rendition.height !== undefined && { height: rendition.height }),
...(rendition.bitrate !== undefined && { bitrate: rendition.bitrate }),
...(rendition.frameRate !== undefined && { frameRate: rendition.frameRate }),
...(rendition.codec !== undefined && { codec: rendition.codec }),
selected: rendition.selected,
};
}
export const qualityFeature = definePlayerFeature({
name: 'quality',
state: ({ target }): MediaQualityState => ({
videoRenditionList: [],
selectVideoRendition(value: string) {
const { media } = target();
if (!isMediaVideoRenditionCapable(media)) return;
if (value === QUALITY_AUTO_VALUE) {
media.videoRenditions.selectedIndex = -1;
return;
}
const index = [...media.videoRenditions].findIndex(
(rendition, renditionIndex) => getRenditionValue(rendition, renditionIndex) === value
);
if (index !== -1) media.videoRenditions.selectedIndex = index;
},
}),
attach({ target, signal, set }) {
const { media } = target;
let videoRenditions: VideoRenditionListLike | null = null;
let cleanup: AbortController | null = null;
const getVideoRenditions = () => (isMediaVideoRenditionCapable(media) ? media.videoRenditions : null);
const sync = (list = getVideoRenditions()) => {
set({ videoRenditionList: list ? [...list].map(toMediaRendition) : [] });
};
const bind = () => {
const nextVideoRenditions = getVideoRenditions();
if (nextVideoRenditions === videoRenditions) {
sync(nextVideoRenditions);
return;
}
cleanup?.abort();
cleanup = new AbortController();
videoRenditions = nextVideoRenditions;
if (videoRenditions) {
listen(videoRenditions, 'addrendition', () => sync(videoRenditions), { signal: cleanup.signal });
listen(videoRenditions, 'removerendition', () => sync(videoRenditions), { signal: cleanup.signal });
listen(videoRenditions, 'change', () => sync(videoRenditions), { signal: cleanup.signal });
}
sync(videoRenditions);
};
bind();
listen(media, 'loadstart', bind, { signal });
signal.addEventListener('abort', () => cleanup?.abort(), { once: true });
},
});
@@ -0,0 +1,144 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import type { VideoRenditionLike } from '../../../../core/media/types';
import type { PlayerTarget } from '../../../media/types';
import { qualityFeature } from '../quality';
class TestRenditionList extends EventTarget {
renditions: VideoRenditionLike[];
constructor(renditions: VideoRenditionLike[]) {
super();
this.renditions = renditions;
}
[Symbol.iterator](): Iterator<VideoRenditionLike> {
return this.renditions.values();
}
get length(): number {
return this.renditions.length;
}
get selectedIndex(): number {
return this.renditions.findIndex((rendition) => rendition.selected);
}
set selectedIndex(index: number) {
for (const [renditionIndex, rendition] of this.renditions.entries()) {
rendition.selected = renditionIndex === index;
}
}
}
class TestTrackList extends EventTarget {
[Symbol.iterator](): Iterator<unknown> {
return [][Symbol.iterator]();
}
}
class TestMedia extends EventTarget {
videoRenditions: TestRenditionList | undefined = undefined;
videoTracks = new TestTrackList();
async play() {}
}
function createRendition(overrides: Partial<VideoRenditionLike>): VideoRenditionLike {
return {
id: undefined,
width: undefined,
height: undefined,
bitrate: undefined,
frameRate: undefined,
codec: undefined,
selected: false,
...overrides,
};
}
function createMedia(renditions: VideoRenditionLike[]): PlayerTarget['media'] {
return {
play: async () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
videoRenditions: new TestRenditionList(renditions),
videoTracks: new TestTrackList(),
} as unknown as PlayerTarget['media'];
}
describe('qualityFeature', () => {
it('syncs video renditions on attach', () => {
const media = createMedia([
createRendition({ id: '0', height: 1080, bitrate: 6_000_000 }),
createRendition({ id: '1', height: 720, bitrate: 3_000_000 }),
]);
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
expect(store.state.videoRenditionList).toEqual([
{ id: '0', height: 1080, bitrate: 6_000_000, selected: false },
{ id: '1', height: 720, bitrate: 3_000_000, selected: false },
]);
});
it('syncs video renditions after loadstart', () => {
const media = new TestMedia() as unknown as PlayerTarget['media'];
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
expect(store.state.videoRenditionList).toEqual([]);
const list = new TestRenditionList([createRendition({ id: '0', height: 1080 })]);
(media as unknown as TestMedia).videoRenditions = list;
media.dispatchEvent(new Event('loadstart'));
expect(store.state.videoRenditionList).toEqual([{ id: '0', height: 1080, selected: false }]);
list.renditions.push(createRendition({ id: '1', height: 720 }));
list.dispatchEvent(new Event('addrendition'));
expect(store.state.videoRenditionList).toEqual([
{ id: '0', height: 1080, selected: false },
{ id: '1', height: 720, selected: false },
]);
});
it('selects automatic quality', () => {
const media = createMedia([
createRendition({ id: '0', height: 1080, selected: true }),
createRendition({ id: '1', height: 720 }),
]);
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
store.state.selectVideoRendition('auto');
expect((media as any).videoRenditions.selectedIndex).toBe(-1);
});
it('selects a rendition by value', () => {
const media = createMedia([createRendition({ id: '0', height: 1080 }), createRendition({ id: '1', height: 720 })]);
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
store.state.selectVideoRendition('1');
expect((media as any).videoRenditions.selectedIndex).toBe(1);
});
it('resyncs on rendition change', () => {
const media = createMedia([createRendition({ id: '0', height: 1080 }), createRendition({ id: '1', height: 720 })]);
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
(media as any).videoRenditions.renditions[1].selected = true;
(media as any).videoRenditions.dispatchEvent(new Event('change'));
expect(store.state.videoRenditionList[1]?.selected).toBe(true);
});
});
+3
View File
@@ -8,6 +8,7 @@ import { liveFeature } from './features/live';
import { pipFeature } from './features/pip';
import { playbackFeature } from './features/playback';
import { playbackRateFeature } from './features/playback-rate';
import { qualityFeature } from './features/quality';
import { remotePlaybackFeature } from './features/remote-playback';
import { sourceFeature } from './features/source';
import { streamTypeFeature } from './features/stream-type';
@@ -31,6 +32,8 @@ export const selectPiP = createSelector(pipFeature);
export const selectPlayback = createSelector(playbackFeature);
/** Select the playback rate state (playbackRate, playbackRates, setPlaybackRate). */
export const selectPlaybackRate = createSelector(playbackRateFeature);
/** Select the quality state (videoRenditionList, selectVideoRendition). */
export const selectQuality = createSelector(qualityFeature);
/** Select the remote playback state (remote playback connection state, availability). */
export const selectRemotePlayback = createSelector(remotePlaybackFeature);
/** Select the source state (src, type). */