mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): add quality selection state (#1693)
This commit is contained in:
@@ -50,6 +50,8 @@ export * from './ui/popover/popover-data-attrs';
|
||||
export * from './ui/popover/popup-host-attr';
|
||||
export * from './ui/poster/poster-core';
|
||||
export * from './ui/poster/poster-data-attrs';
|
||||
export * from './ui/quality-radio-group/quality-radio-group-core';
|
||||
export * from './ui/quality-radio-group/quality-radio-group-data-attrs';
|
||||
export * from './ui/seek-button/seek-button-core';
|
||||
export * from './ui/seek-button/seek-button-data-attrs';
|
||||
export * from './ui/slider/slider-core';
|
||||
|
||||
@@ -230,6 +230,23 @@ export interface MediaPlaybackRateState {
|
||||
setPlaybackRate(rate: number): void;
|
||||
}
|
||||
|
||||
export interface MediaVideoRendition {
|
||||
id?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate?: number;
|
||||
frameRate?: number;
|
||||
codec?: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface MediaQualityState {
|
||||
/** Video renditions available for manual quality selection. */
|
||||
videoRenditionList: MediaVideoRendition[];
|
||||
/** Select a video rendition by menu value, or automatic ABR with `"auto"`. */
|
||||
selectVideoRendition(value: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text cue.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaQualityState, MediaVideoRendition } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface QualityRadioGroupProps {
|
||||
/** Custom label for the options group. */
|
||||
label?: string | ((state: QualityRadioGroupState) => string) | undefined;
|
||||
/** Custom formatter for visible rendition labels. */
|
||||
formatRendition?: ((rendition: MediaVideoRendition) => string) | undefined;
|
||||
/** Whether quality selection is disabled. */
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface QualityRadioGroupRendition {
|
||||
value: string;
|
||||
label: string;
|
||||
tier?: string | undefined;
|
||||
badge?: string | undefined;
|
||||
}
|
||||
|
||||
export interface QualityRadioGroupState extends ButtonState {
|
||||
renditions: readonly QualityRadioGroupRendition[];
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
availability: 'available' | 'unavailable';
|
||||
}
|
||||
|
||||
export const QUALITY_AUTO_VALUE = 'auto';
|
||||
|
||||
const STANDARD_RENDITION_SIZES: readonly number[] = [4320, 2160, 1440, 1080, 720, 480, 360, 240];
|
||||
|
||||
function formatBitrate(bitrate: number): string {
|
||||
return bitrate >= 1_000_000 ? `${Math.round(bitrate / 100_000) / 10} Mbps` : `${Math.round(bitrate / 1000)} kbps`;
|
||||
}
|
||||
|
||||
function getWidescreenSize(width: number): number | undefined {
|
||||
const size = Math.round((width * 9) / 16);
|
||||
return STANDARD_RENDITION_SIZES.includes(size) ? size : undefined;
|
||||
}
|
||||
|
||||
function getRenditionSize(rendition: MediaVideoRendition): number | undefined {
|
||||
const { width, height } = rendition;
|
||||
|
||||
if (width && height) {
|
||||
// 4:3 and portrait renditions use their actual vertical-ish size. For wider-than-16:9
|
||||
// cinematic encodes, snap to a known 16:9 class only when the width maps cleanly.
|
||||
if (width > height && width * 9 > height * 16) return getWidescreenSize(width) ?? height;
|
||||
return Math.min(width, height);
|
||||
}
|
||||
|
||||
if (height) return height;
|
||||
if (width) return getWidescreenSize(width) ?? width;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasSameSize(rendition: MediaVideoRendition, renditions: readonly MediaVideoRendition[]): boolean {
|
||||
const size = getRenditionSize(rendition);
|
||||
return Boolean(size && renditions.some((other) => other !== rendition && getRenditionSize(other) === size));
|
||||
}
|
||||
|
||||
function formatRenditionLabel(rendition: MediaVideoRendition): string {
|
||||
const size = getRenditionSize(rendition);
|
||||
if (size) return `${size}p`;
|
||||
if (rendition.bitrate) return formatBitrate(rendition.bitrate);
|
||||
return 'Quality';
|
||||
}
|
||||
|
||||
function formatRenditionBadge(
|
||||
rendition: MediaVideoRendition,
|
||||
renditions: readonly MediaVideoRendition[] = []
|
||||
): string | undefined {
|
||||
if (!getRenditionSize(rendition) || !rendition.bitrate || !hasSameSize(rendition, renditions)) return undefined;
|
||||
return formatBitrate(rendition.bitrate);
|
||||
}
|
||||
|
||||
function formatRenditionTier(rendition: MediaVideoRendition): string | undefined {
|
||||
const size = getRenditionSize(rendition);
|
||||
|
||||
if (!size) return undefined;
|
||||
if (size >= 4320) return '8K';
|
||||
if (size >= 2160) return '4K';
|
||||
if (size >= 1080) return 'HD';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRenditionValue(rendition: MediaVideoRendition, index: number): string {
|
||||
return rendition.id || String(index);
|
||||
}
|
||||
|
||||
export class QualityRadioGroupCore {
|
||||
static readonly defaultProps: NonNullableObject<QualityRadioGroupProps> = {
|
||||
label: '',
|
||||
formatRendition: formatRenditionLabel,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<QualityRadioGroupState>({
|
||||
renditions: [],
|
||||
value: QUALITY_AUTO_VALUE,
|
||||
disabled: false,
|
||||
availability: 'unavailable',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...QualityRadioGroupCore.defaultProps };
|
||||
#media: MediaQualityState | null = null;
|
||||
|
||||
constructor(props?: QualityRadioGroupProps) {
|
||||
if (props) this.setProps(props);
|
||||
}
|
||||
|
||||
setProps(props: QualityRadioGroupProps): void {
|
||||
this.#props = defaults(props, QualityRadioGroupCore.defaultProps);
|
||||
}
|
||||
|
||||
getLabel(state: QualityRadioGroupState): string {
|
||||
const { label } = this.#props;
|
||||
|
||||
if (isFunction(label)) {
|
||||
const customLabel = label(state);
|
||||
if (customLabel) return customLabel;
|
||||
} else if (label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return 'Quality';
|
||||
}
|
||||
|
||||
getRenditionLabel(rendition: MediaVideoRendition): string {
|
||||
if (this.#props.formatRendition !== QualityRadioGroupCore.defaultProps.formatRendition) {
|
||||
return this.#props.formatRendition(rendition);
|
||||
}
|
||||
|
||||
return formatRenditionLabel(rendition);
|
||||
}
|
||||
|
||||
getRenditionBadge(
|
||||
rendition: MediaVideoRendition,
|
||||
renditions: readonly MediaVideoRendition[] = []
|
||||
): string | undefined {
|
||||
if (this.#props.formatRendition !== QualityRadioGroupCore.defaultProps.formatRendition) return undefined;
|
||||
|
||||
return formatRenditionBadge(rendition, renditions);
|
||||
}
|
||||
|
||||
getRenditionTier(rendition: MediaVideoRendition): string | undefined {
|
||||
if (this.#props.formatRendition !== QualityRadioGroupCore.defaultProps.formatRendition) return undefined;
|
||||
|
||||
return formatRenditionTier(rendition);
|
||||
}
|
||||
|
||||
getRenditionValue(rendition: MediaVideoRendition, index: number): string {
|
||||
return getRenditionValue(rendition, index);
|
||||
}
|
||||
|
||||
getAttrs(state: QualityRadioGroupState) {
|
||||
return {
|
||||
'aria-label': this.getLabel(state),
|
||||
'aria-disabled': state.disabled ? 'true' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
setMedia(media: MediaQualityState): void {
|
||||
this.#media = media;
|
||||
}
|
||||
|
||||
getState(): QualityRadioGroupState {
|
||||
const media = this.#media!;
|
||||
const selectedIndex = media.videoRenditionList.findIndex((rendition) => rendition.selected);
|
||||
const availability: QualityRadioGroupState['availability'] =
|
||||
media.videoRenditionList.length > 1 ? 'available' : 'unavailable';
|
||||
|
||||
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 }),
|
||||
};
|
||||
}),
|
||||
value:
|
||||
selectedIndex === -1
|
||||
? QUALITY_AUTO_VALUE
|
||||
: this.getRenditionValue(media.videoRenditionList[selectedIndex]!, selectedIndex),
|
||||
disabled: this.#props.disabled || availability === 'unavailable',
|
||||
availability,
|
||||
});
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
select(media: MediaQualityState, value: string): void {
|
||||
if (this.#props.disabled) return;
|
||||
|
||||
if (value === QUALITY_AUTO_VALUE) {
|
||||
media.selectVideoRendition(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValue = media.videoRenditionList.some(
|
||||
(rendition, index) => this.getRenditionValue(rendition, index) === value
|
||||
);
|
||||
if (!hasValue) return;
|
||||
|
||||
media.selectVideoRendition(value);
|
||||
}
|
||||
|
||||
selectValue(media: MediaQualityState, value: string): void {
|
||||
this.select(media, value);
|
||||
}
|
||||
}
|
||||
|
||||
export namespace QualityRadioGroupCore {
|
||||
export type Props = QualityRadioGroupProps;
|
||||
export type State = QualityRadioGroupState;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { StateAttrMap } from '../types';
|
||||
import type { QualityRadioGroupState } from './quality-radio-group-core';
|
||||
|
||||
export const QualityRadioGroupDataAttrs = {
|
||||
/** Current quality value. */
|
||||
value: 'data-quality',
|
||||
/** Present when quality selection is disabled. */
|
||||
disabled: 'data-disabled',
|
||||
/** Indicates quality availability (`available` or `unavailable`). */
|
||||
availability: 'data-availability',
|
||||
} as const satisfies StateAttrMap<QualityRadioGroupState>;
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MediaQualityState } from '../../../media/state';
|
||||
import type { QualityRadioGroupState } from '../quality-radio-group-core';
|
||||
import { QUALITY_AUTO_VALUE, QualityRadioGroupCore } from '../quality-radio-group-core';
|
||||
|
||||
function createMediaState(overrides: Partial<MediaQualityState> = {}): MediaQualityState {
|
||||
return {
|
||||
videoRenditionList: [
|
||||
{ id: '0', height: 1080, bitrate: 6_000_000, selected: false },
|
||||
{ id: '1', height: 720, bitrate: 3_000_000, selected: false },
|
||||
],
|
||||
selectVideoRendition: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<QualityRadioGroupState> = {}): QualityRadioGroupState {
|
||||
return {
|
||||
renditions: [
|
||||
{ value: '0', label: '1080p' },
|
||||
{ value: '1', label: '720p' },
|
||||
],
|
||||
value: QUALITY_AUTO_VALUE,
|
||||
disabled: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('QualityRadioGroupCore', () => {
|
||||
describe('getState', () => {
|
||||
it('projects video renditions', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState();
|
||||
core.setMedia(media);
|
||||
|
||||
const state = core.getState();
|
||||
|
||||
expect(state.renditions).toEqual([
|
||||
{ value: '0', label: '1080p', tier: 'HD' },
|
||||
{ value: '1', label: '720p' },
|
||||
]);
|
||||
expect(state.value).toBe(QUALITY_AUTO_VALUE);
|
||||
});
|
||||
|
||||
it('adds a bitrate badge when multiple renditions share a size', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState({
|
||||
videoRenditionList: [
|
||||
{ id: '0', width: 1920, height: 1080, bitrate: 6_000_000, selected: false },
|
||||
{ id: '1', width: 1080, height: 1920, bitrate: 3_000_000, selected: false },
|
||||
{ id: '2', width: 1280, height: 720, bitrate: 1_500_000, selected: false },
|
||||
],
|
||||
});
|
||||
core.setMedia(media);
|
||||
|
||||
expect(core.getState().renditions).toEqual([
|
||||
{ value: '0', label: '1080p', tier: 'HD', badge: '6 Mbps' },
|
||||
{ value: '1', label: '1080p', tier: 'HD', badge: '3 Mbps' },
|
||||
{ value: '2', label: '720p' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds superscript labels for high-resolution renditions', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState({
|
||||
videoRenditionList: [
|
||||
{ id: '0', width: 1920, height: 1080, selected: false },
|
||||
{ id: '1', width: 3840, height: 2160, selected: false },
|
||||
{ id: '2', width: 7680, height: 4320, selected: false },
|
||||
],
|
||||
});
|
||||
core.setMedia(media);
|
||||
|
||||
expect(core.getState().renditions).toEqual([
|
||||
{ value: '0', label: '1080p', tier: 'HD' },
|
||||
{ value: '1', label: '2160p', tier: '4K' },
|
||||
{ value: '2', label: '4320p', tier: '8K' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the selected rendition value', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState({
|
||||
videoRenditionList: [
|
||||
{ id: '0', height: 1080, selected: false },
|
||||
{ id: '1', height: 720, selected: true },
|
||||
],
|
||||
});
|
||||
core.setMedia(media);
|
||||
|
||||
expect(core.getState().value).toBe('1');
|
||||
});
|
||||
|
||||
it('marks availability unavailable with one rendition', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
core.setMedia(createMediaState({ videoRenditionList: [{ id: '0', height: 1080, selected: false }] }));
|
||||
|
||||
expect(core.getState().availability).toBe('unavailable');
|
||||
expect(core.getState().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLabel', () => {
|
||||
it('returns the default label', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
expect(core.getLabel(createState())).toBe('Quality');
|
||||
});
|
||||
|
||||
it('returns a custom string label', () => {
|
||||
const core = new QualityRadioGroupCore({ label: 'Video quality' });
|
||||
expect(core.getLabel(createState())).toBe('Video quality');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRenditionLabel', () => {
|
||||
it('formats height labels by default', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
expect(core.getRenditionLabel({ height: 1080, selected: false })).toBe('1080p');
|
||||
});
|
||||
|
||||
it('formats portrait labels using the shorter dimension', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
expect(core.getRenditionLabel({ width: 1080, height: 1920, selected: false })).toBe('1080p');
|
||||
});
|
||||
|
||||
it('formats cinematic landscape labels using matching widescreen classes', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
|
||||
expect(core.getRenditionLabel({ width: 1920, height: 800, selected: false })).toBe('1080p');
|
||||
expect(core.getRenditionLabel({ width: 3840, height: 1600, selected: false })).toBe('2160p');
|
||||
});
|
||||
|
||||
it('formats non-standard wide landscape labels using height', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
|
||||
expect(core.getRenditionLabel({ width: 1234, height: 567, selected: false })).toBe('567p');
|
||||
});
|
||||
|
||||
it('formats bitrate labels when height is missing', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
expect(core.getRenditionLabel({ bitrate: 1_500_000, selected: false })).toBe('1.5 Mbps');
|
||||
});
|
||||
|
||||
it('uses a custom formatter', () => {
|
||||
const core = new QualityRadioGroupCore({
|
||||
formatRendition: (rendition) => `${rendition.width}×${rendition.height}`,
|
||||
});
|
||||
|
||||
expect(core.getRenditionLabel({ width: 1920, height: 1080, selected: false })).toBe('1920×1080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectValue', () => {
|
||||
it('selects automatic quality', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState();
|
||||
|
||||
core.selectValue(media, QUALITY_AUTO_VALUE);
|
||||
|
||||
expect(media.selectVideoRendition).toHaveBeenCalledWith(QUALITY_AUTO_VALUE);
|
||||
});
|
||||
|
||||
it('selects a known rendition', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState();
|
||||
|
||||
core.selectValue(media, '1');
|
||||
|
||||
expect(media.selectVideoRendition).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('does nothing for an unknown rendition', () => {
|
||||
const core = new QualityRadioGroupCore();
|
||||
const media = createMediaState();
|
||||
|
||||
core.selectValue(media, '3');
|
||||
|
||||
expect(media.selectVideoRendition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const core = new QualityRadioGroupCore({ disabled: true });
|
||||
const media = createMediaState();
|
||||
|
||||
core.selectValue(media, '1');
|
||||
|
||||
expect(media.selectVideoRendition).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Quality
|
||||
description: Video rendition state and actions for the player store
|
||||
---
|
||||
|
||||
import FeatureReference from "@/components/docs/api-reference/FeatureReference.astro";
|
||||
import DocsLink from "@/components/docs/DocsLink.astro";
|
||||
import FrameworkCase from "@/components/docs/FrameworkCase.astro";
|
||||
|
||||
Tracks video renditions and selects a manual rendition or automatic adaptive bitrate.
|
||||
|
||||
<FeatureReference feature="quality" />
|
||||
|
||||
### Selector
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
Pass `selectQuality` to <DocsLink slug="reference/use-player">`usePlayer`</DocsLink> to subscribe to quality state. Returns `undefined` if the quality feature is not configured.
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
Pass `selectQuality` to <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> to subscribe to quality state. Returns `undefined` if the quality feature is not configured.
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
```tsx title="QualityInfo.tsx"
|
||||
import { selectQuality, usePlayer } from '@videojs/react';
|
||||
|
||||
function QualityInfo() {
|
||||
const quality = usePlayer(selectQuality);
|
||||
if (!quality) return null;
|
||||
|
||||
return <span>{quality.videoRenditionList.length} renditions</span>;
|
||||
}
|
||||
```
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
```ts title="quality-info.ts"
|
||||
import { createPlayer, MediaElement, selectQuality } from '@videojs/html';
|
||||
import { videoFeatures } from '@videojs/html/video';
|
||||
|
||||
const { PlayerController, context } = createPlayer({ features: videoFeatures });
|
||||
|
||||
class QualityInfo extends MediaElement {
|
||||
readonly #quality = new PlayerController(this, context, selectQuality);
|
||||
}
|
||||
```
|
||||
</FrameworkCase>
|
||||
@@ -134,6 +134,7 @@ export const sidebar: Sidebar = [
|
||||
{ slug: 'reference/feature-pip', sidebarLabel: 'Picture-in-picture' },
|
||||
{ slug: 'reference/feature-playback' },
|
||||
{ slug: 'reference/feature-playback-rate' },
|
||||
{ slug: 'reference/feature-quality' },
|
||||
{ slug: 'reference/feature-remote-playback' },
|
||||
{ slug: 'reference/feature-source' },
|
||||
{ slug: 'reference/feature-stream-type' },
|
||||
|
||||
Reference in New Issue
Block a user