feat(packages): add resolved rendition to auto label (#1698)

This commit is contained in:
Sam Potts
2026-06-18 07:42:00 +10:00
committed by GitHub
parent eae0191eed
commit 9275f93504
22 changed files with 289 additions and 38 deletions
@@ -59,6 +59,22 @@ export function selectedChanged(rendition: VideoRendition) {
});
}
export function activeChanged(rendition: VideoRendition) {
const renditionList = getPrivate(rendition).media?.deref()?.videoRenditions as VideoRenditionList | undefined;
if (!renditionList || getPrivate(renditionList).activeChangeRequested) return;
getPrivate(renditionList).activeChangeRequested = true;
queueMicrotask(() => {
delete getPrivate(renditionList).activeChangeRequested;
const track = getPrivate(rendition).track as VideoTrack;
if (!track.selected) return;
renditionList.dispatchEvent(new Event('activechange'));
});
}
function getCurrentRenditions(renditionList: VideoRenditionList): VideoRendition[] {
const media = getPrivate(renditionList).media?.deref() as HTMLMediaElement | undefined;
if (!media) return [];
@@ -1,4 +1,4 @@
import { selectedChanged } from './video-rendition-list';
import { activeChanged, selectedChanged } from './video-rendition-list';
/**
* The consumer should use the `selected` setter to select one or multiple
@@ -13,6 +13,7 @@ export class VideoRendition {
frameRate: number | undefined;
codec: string | undefined;
#selected = false;
#active = false;
get selected(): boolean {
return this.#selected;
@@ -24,4 +25,15 @@ export class VideoRendition {
selectedChanged(this);
}
get active(): boolean {
return this.#active;
}
set active(value: boolean) {
if (this.#active === value) return;
this.#active = value;
activeChanged(this);
}
}
+2
View File
@@ -243,6 +243,8 @@ export interface MediaVideoRendition {
export interface MediaQualityState {
/** Video renditions available for manual quality selection. */
videoRenditionList: MediaVideoRendition[];
/** Video rendition currently playing, including when automatic ABR is selected. */
activeVideoRendition: MediaVideoRendition | null;
/** Select a video rendition by menu value, or automatic ABR with `"auto"`. */
selectVideoRendition(value: string): void;
}
+6 -1
View File
@@ -360,9 +360,14 @@ export interface VideoRenditionLike {
readonly frameRate: number | undefined;
readonly codec: string | undefined;
selected: boolean;
active?: boolean | undefined;
}
export interface VideoRenditionListLike extends EventTargetLike<RenditionListEvents<VideoRenditionLike>> {
interface VideoRenditionListEvents extends RenditionListEvents<VideoRenditionLike> {
activechange: EventLike;
}
export interface VideoRenditionListLike extends EventTargetLike<VideoRenditionListEvents> {
readonly length: number;
readonly [index: number]: VideoRenditionLike;
[Symbol.iterator](): Iterator<VideoRenditionLike>;
@@ -24,6 +24,7 @@ export interface QualityRadioGroupRendition {
export interface QualityRadioGroupState extends ButtonState {
renditions: readonly QualityRadioGroupRendition[];
autoLabel: string;
value: string;
disabled: boolean;
availability: 'available' | 'unavailable';
@@ -93,6 +94,18 @@ function getRenditionValue(rendition: MediaVideoRendition, index: number): strin
return rendition.id || String(index);
}
function isSameRendition(a: MediaVideoRendition, b: MediaVideoRendition): boolean {
if (a.id !== undefined || b.id !== undefined) return a.id === b.id;
return (
a.width === b.width &&
a.height === b.height &&
a.bitrate === b.bitrate &&
a.frameRate === b.frameRate &&
a.codec === b.codec
);
}
export class QualityRadioGroupCore {
static readonly defaultProps: NonNullableObject<QualityRadioGroupProps> = {
label: '',
@@ -102,6 +115,7 @@ export class QualityRadioGroupCore {
readonly state = createState<QualityRadioGroupState>({
renditions: [],
autoLabel: 'Auto',
value: QUALITY_AUTO_VALUE,
disabled: false,
availability: 'unavailable',
@@ -175,19 +189,29 @@ export class QualityRadioGroupCore {
const selectedIndex = media.videoRenditionList.findIndex((rendition) => rendition.selected);
const availability: QualityRadioGroupState['availability'] =
media.videoRenditionList.length > 1 ? 'available' : 'unavailable';
const toRendition = (rendition: MediaVideoRendition, index: number): QualityRadioGroupRendition => {
const tier = this.getRenditionTier(rendition);
const badge = this.getRenditionBadge(rendition, media.videoRenditionList);
return {
value: this.getRenditionValue(rendition, index),
label: this.getRenditionLabel(rendition),
...(tier && { tier }),
...(badge && { badge }),
};
};
const activeIndex =
media.activeVideoRendition === null
? -1
: media.videoRenditionList.findIndex((rendition) => isSameRendition(rendition, media.activeVideoRendition!));
const active =
media.activeVideoRendition && activeIndex !== -1
? toRendition(media.activeVideoRendition, activeIndex)
: undefined;
this.state.patch({
renditions: media.videoRenditionList.map((rendition, index) => {
const tier = this.getRenditionTier(rendition);
const badge = this.getRenditionBadge(rendition, media.videoRenditionList);
return {
value: this.getRenditionValue(rendition, index),
label: this.getRenditionLabel(rendition),
...(tier && { tier }),
...(badge && { badge }),
};
}),
renditions: media.videoRenditionList.map(toRendition),
autoLabel: selectedIndex === -1 && active ? `Auto (${active.label})` : 'Auto',
value:
selectedIndex === -1
? QUALITY_AUTO_VALUE
@@ -10,6 +10,7 @@ function createMediaState(overrides: Partial<MediaQualityState> = {}): MediaQual
{ id: '0', height: 1080, bitrate: 6_000_000, selected: false },
{ id: '1', height: 720, bitrate: 3_000_000, selected: false },
],
activeVideoRendition: null,
selectVideoRendition: vi.fn(),
...overrides,
};
@@ -21,6 +22,7 @@ function createState(overrides: Partial<QualityRadioGroupState> = {}): QualityRa
{ value: '0', label: '1080p' },
{ value: '1', label: '720p' },
],
autoLabel: 'Auto',
value: QUALITY_AUTO_VALUE,
disabled: false,
availability: 'available',
@@ -94,6 +96,19 @@ describe('QualityRadioGroupCore', () => {
expect(core.getState().value).toBe('1');
});
it('labels automatic with the active rendition', () => {
const core = new QualityRadioGroupCore();
const media = createMediaState({
activeVideoRendition: { id: '1', height: 720, selected: false },
});
core.setMedia(media);
const state = core.getState();
expect(state.value).toBe(QUALITY_AUTO_VALUE);
expect(state.autoLabel).toBe('Auto (720p)');
});
it('marks availability unavailable with one rendition', () => {
const core = new QualityRadioGroupCore();
core.setMedia(createMediaState({ videoRenditionList: [{ id: '0', height: 1080, selected: false }] }));
@@ -57,6 +57,7 @@ export function HlsJsMediaMediaTracksMixin<Base extends Constructor<MediaTracksH
engine.on(Hls.Events.MANIFEST_PARSED, this.#onManifestParsed);
engine.on(Hls.Events.AUDIO_TRACKS_UPDATED, this.#onAudioTracksUpdated);
engine.on(Hls.Events.LEVELS_UPDATED, this.#onLevelsUpdated);
engine.on(Hls.Events.LEVEL_SWITCHED, this.#onLevelSwitched);
engine.once(Hls.Events.DESTROYING, this.#teardown);
this.audioTracks.addEventListener('change', this.#switchAudioTrack);
@@ -131,6 +132,14 @@ export function HlsJsMediaMediaTracksMixin<Base extends Constructor<MediaTracksH
}
};
#onLevelSwitched = (_event: string, data: { level: number }) => {
const activeId = `${data.level}`;
for (const rendition of this.videoRenditions) {
rendition.active = rendition.id === activeId;
}
};
#switchRendition = () => {
const { engine } = this;
if (!engine) return;
@@ -145,6 +154,7 @@ export function HlsJsMediaMediaTracksMixin<Base extends Constructor<MediaTracksH
engine?.off(Hls.Events.MANIFEST_PARSED, this.#onManifestParsed);
engine?.off(Hls.Events.AUDIO_TRACKS_UPDATED, this.#onAudioTracksUpdated);
engine?.off(Hls.Events.LEVELS_UPDATED, this.#onLevelsUpdated);
engine?.off(Hls.Events.LEVEL_SWITCHED, this.#onLevelSwitched);
engine?.off(Hls.Events.DESTROYING, this.#teardown);
this.audioTracks.removeEventListener('change', this.#switchAudioTrack);
@@ -96,6 +96,18 @@ describe('HlsJsMediaMediaTracksMixin', () => {
expect(engine.nextLevel).toBe(2);
});
it('marks the active rendition from LEVEL_SWITCHED', () => {
const engine = createEngine();
const host = new HlsJsMediaMediaTracks(engine);
manifestParsed(engine, [{ url: ['a'] }, { url: ['b'] }, { url: ['c'] }]);
(engine as any).emit(Hls.Events.LEVEL_SWITCHED, { level: 1 });
expect([...host.videoRenditions].map((rendition) => rendition.active)).toEqual([false, true, false]);
expect(engine.nextLevel).toBe(-1);
});
it('forwards an audio track selection to engine.audioTrack', async () => {
const engine = createEngine();
const host = new HlsJsMediaMediaTracks(engine);
+7
View File
@@ -11,6 +11,7 @@ import type {
MediaSourceCapability,
MediaStreamTypeCapability,
MediaTextTrackCapability,
MediaVideoDimensionsCapability,
MediaVideoRenditionCapability,
MediaVolumeCapability,
} from '../../core/media/types';
@@ -84,6 +85,12 @@ export function isMediaVideoRenditionCapable(value: unknown): value is MediaVide
return !isUndefined(media.videoRenditions);
}
export function isMediaVideoDimensionsCapable(value: unknown): value is MediaVideoDimensionsCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
return !isUndefined(media.videoWidth) && !isUndefined(media.videoHeight);
}
export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
@@ -3,7 +3,7 @@ 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';
import { isMediaVideoDimensionsCapable, isMediaVideoRenditionCapable } from '../../media/predicate';
const QUALITY_AUTO_VALUE = 'auto';
@@ -23,10 +23,16 @@ function toMediaRendition(rendition: VideoRenditionLike): MediaVideoRendition {
};
}
function getSize(rendition: Pick<VideoRenditionLike, 'width' | 'height'>): number | undefined {
if (rendition.width && rendition.height) return Math.min(rendition.width, rendition.height);
return rendition.height ?? rendition.width;
}
export const qualityFeature = definePlayerFeature({
name: 'quality',
state: ({ target }): MediaQualityState => ({
videoRenditionList: [],
activeVideoRendition: null,
selectVideoRendition(value: string) {
const { media } = target();
if (!isMediaVideoRenditionCapable(media)) return;
@@ -50,8 +56,31 @@ export const qualityFeature = definePlayerFeature({
let cleanup: AbortController | null = null;
const getVideoRenditions = () => (isMediaVideoRenditionCapable(media) ? media.videoRenditions : null);
const getActiveRendition = (list: VideoRenditionListLike | null) => {
if (!list) return null;
const renditions = [...list];
const active = renditions.find((rendition) => rendition.active);
if (active) return active;
if (!isMediaVideoDimensionsCapable(media) || (!media.videoWidth && !media.videoHeight)) return null;
const size = getSize({
width: media.videoWidth || undefined,
height: media.videoHeight || undefined,
});
const matches = renditions.filter((rendition) => getSize(rendition) === size);
return matches.length === 1 ? matches[0] : null;
};
const sync = (list = getVideoRenditions()) => {
set({ videoRenditionList: list ? [...list].map(toMediaRendition) : [] });
const active = getActiveRendition(list);
set({
videoRenditionList: list ? [...list].map(toMediaRendition) : [],
activeVideoRendition: active ? toMediaRendition(active) : null,
});
};
const bind = () => {
@@ -70,6 +99,7 @@ export const qualityFeature = definePlayerFeature({
listen(videoRenditions, 'addrendition', () => sync(videoRenditions), { signal: cleanup.signal });
listen(videoRenditions, 'removerendition', () => sync(videoRenditions), { signal: cleanup.signal });
listen(videoRenditions, 'change', () => sync(videoRenditions), { signal: cleanup.signal });
listen(videoRenditions, 'activechange', () => sync(videoRenditions), { signal: cleanup.signal });
}
sync(videoRenditions);
@@ -78,6 +108,7 @@ export const qualityFeature = definePlayerFeature({
bind();
listen(media, 'loadstart', bind, { signal });
listen(media, 'resize', () => sync(videoRenditions), { signal });
signal.addEventListener('abort', () => cleanup?.abort(), { once: true });
},
});
@@ -41,6 +41,13 @@ class TestTrackList extends EventTarget {
class TestMedia extends EventTarget {
videoRenditions: TestRenditionList | undefined = undefined;
videoTracks = new TestTrackList();
videoWidth = 0;
videoHeight = 0;
constructor(renditions?: VideoRenditionLike[]) {
super();
if (renditions) this.videoRenditions = new TestRenditionList(renditions);
}
async play() {}
}
@@ -59,14 +66,7 @@ function createRendition(overrides: Partial<VideoRenditionLike>): VideoRendition
}
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'];
return new TestMedia(renditions) as unknown as PlayerTarget['media'];
}
describe('qualityFeature', () => {
@@ -83,6 +83,7 @@ describe('qualityFeature', () => {
{ id: '0', height: 1080, bitrate: 6_000_000, selected: false },
{ id: '1', height: 720, bitrate: 3_000_000, selected: false },
]);
expect(store.state.activeVideoRendition).toBeNull();
});
it('syncs video renditions after loadstart', () => {
@@ -141,4 +142,66 @@ describe('qualityFeature', () => {
expect(store.state.videoRenditionList[1]?.selected).toBe(true);
});
it('syncs the active video rendition', () => {
const media = createMedia([
createRendition({ id: '0', height: 1080 }),
createRendition({ id: '1', height: 720, active: true }),
]);
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
expect(store.state.activeVideoRendition).toEqual({ id: '1', height: 720, selected: false });
(media as any).videoRenditions.renditions[1].active = false;
(media as any).videoRenditions.renditions[0].active = true;
(media as any).videoRenditions.dispatchEvent(new Event('activechange'));
expect(store.state.activeVideoRendition).toEqual({ id: '0', height: 1080, selected: false });
});
it('falls back to video dimensions for the active rendition', () => {
const media = createMedia([createRendition({ id: '0', height: 1080 }), createRendition({ id: '1', height: 720 })]);
const testMedia = media as unknown as TestMedia;
testMedia.videoWidth = 1280;
testMedia.videoHeight = 720;
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
expect(store.state.activeVideoRendition).toEqual({ id: '1', height: 720, selected: false });
testMedia.videoWidth = 1920;
testMedia.videoHeight = 1080;
media.dispatchEvent(new Event('resize'));
expect(store.state.activeVideoRendition).toEqual({ id: '0', height: 1080, selected: false });
});
it('does not fall back when multiple renditions share the video dimensions', () => {
const media = createMedia([
createRendition({ id: '0', height: 1080, bitrate: 6_000_000 }),
createRendition({ id: '1', height: 1080, bitrate: 3_000_000 }),
createRendition({ id: '2', height: 720, bitrate: 1_500_000 }),
]);
const testMedia = media as unknown as TestMedia;
testMedia.videoWidth = 1920;
testMedia.videoHeight = 1080;
const store = createStore<PlayerTarget>()(qualityFeature);
store.attach({ media, container: null });
expect(store.state.activeVideoRendition).toBeNull();
testMedia.videoWidth = 1280;
testMedia.videoHeight = 720;
media.dispatchEvent(new Event('resize'));
expect(store.state.activeVideoRendition).toEqual({
id: '2',
height: 720,
bitrate: 1_500_000,
selected: false,
});
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ 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). */
/** Select the quality state (videoRenditionList, activeVideoRendition, selectVideoRendition). */
export const selectQuality = createSelector(qualityFeature);
/** Select the remote playback state (remote playback connection state, availability). */
export const selectRemotePlayback = createSelector(remotePlaybackFeature);
@@ -171,8 +171,10 @@ function getTemplateHTML() {
<media-quality-radio-group class="${menu.group}">
<template>
<media-menu-radio-item class="${menu.item}">
<span data-part="label"></span>
<sup data-part="tier" class="${menu.tier}"></sup>
<span>
<span data-part="label"></span>
<sup data-part="tier" class="${menu.tier}"></sup>
</span>
<span data-part="badge" class="${cn(badge, menu.badge)}"></span>
<media-menu-item-indicator force-mount class="${menu.indicator}">
${renderIcon('check', { class: icon })}
@@ -149,8 +149,10 @@ function getTemplateHTML() {
<media-quality-radio-group class="media-menu__group">
<template>
<media-menu-radio-item class="media-menu__item">
<span data-part="label"></span>
<sup data-part="tier" class="media-menu__tier"></sup>
<span>
<span data-part="label"></span>
<sup data-part="tier" class="media-menu__tier"></sup>
</span>
<span data-part="badge" class="media-badge"></span>
<media-menu-item-indicator force-mount class="media-menu__indicator">
${renderIcon('check', { class: 'media-icon' })}
@@ -167,8 +167,10 @@ function getTemplateHTML() {
<media-quality-radio-group class="${menu.group}">
<template>
<media-menu-radio-item class="${menu.item}">
<span data-part="label"></span>
<sup data-part="tier" class="${menu.tier}"></sup>
<span>
<span data-part="label"></span>
<sup data-part="tier" class="${menu.tier}"></sup>
</span>
<span data-part="badge" class="${cn(badge, menu.badge)}"></span>
<media-menu-item-indicator force-mount class="${menu.indicator}">
${renderIcon('check', { class: icon })}
+4 -2
View File
@@ -145,8 +145,10 @@ function getTemplateHTML() {
<media-quality-radio-group class="media-menu__group">
<template>
<media-menu-radio-item class="media-menu__item">
<span data-part="label"></span>
<sup data-part="tier" class="media-menu__tier"></sup>
<span>
<span data-part="label"></span>
<sup data-part="tier" class="media-menu__tier"></sup>
</span>
<span data-part="badge" class="media-badge"></span>
<media-menu-item-indicator force-mount class="media-menu__indicator">
${renderIcon('check', { class: 'media-icon' })}
@@ -38,7 +38,7 @@ export function getMenuItemSettingState(
const state = cores.quality.getState();
if (state.value === QUALITY_AUTO_VALUE) {
return { label: 'Auto', availability: state.availability };
return { label: state.autoLabel, availability: state.availability };
}
const rendition = state.renditions.find((candidate) => candidate.value === state.value);
@@ -80,13 +80,16 @@ function createQualityStore({
{ id: '0', height: 1080, selected: false },
{ id: '1', height: 720, selected: false },
],
activeVideoRendition = null,
}: {
videoRenditionList?: MediaQualityState['videoRenditionList'] | undefined;
activeVideoRendition?: MediaQualityState['activeVideoRendition'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaQualityState>({
name: 'quality',
state: () => ({
videoRenditionList,
activeVideoRendition,
selectVideoRendition: vi.fn(),
}),
}) as unknown as AnyPlayerStore;
@@ -170,6 +173,20 @@ describe('MenuItemValueElement', () => {
});
});
it('renders the active quality label when quality is automatic', async () => {
const { value } = setup(
createQualityStore({
activeVideoRendition: { id: '1', height: 720, selected: false },
}),
'quality'
);
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('Auto (720p)');
});
});
it('renders the selected quality label', async () => {
const { value } = setup(
createQualityStore({
@@ -69,7 +69,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
const templateKey = template?.innerHTML ?? '';
const renditionsKey = `${state.renditions
.map((rendition) => `${rendition.value}:${rendition.label}:${rendition.tier ?? ''}:${rendition.badge ?? ''}`)
.join('|')}::${templateKey}`;
.join('|')}::${state.autoLabel}::${templateKey}`;
if (renditionsKey !== this.#renditionsKey) {
this.#renditionsKey = renditionsKey;
@@ -79,7 +79,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
child.remove();
}
this.append(this.#createItem(QUALITY_AUTO_VALUE, 'Auto', undefined, undefined, template));
this.append(this.#createItem(QUALITY_AUTO_VALUE, state.autoLabel, undefined, undefined, template));
this.append(
...state.renditions.map((rendition) =>
this.#createItem(rendition.value, rendition.label, rendition.tier, rendition.badge, template)
@@ -55,15 +55,18 @@ function createQualityStore({
{ id: '0', height: 1080, selected: false },
{ id: '1', height: 720, selected: false },
],
activeVideoRendition = null,
selectVideoRendition = vi.fn(),
}: {
videoRenditionList?: MediaQualityState['videoRenditionList'] | undefined;
activeVideoRendition?: MediaQualityState['activeVideoRendition'] | undefined;
selectVideoRendition?: MediaQualityState['selectVideoRendition'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaQualityState>({
name: 'quality',
state: () => ({
videoRenditionList,
activeVideoRendition,
selectVideoRendition,
}),
}) as unknown as AnyPlayerStore;
@@ -94,14 +97,16 @@ defineElement('test-quality-player', TestPlayerProviderElement);
function setup({
videoRenditionList,
activeVideoRendition,
selectVideoRendition,
template,
}: {
videoRenditionList?: MediaQualityState['videoRenditionList'] | undefined;
activeVideoRendition?: MediaQualityState['activeVideoRendition'] | undefined;
selectVideoRendition?: MediaQualityState['selectVideoRendition'] | undefined;
template?: string | undefined;
} = {}) {
const store = createQualityStore({ videoRenditionList, selectVideoRendition });
const store = createQualityStore({ videoRenditionList, activeVideoRendition, selectVideoRendition });
const provider = document.createElement('test-quality-player') as TestPlayerProviderElement;
const menu = createElement(MenuElement);
const options = createElement(QualityRadioGroupElement);
@@ -169,6 +174,20 @@ describe('QualityRadioGroupElement', () => {
expect(indicators.map((indicator) => indicator.checked)).toEqual([true, false, false]);
});
it('renders the active rendition in the Auto label', async () => {
const { menu, options } = setup({
activeVideoRendition: { id: '1', height: 720, selected: false },
template:
'<media-menu-radio-item><span data-part="label"></span><media-menu-item-indicator force-mount></media-menu-item-indicator></media-menu-radio-item>',
});
await waitForMenu(menu, options);
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
expect(items[0]?.querySelector('[data-part~="label"]')?.textContent).toBe('Auto (720p)');
});
it('renders bitrate badges from a template', async () => {
const { menu, options } = setup({
videoRenditionList: [
@@ -16,14 +16,16 @@ function renderQualityOptions({
{ id: '0', height: 1080, selected: false },
{ id: '1', height: 720, selected: false },
],
activeVideoRendition = null,
selectVideoRendition = vi.fn(),
formatRendition,
}: {
videoRenditionList?: MediaVideoRendition[];
activeVideoRendition?: MediaVideoRendition | null | undefined;
selectVideoRendition?: (value: string) => void;
formatRendition?: ((rendition: MediaVideoRendition) => string) | undefined;
} = {}) {
const { Wrapper } = createPlayerWrapper({ videoRenditionList, selectVideoRendition });
const { Wrapper } = createPlayerWrapper({ videoRenditionList, activeVideoRendition, selectVideoRendition });
render(
<Menu.Root defaultOpen align="center">
@@ -78,6 +80,14 @@ describe('useQualityOptions', () => {
expect(selectVideoRendition).toHaveBeenCalledWith('1');
});
it('renders the active rendition in the Auto option', () => {
renderQualityOptions({
activeVideoRendition: { id: '1', height: 720, selected: false },
});
expect(screen.getByRole('menuitemradio', { name: 'Auto (720p)' }).getAttribute('aria-checked')).toBe('true');
});
it('uses a custom rendition formatter', () => {
renderQualityOptions({
formatRendition: (rendition) => `${rendition.height} pixels`,
@@ -46,7 +46,7 @@ export function useQualityOptions(props?: QualityOptionsProps): QualityOptionsRe
state,
value: state.value,
options: [
{ value: QUALITY_AUTO_VALUE, label: 'Auto', disabled: state.disabled },
{ value: QUALITY_AUTO_VALUE, label: state.autoLabel, disabled: state.disabled },
...state.renditions.map((rendition) => ({
value: rendition.value,
label: rendition.label,