mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add quality selection state (#1693)
This commit is contained in:
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user