mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add resolved rendition to auto label (#1698)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user