From 74da6821d7c60f79bc07d36bbc4cb49fd5c6045e Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Thu, 18 Jun 2026 07:20:13 +1000 Subject: [PATCH] feat(core): add quality selection state (#1693) --- packages/core/src/core/index.ts | 2 + packages/core/src/core/media/state.ts | 17 ++ .../quality-radio-group-core.ts | 227 ++++++++++++++++++ .../quality-radio-group-data-attrs.ts | 11 + .../tests/quality-radio-group-core.test.ts | 193 +++++++++++++++ packages/core/src/dom/media/predicate.ts | 7 + packages/core/src/dom/media/types.ts | 2 + .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + .../core/src/dom/store/features/presets.ts | 2 + .../core/src/dom/store/features/quality.ts | 83 +++++++ .../dom/store/features/tests/quality.test.ts | 144 +++++++++++ packages/core/src/dom/store/selectors.ts | 3 + .../docs/reference/feature-quality.mdx | 48 ++++ site/src/docs.config.ts | 1 + 15 files changed, 743 insertions(+) create mode 100644 packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts create mode 100644 packages/core/src/core/ui/quality-radio-group/quality-radio-group-data-attrs.ts create mode 100644 packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts create mode 100644 packages/core/src/dom/store/features/quality.ts create mode 100644 packages/core/src/dom/store/features/tests/quality.test.ts create mode 100644 site/src/content/docs/reference/feature-quality.mdx diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index e0ced1f4..274d9597 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 5a4970b1..6af4f8f9 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -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. * diff --git a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts new file mode 100644 index 00000000..f6222d96 --- /dev/null +++ b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts @@ -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 = { + label: '', + formatRendition: formatRenditionLabel, + disabled: false, + }; + + readonly state = createState({ + 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; +} diff --git a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-data-attrs.ts b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-data-attrs.ts new file mode 100644 index 00000000..6450dd83 --- /dev/null +++ b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts b/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts new file mode 100644 index 00000000..2428fb16 --- /dev/null +++ b/packages/core/src/core/ui/quality-radio-group/tests/quality-radio-group-core.test.ts @@ -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 { + 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 { + 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(); + }); + }); +}); diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index ed51cced..4c9ed8b0 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -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; + return !isUndefined(media.videoRenditions); +} + export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability { if (!isObject(value)) return false; const media = value as Record; diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 2cf8be37..c1a23411 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -8,6 +8,7 @@ import type { MediaPictureInPictureState, MediaPlaybackRateState, MediaPlaybackState, + MediaQualityState, MediaRemotePlaybackState, MediaSourceState, MediaTextTrackState, @@ -42,6 +43,7 @@ export type AnyPlayerStore = Store; export type VideoFeatures = [ PlayerFeature, PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 222351ad..59f0295f 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -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, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 2025b08b..7598fefb 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -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'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index c1919724..39986656 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -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, diff --git a/packages/core/src/dom/store/features/quality.ts b/packages/core/src/dom/store/features/quality.ts new file mode 100644 index 00000000..80e2caec --- /dev/null +++ b/packages/core/src/dom/store/features/quality.ts @@ -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 }); + }, +}); diff --git a/packages/core/src/dom/store/features/tests/quality.test.ts b/packages/core/src/dom/store/features/tests/quality.test.ts new file mode 100644 index 00000000..41cc1860 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/quality.test.ts @@ -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 { + 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 { + return [][Symbol.iterator](); + } +} + +class TestMedia extends EventTarget { + videoRenditions: TestRenditionList | undefined = undefined; + videoTracks = new TestTrackList(); + + async play() {} +} + +function createRendition(overrides: Partial): 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()(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()(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()(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()(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()(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); + }); +}); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 17d28ab9..0e7644f9 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -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). */ diff --git a/site/src/content/docs/reference/feature-quality.mdx b/site/src/content/docs/reference/feature-quality.mdx new file mode 100644 index 00000000..1153fe30 --- /dev/null +++ b/site/src/content/docs/reference/feature-quality.mdx @@ -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. + + + +### Selector + + +Pass `selectQuality` to `usePlayer` to subscribe to quality state. Returns `undefined` if the quality feature is not configured. + + + +Pass `selectQuality` to `PlayerController` to subscribe to quality state. Returns `undefined` if the quality feature is not configured. + + + +```tsx title="QualityInfo.tsx" +import { selectQuality, usePlayer } from '@videojs/react'; + +function QualityInfo() { + const quality = usePlayer(selectQuality); + if (!quality) return null; + + return {quality.videoRenditionList.length} renditions; +} +``` + + + +```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); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index d0d9bce9..333e2f24 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -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' },