diff --git a/packages/core/src/core/media/media-tracks/video-rendition-list.ts b/packages/core/src/core/media/media-tracks/video-rendition-list.ts index 3d860cfa..157ebf3f 100644 --- a/packages/core/src/core/media/media-tracks/video-rendition-list.ts +++ b/packages/core/src/core/media/media-tracks/video-rendition-list.ts @@ -59,6 +59,22 @@ export function selectedChanged(rendition: VideoRendition) { }); } +export function activeChanged(rendition: VideoRendition) { + const renditionList = getPrivate(rendition).media?.deref()?.videoRenditions as VideoRenditionList | undefined; + + if (!renditionList || getPrivate(renditionList).activeChangeRequested) return; + getPrivate(renditionList).activeChangeRequested = true; + + queueMicrotask(() => { + delete getPrivate(renditionList).activeChangeRequested; + + const track = getPrivate(rendition).track as VideoTrack; + if (!track.selected) return; + + renditionList.dispatchEvent(new Event('activechange')); + }); +} + function getCurrentRenditions(renditionList: VideoRenditionList): VideoRendition[] { const media = getPrivate(renditionList).media?.deref() as HTMLMediaElement | undefined; if (!media) return []; diff --git a/packages/core/src/core/media/media-tracks/video-rendition.ts b/packages/core/src/core/media/media-tracks/video-rendition.ts index 81ca3ac7..021914ac 100644 --- a/packages/core/src/core/media/media-tracks/video-rendition.ts +++ b/packages/core/src/core/media/media-tracks/video-rendition.ts @@ -1,4 +1,4 @@ -import { selectedChanged } from './video-rendition-list'; +import { activeChanged, selectedChanged } from './video-rendition-list'; /** * The consumer should use the `selected` setter to select one or multiple @@ -13,6 +13,7 @@ export class VideoRendition { frameRate: number | undefined; codec: string | undefined; #selected = false; + #active = false; get selected(): boolean { return this.#selected; @@ -24,4 +25,15 @@ export class VideoRendition { selectedChanged(this); } + + get active(): boolean { + return this.#active; + } + + set active(value: boolean) { + if (this.#active === value) return; + this.#active = value; + + activeChanged(this); + } } diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 6af4f8f9..55489e8d 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -243,6 +243,8 @@ export interface MediaVideoRendition { export interface MediaQualityState { /** Video renditions available for manual quality selection. */ videoRenditionList: MediaVideoRendition[]; + /** Video rendition currently playing, including when automatic ABR is selected. */ + activeVideoRendition: MediaVideoRendition | null; /** Select a video rendition by menu value, or automatic ABR with `"auto"`. */ selectVideoRendition(value: string): void; } diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index dff551d5..1cdec075 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -360,9 +360,14 @@ export interface VideoRenditionLike { readonly frameRate: number | undefined; readonly codec: string | undefined; selected: boolean; + active?: boolean | undefined; } -export interface VideoRenditionListLike extends EventTargetLike> { +interface VideoRenditionListEvents extends RenditionListEvents { + activechange: EventLike; +} + +export interface VideoRenditionListLike extends EventTargetLike { readonly length: number; readonly [index: number]: VideoRenditionLike; [Symbol.iterator](): Iterator; 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 index f6222d96..3e837db1 100644 --- 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 @@ -24,6 +24,7 @@ export interface QualityRadioGroupRendition { export interface QualityRadioGroupState extends ButtonState { renditions: readonly QualityRadioGroupRendition[]; + autoLabel: string; value: string; disabled: boolean; availability: 'available' | 'unavailable'; @@ -93,6 +94,18 @@ function getRenditionValue(rendition: MediaVideoRendition, index: number): strin return rendition.id || String(index); } +function isSameRendition(a: MediaVideoRendition, b: MediaVideoRendition): boolean { + if (a.id !== undefined || b.id !== undefined) return a.id === b.id; + + return ( + a.width === b.width && + a.height === b.height && + a.bitrate === b.bitrate && + a.frameRate === b.frameRate && + a.codec === b.codec + ); +} + export class QualityRadioGroupCore { static readonly defaultProps: NonNullableObject = { label: '', @@ -102,6 +115,7 @@ export class QualityRadioGroupCore { readonly state = createState({ renditions: [], + autoLabel: 'Auto', value: QUALITY_AUTO_VALUE, disabled: false, availability: 'unavailable', @@ -175,19 +189,29 @@ export class QualityRadioGroupCore { const selectedIndex = media.videoRenditionList.findIndex((rendition) => rendition.selected); const availability: QualityRadioGroupState['availability'] = media.videoRenditionList.length > 1 ? 'available' : 'unavailable'; + const toRendition = (rendition: MediaVideoRendition, index: number): QualityRadioGroupRendition => { + const tier = this.getRenditionTier(rendition); + const badge = this.getRenditionBadge(rendition, media.videoRenditionList); + + return { + value: this.getRenditionValue(rendition, index), + label: this.getRenditionLabel(rendition), + ...(tier && { tier }), + ...(badge && { badge }), + }; + }; + const activeIndex = + media.activeVideoRendition === null + ? -1 + : media.videoRenditionList.findIndex((rendition) => isSameRendition(rendition, media.activeVideoRendition!)); + const active = + media.activeVideoRendition && activeIndex !== -1 + ? toRendition(media.activeVideoRendition, activeIndex) + : undefined; this.state.patch({ - renditions: media.videoRenditionList.map((rendition, index) => { - const tier = this.getRenditionTier(rendition); - const badge = this.getRenditionBadge(rendition, media.videoRenditionList); - - return { - value: this.getRenditionValue(rendition, index), - label: this.getRenditionLabel(rendition), - ...(tier && { tier }), - ...(badge && { badge }), - }; - }), + renditions: media.videoRenditionList.map(toRendition), + autoLabel: selectedIndex === -1 && active ? `Auto (${active.label})` : 'Auto', value: selectedIndex === -1 ? QUALITY_AUTO_VALUE 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 index 2428fb16..125964cd 100644 --- 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 @@ -10,6 +10,7 @@ function createMediaState(overrides: Partial = {}): MediaQual { id: '0', height: 1080, bitrate: 6_000_000, selected: false }, { id: '1', height: 720, bitrate: 3_000_000, selected: false }, ], + activeVideoRendition: null, selectVideoRendition: vi.fn(), ...overrides, }; @@ -21,6 +22,7 @@ function createState(overrides: Partial = {}): QualityRa { value: '0', label: '1080p' }, { value: '1', label: '720p' }, ], + autoLabel: 'Auto', value: QUALITY_AUTO_VALUE, disabled: false, availability: 'available', @@ -94,6 +96,19 @@ describe('QualityRadioGroupCore', () => { expect(core.getState().value).toBe('1'); }); + it('labels automatic with the active rendition', () => { + const core = new QualityRadioGroupCore(); + const media = createMediaState({ + activeVideoRendition: { id: '1', height: 720, selected: false }, + }); + core.setMedia(media); + + const state = core.getState(); + + expect(state.value).toBe(QUALITY_AUTO_VALUE); + expect(state.autoLabel).toBe('Auto (720p)'); + }); + it('marks availability unavailable with one rendition', () => { const core = new QualityRadioGroupCore(); core.setMedia(createMediaState({ videoRenditionList: [{ id: '0', height: 1080, selected: false }] })); diff --git a/packages/core/src/dom/media/hls/media-tracks.ts b/packages/core/src/dom/media/hls/media-tracks.ts index 296104e8..098a5994 100644 --- a/packages/core/src/dom/media/hls/media-tracks.ts +++ b/packages/core/src/dom/media/hls/media-tracks.ts @@ -57,6 +57,7 @@ export function HlsJsMediaMediaTracksMixin { + const activeId = `${data.level}`; + + for (const rendition of this.videoRenditions) { + rendition.active = rendition.id === activeId; + } + }; + #switchRendition = () => { const { engine } = this; if (!engine) return; @@ -145,6 +154,7 @@ export function HlsJsMediaMediaTracksMixin { expect(engine.nextLevel).toBe(2); }); + it('marks the active rendition from LEVEL_SWITCHED', () => { + const engine = createEngine(); + const host = new HlsJsMediaMediaTracks(engine); + + manifestParsed(engine, [{ url: ['a'] }, { url: ['b'] }, { url: ['c'] }]); + + (engine as any).emit(Hls.Events.LEVEL_SWITCHED, { level: 1 }); + + expect([...host.videoRenditions].map((rendition) => rendition.active)).toEqual([false, true, false]); + expect(engine.nextLevel).toBe(-1); + }); + it('forwards an audio track selection to engine.audioTrack', async () => { const engine = createEngine(); const host = new HlsJsMediaMediaTracks(engine); diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index 4c9ed8b0..f8244118 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, + MediaVideoDimensionsCapability, MediaVideoRenditionCapability, MediaVolumeCapability, } from '../../core/media/types'; @@ -84,6 +85,12 @@ export function isMediaVideoRenditionCapable(value: unknown): value is MediaVide return !isUndefined(media.videoRenditions); } +export function isMediaVideoDimensionsCapable(value: unknown): value is MediaVideoDimensionsCapability { + if (!isObject(value)) return false; + const media = value as Record; + return !isUndefined(media.videoWidth) && !isUndefined(media.videoHeight); +} + export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability { if (!isObject(value)) return false; const media = value as Record; diff --git a/packages/core/src/dom/store/features/quality.ts b/packages/core/src/dom/store/features/quality.ts index 80e2caec..5d1f8b4e 100644 --- a/packages/core/src/dom/store/features/quality.ts +++ b/packages/core/src/dom/store/features/quality.ts @@ -3,7 +3,7 @@ import { listen } from '@videojs/utils/dom'; import type { MediaQualityState, MediaVideoRendition } from '../../../core/media/state'; import type { VideoRenditionLike, VideoRenditionListLike } from '../../../core/media/types'; import { definePlayerFeature } from '../../feature'; -import { isMediaVideoRenditionCapable } from '../../media/predicate'; +import { isMediaVideoDimensionsCapable, isMediaVideoRenditionCapable } from '../../media/predicate'; const QUALITY_AUTO_VALUE = 'auto'; @@ -23,10 +23,16 @@ function toMediaRendition(rendition: VideoRenditionLike): MediaVideoRendition { }; } +function getSize(rendition: Pick): number | undefined { + if (rendition.width && rendition.height) return Math.min(rendition.width, rendition.height); + return rendition.height ?? rendition.width; +} + export const qualityFeature = definePlayerFeature({ name: 'quality', state: ({ target }): MediaQualityState => ({ videoRenditionList: [], + activeVideoRendition: null, selectVideoRendition(value: string) { const { media } = target(); if (!isMediaVideoRenditionCapable(media)) return; @@ -50,8 +56,31 @@ export const qualityFeature = definePlayerFeature({ let cleanup: AbortController | null = null; const getVideoRenditions = () => (isMediaVideoRenditionCapable(media) ? media.videoRenditions : null); + const getActiveRendition = (list: VideoRenditionListLike | null) => { + if (!list) return null; + + const renditions = [...list]; + const active = renditions.find((rendition) => rendition.active); + if (active) return active; + + if (!isMediaVideoDimensionsCapable(media) || (!media.videoWidth && !media.videoHeight)) return null; + + const size = getSize({ + width: media.videoWidth || undefined, + height: media.videoHeight || undefined, + }); + const matches = renditions.filter((rendition) => getSize(rendition) === size); + + return matches.length === 1 ? matches[0] : null; + }; + const sync = (list = getVideoRenditions()) => { - set({ videoRenditionList: list ? [...list].map(toMediaRendition) : [] }); + const active = getActiveRendition(list); + + set({ + videoRenditionList: list ? [...list].map(toMediaRendition) : [], + activeVideoRendition: active ? toMediaRendition(active) : null, + }); }; const bind = () => { @@ -70,6 +99,7 @@ export const qualityFeature = definePlayerFeature({ listen(videoRenditions, 'addrendition', () => sync(videoRenditions), { signal: cleanup.signal }); listen(videoRenditions, 'removerendition', () => sync(videoRenditions), { signal: cleanup.signal }); listen(videoRenditions, 'change', () => sync(videoRenditions), { signal: cleanup.signal }); + listen(videoRenditions, 'activechange', () => sync(videoRenditions), { signal: cleanup.signal }); } sync(videoRenditions); @@ -78,6 +108,7 @@ export const qualityFeature = definePlayerFeature({ bind(); listen(media, 'loadstart', bind, { signal }); + listen(media, 'resize', () => sync(videoRenditions), { signal }); signal.addEventListener('abort', () => cleanup?.abort(), { once: true }); }, }); diff --git a/packages/core/src/dom/store/features/tests/quality.test.ts b/packages/core/src/dom/store/features/tests/quality.test.ts index 41cc1860..c965721d 100644 --- a/packages/core/src/dom/store/features/tests/quality.test.ts +++ b/packages/core/src/dom/store/features/tests/quality.test.ts @@ -41,6 +41,13 @@ class TestTrackList extends EventTarget { class TestMedia extends EventTarget { videoRenditions: TestRenditionList | undefined = undefined; videoTracks = new TestTrackList(); + videoWidth = 0; + videoHeight = 0; + + constructor(renditions?: VideoRenditionLike[]) { + super(); + if (renditions) this.videoRenditions = new TestRenditionList(renditions); + } async play() {} } @@ -59,14 +66,7 @@ function createRendition(overrides: Partial): VideoRendition } function createMedia(renditions: VideoRenditionLike[]): PlayerTarget['media'] { - return { - play: async () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => true, - videoRenditions: new TestRenditionList(renditions), - videoTracks: new TestTrackList(), - } as unknown as PlayerTarget['media']; + return new TestMedia(renditions) as unknown as PlayerTarget['media']; } describe('qualityFeature', () => { @@ -83,6 +83,7 @@ describe('qualityFeature', () => { { id: '0', height: 1080, bitrate: 6_000_000, selected: false }, { id: '1', height: 720, bitrate: 3_000_000, selected: false }, ]); + expect(store.state.activeVideoRendition).toBeNull(); }); it('syncs video renditions after loadstart', () => { @@ -141,4 +142,66 @@ describe('qualityFeature', () => { expect(store.state.videoRenditionList[1]?.selected).toBe(true); }); + + it('syncs the active video rendition', () => { + const media = createMedia([ + createRendition({ id: '0', height: 1080 }), + createRendition({ id: '1', height: 720, active: true }), + ]); + const store = createStore()(qualityFeature); + store.attach({ media, container: null }); + + expect(store.state.activeVideoRendition).toEqual({ id: '1', height: 720, selected: false }); + + (media as any).videoRenditions.renditions[1].active = false; + (media as any).videoRenditions.renditions[0].active = true; + (media as any).videoRenditions.dispatchEvent(new Event('activechange')); + + expect(store.state.activeVideoRendition).toEqual({ id: '0', height: 1080, selected: false }); + }); + + it('falls back to video dimensions for the active rendition', () => { + const media = createMedia([createRendition({ id: '0', height: 1080 }), createRendition({ id: '1', height: 720 })]); + const testMedia = media as unknown as TestMedia; + testMedia.videoWidth = 1280; + testMedia.videoHeight = 720; + const store = createStore()(qualityFeature); + + store.attach({ media, container: null }); + + expect(store.state.activeVideoRendition).toEqual({ id: '1', height: 720, selected: false }); + + testMedia.videoWidth = 1920; + testMedia.videoHeight = 1080; + media.dispatchEvent(new Event('resize')); + + expect(store.state.activeVideoRendition).toEqual({ id: '0', height: 1080, selected: false }); + }); + + it('does not fall back when multiple renditions share the video dimensions', () => { + const media = createMedia([ + createRendition({ id: '0', height: 1080, bitrate: 6_000_000 }), + createRendition({ id: '1', height: 1080, bitrate: 3_000_000 }), + createRendition({ id: '2', height: 720, bitrate: 1_500_000 }), + ]); + const testMedia = media as unknown as TestMedia; + testMedia.videoWidth = 1920; + testMedia.videoHeight = 1080; + const store = createStore()(qualityFeature); + + store.attach({ media, container: null }); + + expect(store.state.activeVideoRendition).toBeNull(); + + testMedia.videoWidth = 1280; + testMedia.videoHeight = 720; + media.dispatchEvent(new Event('resize')); + + expect(store.state.activeVideoRendition).toEqual({ + id: '2', + height: 720, + bitrate: 1_500_000, + selected: false, + }); + }); }); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 0e7644f9..a709b4f5 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -32,7 +32,7 @@ export const selectPiP = createSelector(pipFeature); export const selectPlayback = createSelector(playbackFeature); /** Select the playback rate state (playbackRate, playbackRates, setPlaybackRate). */ export const selectPlaybackRate = createSelector(playbackRateFeature); -/** Select the quality state (videoRenditionList, selectVideoRendition). */ +/** Select the quality state (videoRenditionList, activeVideoRendition, selectVideoRendition). */ export const selectQuality = createSelector(qualityFeature); /** Select the remote playback state (remote playback connection state, availability). */ export const selectRemotePlayback = createSelector(remotePlaybackFeature); diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 6be023e1..e3a675db 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -171,8 +171,10 @@ function getTemplateHTML() {