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
+2
View File
@@ -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';
+17
View File
@@ -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();
});
});
});
+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). */
@@ -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>
+1
View File
@@ -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' },