mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): add quality selection to shared player controls (#1470)
* feat(playback): add quality selection to shared player controls Adds a per-session video quality menu (Auto + "1080p"-style levels) to the shared player-controls layer, mirroring the audio-track pattern: - Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state, setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR. - hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with list-index ids, smooth switching through nextLevel, selection read from manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED, LEVEL_SWITCHED. - Shaka (DASH): variant tracks filtered to the active audio language, ABR disabled before selectVariantTrack; manual state keyed to the exact player instance so a session restart never shows a stale selection. - Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual = exactly one enabled level, auto = all enabled, derived statelessly). - Embedded MPV and external players report the capability false. The capability derives from the manifest (advertised only for >1 video rendition), nothing persists to Settings, and the menu rides the default-off webPlayerSharedControls rollout gate. Labels come from one shared helper so all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to all 19 i18n files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): pin DASH quality candidates to the active audio stream Review findings on #1470: - Shaka quality candidates now match the active variant's exact audioId (language fallback only when Shaka reports none), so a DASH manifest with same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no longer switch the audio track or show duplicate levels when a quality is picked. Regression test added. - Mirror the quality-selection contract into AGENTS.md's Shared Player Controls section, which must stay in sync with CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): track Video.js manual quality intent explicitly Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off itself when it temporarily excludes failing renditions, so inferring the manual/auto mode from the enabled count could report a manual selection the user never made once exclusions leave a single survivor. VjsQualityLevels now records the picked level object as explicit manual intent: error exclusions read as auto, a picked level that leaves the list reverts to auto, and the bridge resets the intent on every new source. Regression tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): re-enable surviving renditions when the picked level is removed Codex follow-up on #1470: dropping manual intent when the picked QualityLevel leaves the list reverted the UI to auto but left the surviving renditions disabled by the earlier manual pick, pinning VHS with no selectable rendition. Reverting to auto now re-enables every remaining level, both on the removal event and lazily from the state read. Regression tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
61 files changed
+1582
-10
No files matched your search
@@ -71,6 +71,8 @@ class MockHls {
|
||||
SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared',
|
||||
SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch',
|
||||
MANIFEST_LOADING: 'manifestLoading',
|
||||
LEVELS_UPDATED: 'levelsUpdated',
|
||||
LEVEL_SWITCHED: 'levelSwitched',
|
||||
};
|
||||
static isSupported = jest.fn(() => true);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
volume: true,
|
||||
audioTracks: true,
|
||||
subtitles: true,
|
||||
qualityLevels: false,
|
||||
playbackSpeed: true,
|
||||
aspectRatio: true,
|
||||
recording: true,
|
||||
@@ -220,6 +221,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
|
||||
volume: true,
|
||||
audioTracks: true,
|
||||
subtitles: false,
|
||||
qualityLevels: false,
|
||||
playbackSpeed: false,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
|
||||
@@ -176,6 +176,9 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
})
|
||||
),
|
||||
subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0,
|
||||
// MPV demuxes one program; no HLS/DASH rendition list is surfaced.
|
||||
qualityLevels: [],
|
||||
qualityAutoEnabled: true,
|
||||
playbackSpeed: session?.playbackSpeed ?? 1,
|
||||
speedPresets: DEFAULT_SPEED_PRESETS,
|
||||
aspectRatio: session?.aspectOverride ?? 'no',
|
||||
@@ -207,6 +210,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
setVolume: (value) => void this.controller.applyVolume(value),
|
||||
setAudioTrack: (id) => void this.controller.setAudioTrack(id),
|
||||
setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id),
|
||||
setQualityLevel: () => undefined,
|
||||
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
|
||||
setAspectRatio: (value) => void this.controller.setAspect(value),
|
||||
toggleRecording: () => void this.toggleRecording(),
|
||||
|
||||
@@ -121,6 +121,80 @@ describe('HtmlVideoPlayerControlsBridge HLS tracks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS quality levels', () => {
|
||||
it('projects labelled levels and advertises the capability above one level', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.levels = [
|
||||
{ height: 1080, width: 1920, bitrate: 8_000_000 },
|
||||
{ height: 720, width: 1280, bitrate: 4_000_000 },
|
||||
];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
|
||||
expect(adapter.capabilities().qualityLevels).toBe(true);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
expect(adapter.state().qualityLevels).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('hides the capability for a single-rendition manifest', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.levels = [{ height: 720, bitrate: 2_000_000 }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('switches through nextLevel and marks the manual selection', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.levels = [{ height: 1080 }, { height: 720 }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setQualityLevel(1);
|
||||
expect(hls.assignments).toEqual(['nextLevel:1']);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(false);
|
||||
expect(adapter.state().qualityLevels[1].selected).toBe(true);
|
||||
expect(adapter.state().qualityLevels[0].selected).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('returns to auto with the sentinel id', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.levels = [{ height: 1080 }, { height: 720 }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
adapter.commands.setQualityLevel(0);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setQualityLevel(-1);
|
||||
expect(hls.assignments).toEqual(['nextLevel:-1']);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
expect(
|
||||
adapter.state().qualityLevels.some((level) => level.selected)
|
||||
).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('ignores invalid and out-of-range level ids', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.levels = [{ height: 1080 }, { height: 720 }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setQualityLevel(-2);
|
||||
adapter.commands.setQualityLevel(0.5);
|
||||
adapter.commands.setQualityLevel(2);
|
||||
adapter.commands.setQualityLevel(NaN);
|
||||
|
||||
expect(hls.assignments).toEqual([]);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
bridge.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => {
|
||||
const refreshEvents = [
|
||||
Hls.Events.AUDIO_TRACKS_UPDATED,
|
||||
@@ -130,6 +204,9 @@ describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => {
|
||||
Hls.Events.SUBTITLE_TRACKS_CLEARED,
|
||||
Hls.Events.SUBTITLE_TRACK_SWITCH,
|
||||
Hls.Events.MANIFEST_LOADING,
|
||||
Hls.Events.MANIFEST_PARSED,
|
||||
Hls.Events.LEVELS_UPDATED,
|
||||
Hls.Events.LEVEL_SWITCHED,
|
||||
];
|
||||
|
||||
it('refreshes from every relevant HLS event with one callback reference', () => {
|
||||
|
||||
+26
@@ -17,11 +17,18 @@ interface FakeHlsTrack {
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface FakeHlsLevel {
|
||||
height?: number;
|
||||
width?: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
type FakeHlsListener = (...args: unknown[]) => void;
|
||||
|
||||
export class FakeHls {
|
||||
audioTracks: FakeHlsTrack[] = [];
|
||||
subtitleTracks: FakeHlsTrack[] = [];
|
||||
levels: FakeHlsLevel[] = [];
|
||||
readonly assignments: string[] = [];
|
||||
subtitleTrackSwitchEvents = 0;
|
||||
private readonly listeners = new Map<string, Set<FakeHlsListener>>();
|
||||
@@ -29,6 +36,7 @@ export class FakeHls {
|
||||
private selectedAudioTrack = -1;
|
||||
private selectedSubtitleTrack = -1;
|
||||
private displaySubtitles = false;
|
||||
private manualQualityLevel = -1;
|
||||
|
||||
readonly on = jest.fn((event: string, listener: FakeHlsListener): void => {
|
||||
const eventListeners =
|
||||
@@ -66,6 +74,24 @@ export class FakeHls {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors hls.js: assigning nextLevel records the manual level; -1 = auto.
|
||||
get nextLevel(): number {
|
||||
return this.manualQualityLevel;
|
||||
}
|
||||
|
||||
set nextLevel(value: number) {
|
||||
this.assignments.push(`nextLevel:${value}`);
|
||||
this.manualQualityLevel = value;
|
||||
}
|
||||
|
||||
get manualLevel(): number {
|
||||
return this.manualQualityLevel;
|
||||
}
|
||||
|
||||
get autoLevelEnabled(): boolean {
|
||||
return this.manualQualityLevel === -1;
|
||||
}
|
||||
|
||||
get subtitleDisplay(): boolean {
|
||||
return this.displaySubtitles;
|
||||
}
|
||||
|
||||
+2
-2
@@ -303,7 +303,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
lifecycle
|
||||
.slice(0, destroyIndex)
|
||||
.filter((event) => event.startsWith('off:'))
|
||||
).toHaveLength(7);
|
||||
).toHaveLength(10);
|
||||
const internals = readHtmlPlayerInternals(component);
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
|
||||
@@ -312,7 +312,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
expect(internals.mpegtsPlayer).toBeNull();
|
||||
expect(hlsInstances).toHaveLength(1);
|
||||
expect(mpegTsInstances).toHaveLength(0);
|
||||
expect(hls.off).toHaveBeenCalledTimes(7);
|
||||
expect(hls.off).toHaveBeenCalledTimes(10);
|
||||
expect(adapter.state().audioTracks).toEqual([]);
|
||||
expect(adapter.state().subtitleTracks).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('ControlsMenuSelection', () => {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
@@ -62,6 +63,15 @@ describe('ControlsMenuSelection', () => {
|
||||
expect(visibility.scheduleHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a quality level and closes the quality menu', () => {
|
||||
menus.open('quality');
|
||||
selection.qualityLevel(-1);
|
||||
|
||||
expect(commands.setQualityLevel).toHaveBeenCalledWith(-1);
|
||||
expect(menus.qualityOpen()).toBe(false);
|
||||
expect(visibility.scheduleHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies a playback speed and closes the speed menu', () => {
|
||||
menus.open('speed');
|
||||
selection.speed(1.5);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ControlsMenuState } from './controls-menu-state';
|
||||
import type { ControlsVisibility } from './controls-visibility';
|
||||
import type { PlayerControlsCommands } from './player-controls.model';
|
||||
|
||||
type MenuKey = 'audio' | 'subtitle' | 'speed' | 'aspect';
|
||||
type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect';
|
||||
|
||||
export interface MenuSelectionDeps {
|
||||
commands: () => PlayerControlsCommands;
|
||||
@@ -32,6 +32,10 @@ export class ControlsMenuSelection {
|
||||
this.apply('subtitle', (c) => c.setSubtitleTrack(trackId));
|
||||
}
|
||||
|
||||
qualityLevel(levelId: number): void {
|
||||
this.apply('quality', (c) => c.setQualityLevel(levelId));
|
||||
}
|
||||
|
||||
speed(value: number): void {
|
||||
this.apply('speed', (c) => c.setPlaybackSpeed(value));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,14 @@ describe('ControlsMenuState', () => {
|
||||
expect(menus.anyOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['volume', 'audio', 'subtitle', 'speed', 'aspect'] as const)(
|
||||
it.each([
|
||||
'volume',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'quality',
|
||||
'speed',
|
||||
'aspect',
|
||||
] as const)(
|
||||
'closes an open %s menu when it becomes unavailable',
|
||||
(menu) => {
|
||||
const menus = new ControlsMenuState();
|
||||
@@ -38,6 +45,7 @@ describe('ControlsMenuState', () => {
|
||||
volume: menu !== 'volume',
|
||||
audio: menu !== 'audio',
|
||||
subtitle: menu !== 'subtitle',
|
||||
quality: menu !== 'quality',
|
||||
speed: menu !== 'speed',
|
||||
aspect: menu !== 'aspect',
|
||||
});
|
||||
@@ -56,6 +64,7 @@ describe('ControlsMenuState', () => {
|
||||
volume: true,
|
||||
audio: true,
|
||||
subtitle: true,
|
||||
quality: true,
|
||||
speed: true,
|
||||
aspect: true,
|
||||
})
|
||||
@@ -73,6 +82,7 @@ describe('ControlsMenuState', () => {
|
||||
volume: false,
|
||||
audio: false,
|
||||
subtitle: false,
|
||||
quality: false,
|
||||
speed: false,
|
||||
aspect: false,
|
||||
})
|
||||
@@ -84,6 +94,7 @@ describe('ControlsMenuState', () => {
|
||||
['volume', { volume: false }, {}],
|
||||
['audio', {}, { audioTracks: [] }],
|
||||
['subtitle', {}, { subtitleTracks: [] }],
|
||||
['quality', {}, { qualityLevels: [] }],
|
||||
['speed', { playbackSpeed: false }, {}],
|
||||
['aspect', { aspectRatio: false }, {}],
|
||||
] as const)(
|
||||
@@ -99,6 +110,7 @@ describe('ControlsMenuState', () => {
|
||||
volume: true,
|
||||
audioTracks: true,
|
||||
subtitles: true,
|
||||
qualityLevels: true,
|
||||
playbackSpeed: true,
|
||||
aspectRatio: true,
|
||||
...capabilityOverrides,
|
||||
@@ -112,6 +124,10 @@ describe('ControlsMenuState', () => {
|
||||
subtitleTracks: [
|
||||
{ id: 1, label: 'English', selected: true },
|
||||
],
|
||||
qualityLevels: [
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
],
|
||||
...stateOverrides,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ const CONTROL_MENUS = [
|
||||
'volume',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'quality',
|
||||
'speed',
|
||||
'aspect',
|
||||
] as const;
|
||||
@@ -30,6 +31,10 @@ function getControlsMenuAvailability(
|
||||
showControls &&
|
||||
capabilities.subtitles &&
|
||||
state.subtitleTracks.length > 0,
|
||||
quality:
|
||||
showControls &&
|
||||
capabilities.qualityLevels &&
|
||||
state.qualityLevels.length > 1,
|
||||
speed: showControls && capabilities.playbackSpeed,
|
||||
aspect: showControls && capabilities.aspectRatio,
|
||||
};
|
||||
@@ -43,6 +48,7 @@ export class ControlsMenuState {
|
||||
readonly volumeOpen = signal(false);
|
||||
readonly audioOpen = signal(false);
|
||||
readonly subtitleOpen = signal(false);
|
||||
readonly qualityOpen = signal(false);
|
||||
readonly speedOpen = signal(false);
|
||||
readonly aspectOpen = signal(false);
|
||||
|
||||
@@ -51,6 +57,7 @@ export class ControlsMenuState {
|
||||
this.volumeOpen() ||
|
||||
this.audioOpen() ||
|
||||
this.subtitleOpen() ||
|
||||
this.qualityOpen() ||
|
||||
this.speedOpen() ||
|
||||
this.aspectOpen()
|
||||
);
|
||||
@@ -78,6 +85,7 @@ export class ControlsMenuState {
|
||||
this.volumeOpen.set(false);
|
||||
this.audioOpen.set(false);
|
||||
this.subtitleOpen.set(false);
|
||||
this.qualityOpen.set(false);
|
||||
this.speedOpen.set(false);
|
||||
this.aspectOpen.set(false);
|
||||
}
|
||||
@@ -112,6 +120,8 @@ export class ControlsMenuState {
|
||||
return this.audioOpen;
|
||||
case 'subtitle':
|
||||
return this.subtitleOpen;
|
||||
case 'quality':
|
||||
return this.qualityOpen;
|
||||
case 'speed':
|
||||
return this.speedOpen;
|
||||
case 'aspect':
|
||||
|
||||
@@ -53,6 +53,9 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) {
|
||||
const hasSubtitleTracks = computed(
|
||||
() => capabilities().subtitles && state().subtitleTracks.length > 0
|
||||
);
|
||||
const hasQualityLevels = computed(
|
||||
() => capabilities().qualityLevels && state().qualityLevels.length > 1
|
||||
);
|
||||
const canRecord = computed(
|
||||
() =>
|
||||
capabilities().recording &&
|
||||
@@ -103,6 +106,7 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) {
|
||||
canTogglePlay,
|
||||
hasAudioTracks,
|
||||
hasSubtitleTracks,
|
||||
hasQualityLevels,
|
||||
canRecord,
|
||||
isRecording,
|
||||
recordingStatusText,
|
||||
|
||||
@@ -11,4 +11,12 @@ describe('player-controls defaults', () => {
|
||||
canPictureInPicture: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults quality selection to unsupported with auto enabled', () => {
|
||||
expect(DEFAULT_PLAYER_CAPABILITIES.qualityLevels).toBe(false);
|
||||
expect(createEmptyControlsState()).toMatchObject({
|
||||
qualityLevels: [],
|
||||
qualityAutoEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
|
||||
volume: false,
|
||||
audioTracks: false,
|
||||
subtitles: false,
|
||||
qualityLevels: false,
|
||||
playbackSpeed: false,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
@@ -47,6 +48,8 @@ export function createEmptyControlsState(): PlayerControlsState {
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
subtitlesEnabled: false,
|
||||
qualityLevels: [],
|
||||
qualityAutoEnabled: true,
|
||||
playbackSpeed: 1,
|
||||
speedPresets: DEFAULT_SPEED_PRESETS,
|
||||
aspectRatio: 'no',
|
||||
|
||||
@@ -30,6 +30,7 @@ function createFakeController(): FakeController {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
@@ -240,6 +241,63 @@ describe('PlayerControlsComponent capability contract', () => {
|
||||
expect(component.anyMenuOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the quality menu with Auto and routes level selection', () => {
|
||||
const fake = createFakeController();
|
||||
const fixture = createControls(fake);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
fake.capabilities.set({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
qualityLevels: true,
|
||||
});
|
||||
fake.state.set({
|
||||
...createEmptyControlsState(),
|
||||
qualityLevels: [
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
],
|
||||
qualityAutoEnabled: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const trigger = root.querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="player-controls-quality"]'
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
expect(component.menus.qualityOpen()).toBe(true);
|
||||
|
||||
const entries = Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>(
|
||||
'.player-controls__track-popover .player-controls__track'
|
||||
)
|
||||
);
|
||||
expect(entries.map((entry) => entry.textContent?.trim())).toEqual([
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY_AUTOcheck',
|
||||
'1080p',
|
||||
'720p',
|
||||
]);
|
||||
expect(entries[0].getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
entries[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1);
|
||||
expect(component.menus.qualityOpen()).toBe(false);
|
||||
|
||||
component.toggleMenu('quality');
|
||||
fake.state.set({
|
||||
...createEmptyControlsState(),
|
||||
qualityLevels: [{ id: 0, label: '1080p', selected: false }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.anyMenuOpen()).toBe(false);
|
||||
expect(
|
||||
root.querySelector('[data-test-id="player-controls-quality"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the scrub slider without hiding live and recording status', () => {
|
||||
const fake = createFakeController();
|
||||
const fixture = createControls(fake);
|
||||
|
||||
@@ -410,6 +410,83 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasQualityLevels()) {
|
||||
<div class="player-controls__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
data-test-id="player-controls-quality"
|
||||
(click)="toggleMenu('quality')"
|
||||
[attr.aria-expanded]="menus.qualityOpen()"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
|
||||
"
|
||||
[matTooltip]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>high_quality</mat-icon>
|
||||
</button>
|
||||
@if (menus.qualityOpen()) {
|
||||
<div
|
||||
class="player-controls__panel player-controls__track-popover"
|
||||
role="menu"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
|
||||
"
|
||||
>
|
||||
<div class="player-controls__menu-title">
|
||||
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
|
||||
</div>
|
||||
<div class="player-controls__track-list">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="
|
||||
state().qualityAutoEnabled
|
||||
"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
state().qualityAutoEnabled
|
||||
"
|
||||
(click)="menuSelection.qualityLevel(-1)"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY_AUTO'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (state().qualityAutoEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@for (
|
||||
level of state().qualityLevels;
|
||||
track level.id
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="level.selected"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
level.selected
|
||||
"
|
||||
(click)="
|
||||
menuSelection.qualityLevel(level.id)
|
||||
"
|
||||
>
|
||||
<span>{{ level.label }}</span>
|
||||
@if (level.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (capabilities().playbackSpeed) {
|
||||
<div class="player-controls__popover-anchor">
|
||||
<button
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -24,6 +24,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -127,6 +127,7 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
readonly canTogglePlay = this.vm.canTogglePlay;
|
||||
readonly hasAudioTracks = this.vm.hasAudioTracks;
|
||||
readonly hasSubtitleTracks = this.vm.hasSubtitleTracks;
|
||||
readonly hasQualityLevels = this.vm.hasQualityLevels;
|
||||
readonly canRecord = this.vm.canRecord;
|
||||
readonly isRecording = this.vm.isRecording;
|
||||
readonly recordingStatusText = this.vm.recordingStatusText;
|
||||
@@ -300,7 +301,9 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
toggleMenu(menu: 'audio' | 'subtitle' | 'speed' | 'aspect'): void {
|
||||
toggleMenu(
|
||||
menu: 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'
|
||||
): void {
|
||||
this.menus.toggle(menu);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PlayerControlsCapabilities {
|
||||
volume: boolean;
|
||||
audioTracks: boolean;
|
||||
subtitles: boolean;
|
||||
/** Manifest-driven: true only when the source exposes >1 video rendition. */
|
||||
qualityLevels: boolean;
|
||||
playbackSpeed: boolean;
|
||||
aspectRatio: boolean;
|
||||
recording: boolean;
|
||||
@@ -28,6 +30,12 @@ export interface PlayerTrack {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `setQualityLevel(AUTO_QUALITY_LEVEL_ID)` re-enables the engine's adaptive
|
||||
* (ABR) selection, mirroring how `setSubtitleTrack(-1)` means "off".
|
||||
*/
|
||||
export const AUTO_QUALITY_LEVEL_ID = -1;
|
||||
|
||||
/**
|
||||
* Content title lines rendered over the player while the controls are visible
|
||||
* in fullscreen. Hosts pass display-ready strings; the controls never derive
|
||||
@@ -69,6 +77,13 @@ export interface PlayerControlsState {
|
||||
subtitleTracks: PlayerTrack[];
|
||||
/** True when a subtitle track is active (selected id !== null/off). */
|
||||
subtitlesEnabled: boolean;
|
||||
/**
|
||||
* Per-level quality options ("1080p", …). A level reports `selected` only
|
||||
* while a manual selection is active; with ABR on, none is selected.
|
||||
*/
|
||||
qualityLevels: PlayerTrack[];
|
||||
/** True while the engine picks the quality level itself (ABR/auto). */
|
||||
qualityAutoEnabled: boolean;
|
||||
playbackSpeed: number;
|
||||
speedPresets: ReadonlyArray<PlayerPreset<number>>;
|
||||
aspectRatio: string;
|
||||
@@ -88,6 +103,7 @@ export interface PlayerControlsCommands {
|
||||
setVolume(value: number): void; // 0..1
|
||||
setAudioTrack(id: number): void;
|
||||
setSubtitleTrack(id: number): void; // -1 = off
|
||||
setQualityLevel(id: number): void; // AUTO_QUALITY_LEVEL_ID (-1) = auto
|
||||
setPlaybackSpeed(speed: number): void;
|
||||
setAspectRatio(value: string): void;
|
||||
toggleRecording(): void;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AUTO_QUALITY_LEVEL_ID, type PlayerTrack } from './player-controls.model';
|
||||
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
|
||||
|
||||
function createVideo(): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
Object.defineProperties(video, {
|
||||
duration: { configurable: true, value: 120 },
|
||||
readyState: { configurable: true, value: 4 },
|
||||
networkState: { configurable: true, value: 1 },
|
||||
paused: { configurable: true, value: true },
|
||||
seekable: { configurable: true, value: { length: 1 } },
|
||||
});
|
||||
return video;
|
||||
}
|
||||
|
||||
const TWO_LEVELS: PlayerTrack[] = [
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
];
|
||||
|
||||
describe('WebVideoControlsAdapter quality levels', () => {
|
||||
let adapter: WebVideoControlsAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new WebVideoControlsAdapter();
|
||||
});
|
||||
|
||||
afterEach(() => adapter.detach());
|
||||
|
||||
it('advertises the capability only for a multi-rendition source', () => {
|
||||
let levels: PlayerTrack[] = TWO_LEVELS;
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => levels,
|
||||
setQualityLevel: () => undefined,
|
||||
});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(true);
|
||||
|
||||
levels = [TWO_LEVELS[0]];
|
||||
adapter.refresh();
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
});
|
||||
|
||||
it('advertises no capability without a setter or without accessors', () => {
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
expect(adapter.state().qualityLevels).toEqual(TWO_LEVELS);
|
||||
|
||||
adapter.attach(createVideo(), {});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
expect(adapter.state().qualityLevels).toEqual([]);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('projects the auto flag and level list into state', () => {
|
||||
let auto = true;
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
setQualityLevel: () => undefined,
|
||||
isAutoQualityEnabled: () => auto,
|
||||
});
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
|
||||
auto = false;
|
||||
adapter.refresh();
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes the command through the setter and refreshes', () => {
|
||||
let levels = TWO_LEVELS;
|
||||
const setQualityLevel = jest.fn((id: number) => {
|
||||
levels = levels.map((level) => ({
|
||||
...level,
|
||||
selected: level.id === id,
|
||||
}));
|
||||
});
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => levels,
|
||||
setQualityLevel,
|
||||
});
|
||||
|
||||
adapter.commands.setQualityLevel(1);
|
||||
expect(setQualityLevel).toHaveBeenCalledWith(1);
|
||||
expect(adapter.state().qualityLevels[1].selected).toBe(true);
|
||||
|
||||
adapter.commands.setQualityLevel(AUTO_QUALITY_LEVEL_ID);
|
||||
expect(setQualityLevel).toHaveBeenCalledWith(AUTO_QUALITY_LEVEL_ID);
|
||||
});
|
||||
|
||||
it('contains a synchronous setter exception', () => {
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
setQualityLevel: () => {
|
||||
throw new Error('engine changing source');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => adapter.commands.setQualityLevel(0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,9 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions {
|
||||
setAudioTrack?: (id: number) => void | Promise<void>;
|
||||
getSubtitleTracks?: () => PlayerTrack[];
|
||||
setSubtitleTrack?: (id: number) => void | Promise<void>;
|
||||
getQualityLevels?: () => PlayerTrack[];
|
||||
setQualityLevel?: (id: number) => void | Promise<void>;
|
||||
isAutoQualityEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
interface WebVideoControlsContext {
|
||||
@@ -90,6 +93,10 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
const hasSubtitles =
|
||||
typeof this.opts.setSubtitleTrack === 'function' &&
|
||||
(this.opts.getSubtitleTracks?.().length ?? 0) > 0;
|
||||
// Manifest-driven: a single-rendition source advertises no selector.
|
||||
const hasQualityLevels =
|
||||
typeof this.opts.setQualityLevel === 'function' &&
|
||||
(this.opts.getQualityLevels?.().length ?? 0) > 1;
|
||||
const isLive = readVideoIsLive(this.video, this.opts);
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
return {
|
||||
@@ -100,6 +107,7 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
fullscreen: true,
|
||||
audioTracks: hasAudioTracks,
|
||||
subtitles: hasSubtitles,
|
||||
qualityLevels: hasQualityLevels,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
pictureInPicture: pictureInPicture.supported,
|
||||
@@ -124,6 +132,7 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
|
||||
const audioTracks = this.opts.getAudioTracks?.() ?? [];
|
||||
const subtitleTracks = this.opts.getSubtitleTracks?.() ?? [];
|
||||
const qualityLevels = this.opts.getQualityLevels?.() ?? [];
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
|
||||
return {
|
||||
@@ -138,6 +147,8 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
subtitlesEnabled: subtitleTracks.some((track) => track.selected),
|
||||
qualityLevels,
|
||||
qualityAutoEnabled: this.opts.isAutoQualityEnabled?.() ?? true,
|
||||
playbackSpeed: video?.playbackRate ?? 1,
|
||||
speedPresets: DEFAULT_SPEED_PRESETS,
|
||||
aspectRatio: 'no',
|
||||
@@ -172,6 +183,10 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
applyTrackSelection(this.opts.setSubtitleTrack, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setQualityLevel: (id) =>
|
||||
applyTrackSelection(this.opts.setQualityLevel, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setPlaybackSpeed: (speed) => applyVideoSpeed(this.video, speed),
|
||||
setAspectRatio: () => undefined,
|
||||
toggleRecording: () => undefined,
|
||||
|
||||
@@ -22,6 +22,22 @@ export interface ShakaTextTrackLike {
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
export interface ShakaVariantTrackLike {
|
||||
id: number;
|
||||
active: boolean;
|
||||
language: string;
|
||||
label: string | null;
|
||||
height: number | null;
|
||||
width: number | null;
|
||||
bandwidth: number;
|
||||
/**
|
||||
* Identifies the exact audio stream inside the variant. Two same-language
|
||||
* audio tracks (main vs. commentary, stereo vs. 5.1) have different ids,
|
||||
* so quality filtering pins to it rather than to `language`.
|
||||
*/
|
||||
audioId?: number | null;
|
||||
}
|
||||
|
||||
export type { ShakaErrorLike } from '@iptvnator/playback/util';
|
||||
|
||||
export interface ShakaPlayerLike {
|
||||
@@ -33,6 +49,16 @@ export interface ShakaPlayerLike {
|
||||
removeEventListener(type: string, listener: (event: Event) => void): void;
|
||||
getAudioTracks(): ShakaAudioTrackLike[];
|
||||
selectAudioTrack(track: ShakaAudioTrackLike): void;
|
||||
getVariantTracks(): ShakaVariantTrackLike[];
|
||||
/**
|
||||
* Manual quality selection. Callers must disable ABR first via
|
||||
* `configure({abr: {enabled: false}})` or the ABR manager immediately
|
||||
* overrides the choice. `clearBuffer` keeps the switch immediate.
|
||||
*/
|
||||
selectVariantTrack(
|
||||
track: ShakaVariantTrackLike,
|
||||
clearBuffer?: boolean
|
||||
): void;
|
||||
getTextTracks(): ShakaTextTrackLike[];
|
||||
/**
|
||||
* Shaka 5 visibility model: selecting a track shows it, `null` unloads
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ShakaModuleLoader,
|
||||
ShakaPlayerLike,
|
||||
ShakaTextTrackLike,
|
||||
ShakaVariantTrackLike,
|
||||
} from './shaka-module.types';
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,11 @@ export class FakeShakaPlayer implements ShakaPlayerLike {
|
||||
destroyCount = 0;
|
||||
audioTracks: ShakaAudioTrackLike[] = [];
|
||||
textTracks: ShakaTextTrackLike[] = [];
|
||||
variantTracks: ShakaVariantTrackLike[] = [];
|
||||
readonly selectVariantTrackCalls: {
|
||||
track: ShakaVariantTrackLike;
|
||||
clearBuffer: boolean | undefined;
|
||||
}[] = [];
|
||||
/** When true, the next `load()` stays pending until `destroy()`. */
|
||||
stallNextLoad = false;
|
||||
loadResult: Promise<unknown> = Promise.resolve();
|
||||
@@ -88,6 +94,20 @@ export class FakeShakaPlayer implements ShakaPlayerLike {
|
||||
this.selectedAudioTracks.push(track);
|
||||
}
|
||||
|
||||
getVariantTracks(): ShakaVariantTrackLike[] {
|
||||
return this.variantTracks;
|
||||
}
|
||||
|
||||
selectVariantTrack(
|
||||
track: ShakaVariantTrackLike,
|
||||
clearBuffer?: boolean
|
||||
): void {
|
||||
this.selectVariantTrackCalls.push({ track, clearBuffer });
|
||||
for (const candidate of this.variantTracks) {
|
||||
candidate.active = candidate === track;
|
||||
}
|
||||
}
|
||||
|
||||
getTextTracks(): ShakaTextTrackLike[] {
|
||||
return this.textTracks;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WebVideoControlsOptions } from '../player-controls/web-video-contr
|
||||
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { VjsAudioTracks } from './vjs-audio-tracks';
|
||||
import type { VideoJsPlayer } from './vjs-player.types';
|
||||
import { VjsQualityLevels } from './vjs-quality-levels';
|
||||
import { VjsTextTracks } from './vjs-text-tracks';
|
||||
|
||||
export interface VjsPlayerControlsBridgeConfig {
|
||||
@@ -14,6 +15,7 @@ export interface VjsPlayerControlsBridgeConfig {
|
||||
export class VjsPlayerControlsBridge {
|
||||
private readonly audioTracks: VjsAudioTracks;
|
||||
private readonly textTracks: VjsTextTracks;
|
||||
private readonly qualityLevels: VjsQualityLevels;
|
||||
private video: HTMLVideoElement | null = null;
|
||||
private sourceActive = false;
|
||||
private destroyed = false;
|
||||
@@ -25,6 +27,9 @@ export class VjsPlayerControlsBridge {
|
||||
setAudioTrack: (id) => this.audioTracks.setAudioTrack(id),
|
||||
getSubtitleTracks: () => this.textTracks.getSubtitleTracks(),
|
||||
setSubtitleTrack: (id) => this.textTracks.setSubtitleTrack(id),
|
||||
getQualityLevels: () => this.qualityLevels.getQualityLevels(),
|
||||
setQualityLevel: (id) => this.qualityLevels.setQualityLevel(id),
|
||||
isAutoQualityEnabled: () => this.qualityLevels.isAutoQualityEnabled(),
|
||||
};
|
||||
|
||||
constructor(private readonly config: VjsPlayerControlsBridgeConfig) {
|
||||
@@ -38,6 +43,10 @@ export class VjsPlayerControlsBridge {
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
this.qualityLevels = new VjsQualityLevels({
|
||||
player: config.player,
|
||||
refresh,
|
||||
});
|
||||
}
|
||||
|
||||
attach(video: HTMLVideoElement): void {
|
||||
@@ -63,6 +72,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.resetSource();
|
||||
this.textTracks.resetSource();
|
||||
this.qualityLevels.resetSource();
|
||||
this.sourceActive = true;
|
||||
this.bindTrackLists();
|
||||
this.config.adapter.refresh();
|
||||
@@ -87,6 +97,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.clear();
|
||||
this.textTracks.clear();
|
||||
this.qualityLevels.clear();
|
||||
this.sourceActive = false;
|
||||
this.config.adapter.refresh();
|
||||
}
|
||||
@@ -98,6 +109,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.clear();
|
||||
this.textTracks.clear();
|
||||
this.qualityLevels.clear();
|
||||
if (this.video) {
|
||||
this.config.adapter.detach();
|
||||
}
|
||||
@@ -109,6 +121,7 @@ export class VjsPlayerControlsBridge {
|
||||
private bindTrackLists(): void {
|
||||
this.audioTracks.bind();
|
||||
this.textTracks.bind();
|
||||
this.qualityLevels.bind();
|
||||
}
|
||||
|
||||
private readDuration(): number {
|
||||
|
||||
@@ -52,6 +52,21 @@ export type VideoJsTrackList<TTrack> = {
|
||||
export type VideoJsAudioTrackList = VideoJsTrackList<VideoJsAudioTrack>;
|
||||
export type VideoJsTextTrackList = VideoJsTrackList<VideoJsTextTrack>;
|
||||
|
||||
/** One rendition from videojs-contrib-quality-levels. */
|
||||
export type VideoJsQualityLevel = {
|
||||
id?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate?: number;
|
||||
/** Property accessor: assigning false excludes the level from ABR. */
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type VideoJsQualityLevelList =
|
||||
VideoJsTrackList<VideoJsQualityLevel> & {
|
||||
selectedIndex?: number;
|
||||
};
|
||||
|
||||
export type VideoJsTech = {
|
||||
el?: () => Element | null;
|
||||
vhs?: {
|
||||
@@ -79,6 +94,8 @@ export type VideoJsPlayer = Omit<
|
||||
qualitySelectorHls?: (options?: {
|
||||
displayCurrentQuality?: boolean;
|
||||
}) => void;
|
||||
/** Registered by the videojs-contrib-quality-levels plugin import. */
|
||||
qualityLevels?: () => VideoJsQualityLevelList | null;
|
||||
aspectRatioPanel?: () => void;
|
||||
audioTracks: () => VideoJsAudioTrackList | null;
|
||||
textTracks: () => VideoJsTextTrackList | null;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import type {
|
||||
VideoJsQualityLevel,
|
||||
VideoJsQualityLevelList,
|
||||
} from './vjs-player.types';
|
||||
import { VjsQualityLevels } from './vjs-quality-levels';
|
||||
|
||||
class FakeQualityLevelList {
|
||||
private readonly listeners = new Map<
|
||||
string,
|
||||
Set<EventListenerOrEventListenerObject>
|
||||
>();
|
||||
private levels: VideoJsQualityLevel[] = [];
|
||||
|
||||
readonly addEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
const eventListeners =
|
||||
this.listeners.get(event) ??
|
||||
new Set<EventListenerOrEventListenerObject>();
|
||||
eventListeners.add(listener);
|
||||
this.listeners.set(event, eventListeners);
|
||||
}
|
||||
);
|
||||
|
||||
readonly removeEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
}
|
||||
);
|
||||
|
||||
get length(): number {
|
||||
return this.levels.length;
|
||||
}
|
||||
|
||||
replace(levels: VideoJsQualityLevel[]): void {
|
||||
for (let index = 0; index < this.levels.length; index += 1) {
|
||||
Reflect.deleteProperty(this, index);
|
||||
}
|
||||
this.levels = levels;
|
||||
levels.forEach((level, index) => {
|
||||
Object.defineProperty(this, index, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: level,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
emit(eventName: string): void {
|
||||
const event = new Event(eventName);
|
||||
for (const listener of this.listeners.get(eventName) ?? []) {
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(this, event);
|
||||
} else {
|
||||
listener.handleEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asList(): VideoJsQualityLevelList {
|
||||
return this as unknown as VideoJsQualityLevelList;
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(levels: VideoJsQualityLevel[]) {
|
||||
const levelList = new FakeQualityLevelList();
|
||||
levelList.replace(levels);
|
||||
const refresh = jest.fn();
|
||||
const helper = new VjsQualityLevels({
|
||||
player: { qualityLevels: () => levelList.asList() },
|
||||
refresh,
|
||||
});
|
||||
helper.bind();
|
||||
return { helper, levelList, refresh };
|
||||
}
|
||||
|
||||
function level(
|
||||
facts: Partial<VideoJsQualityLevel> = {}
|
||||
): VideoJsQualityLevel {
|
||||
return { enabled: true, ...facts };
|
||||
}
|
||||
|
||||
describe('VjsQualityLevels', () => {
|
||||
it('projects labelled levels with auto derived from all-enabled', () => {
|
||||
const { helper } = createHarness([
|
||||
level({ height: 1080, bitrate: 8_000_000 }),
|
||||
level({ height: 720, bitrate: 4_000_000 }),
|
||||
]);
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(helper.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('enables exactly one level for a manual selection', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
level({ height: 480 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
|
||||
expect([0, 1, 2].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(false);
|
||||
expect(helper.getQualityLevels()[1].selected).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables every level for the auto sentinel', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
helper.setQualityLevel(0);
|
||||
|
||||
helper.setQualityLevel(-1);
|
||||
|
||||
expect([0, 1].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores invalid and out-of-range ids', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(2);
|
||||
helper.setQualityLevel(-2);
|
||||
helper.setQualityLevel(0.5);
|
||||
helper.setQualityLevel(NaN);
|
||||
|
||||
expect([0, 1].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a single-level list as auto even when only it is enabled', () => {
|
||||
const { helper } = createHarness([level({ height: 720 })]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(helper.getQualityLevels()[0].selected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mistake VHS error-exclusions for a manual selection', () => {
|
||||
const levels = [
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
level({ height: 480 }),
|
||||
];
|
||||
const { helper } = createHarness(levels);
|
||||
|
||||
// VHS excludes failing renditions by flipping `enabled` off itself;
|
||||
// only one survivor must still read as auto, not as a user pick.
|
||||
levels[0].enabled = false;
|
||||
levels[2].enabled = false;
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reverts to auto and re-enables survivors when the picked level leaves the list', () => {
|
||||
const survivorA = level({ height: 1080 });
|
||||
const picked = level({ height: 720 });
|
||||
const survivorB = level({ height: 480 });
|
||||
const { helper, levelList } = createHarness([
|
||||
survivorA,
|
||||
picked,
|
||||
survivorB,
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
expect([survivorA.enabled, survivorB.enabled]).toEqual([false, false]);
|
||||
|
||||
// The list drops the picked rendition but keeps the (still disabled)
|
||||
// survivors; reverting to auto must give ABR renditions back.
|
||||
levelList.replace([survivorA, survivorB]);
|
||||
levelList.emit('removequalitylevel');
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect([survivorA.enabled, survivorB.enabled]).toEqual([true, true]);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-enables survivors lazily when no removal event was observed', () => {
|
||||
const survivor = level({ height: 1080 });
|
||||
const picked = level({ height: 720 });
|
||||
const { helper, levelList } = createHarness([survivor, picked]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
levelList.replace([survivor]);
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(survivor.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('forgets manual intent on resetSource', () => {
|
||||
const { helper } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(0);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(false);
|
||||
|
||||
helper.resetSource();
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes on list events and detaches exact listeners on clear', () => {
|
||||
const { helper, levelList, refresh } = createHarness([
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
levelList.emit('addqualitylevel');
|
||||
levelList.emit('removequalitylevel');
|
||||
levelList.emit('change');
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
|
||||
helper.clear();
|
||||
expect(levelList.removeEventListener).toHaveBeenCalledTimes(3);
|
||||
for (const [event, listener] of levelList.addEventListener.mock
|
||||
.calls) {
|
||||
expect(levelList.removeEventListener).toHaveBeenCalledWith(
|
||||
event,
|
||||
listener
|
||||
);
|
||||
}
|
||||
expect(helper.getQualityLevels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a missing or throwing qualityLevels plugin', () => {
|
||||
const withoutPlugin = new VjsQualityLevels({
|
||||
player: {},
|
||||
refresh: jest.fn(),
|
||||
});
|
||||
withoutPlugin.bind();
|
||||
expect(withoutPlugin.getQualityLevels()).toEqual([]);
|
||||
expect(withoutPlugin.isAutoQualityEnabled()).toBe(true);
|
||||
expect(() => withoutPlugin.setQualityLevel(0)).not.toThrow();
|
||||
|
||||
const throwing = new VjsQualityLevels({
|
||||
player: {
|
||||
qualityLevels: () => {
|
||||
throw new Error('plugin unavailable');
|
||||
},
|
||||
},
|
||||
refresh: jest.fn(),
|
||||
});
|
||||
throwing.bind();
|
||||
expect(throwing.getQualityLevels()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import { buildQualityLevelLabels } from '../web-video-support/quality-level-labels';
|
||||
import type {
|
||||
VideoJsPlayer,
|
||||
VideoJsQualityLevel,
|
||||
VideoJsQualityLevelList,
|
||||
} from './vjs-player.types';
|
||||
|
||||
const QUALITY_LEVEL_LISTENERS = [
|
||||
'addqualitylevel',
|
||||
'removequalitylevel',
|
||||
'change',
|
||||
] as const;
|
||||
|
||||
export type VjsQualityLevelPlayer = Pick<VideoJsPlayer, 'qualityLevels'>;
|
||||
|
||||
export interface VjsQualityLevelsConfig {
|
||||
player: VjsQualityLevelPlayer;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the videojs-contrib-quality-levels list onto the shared-controls
|
||||
* quality contract, mirroring {@link VjsAudioTracks}. VHS has no manual-level
|
||||
* setter; a manual selection enables exactly one level and auto re-enables all.
|
||||
* Manual intent is tracked explicitly by the picked level object — VHS also
|
||||
* flips `enabled` off for renditions it temporarily excludes after delivery
|
||||
* errors, so inferring the mode from the enabled count would report a manual
|
||||
* selection the user never made. Object identity (not an index) keeps the
|
||||
* intent valid across list mutations, and a picked level that leaves the list
|
||||
* reverts to auto. Ids are list indices for the current snapshot.
|
||||
*/
|
||||
export class VjsQualityLevels {
|
||||
private levelList: VideoJsQualityLevelList | null = null;
|
||||
private manualLevel: VideoJsQualityLevel | null = null;
|
||||
private readonly handleListChange: EventListener = () => {
|
||||
this.readManualLevel(this.listLevels());
|
||||
this.config.refresh();
|
||||
};
|
||||
|
||||
constructor(private readonly config: VjsQualityLevelsConfig) {}
|
||||
|
||||
bind(): void {
|
||||
const levelList = this.readLevelList();
|
||||
if (levelList === this.levelList) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachLevelList();
|
||||
this.levelList = levelList;
|
||||
if (typeof levelList?.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventName of QUALITY_LEVEL_LISTENERS) {
|
||||
levelList.addEventListener(eventName, this.handleListChange);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.detachLevelList();
|
||||
this.levelList = null;
|
||||
this.manualLevel = null;
|
||||
}
|
||||
|
||||
/** Forgets manual intent when the bridge activates a new source. */
|
||||
resetSource(): void {
|
||||
this.manualLevel = null;
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const levels = this.listLevels();
|
||||
const labels = buildQualityLevelLabels(levels);
|
||||
const manualLevel = this.readManualLevel(levels);
|
||||
return levels.map((level, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: level === manualLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
if (!Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levels = this.listLevels();
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
this.manualLevel = null;
|
||||
for (const level of levels) {
|
||||
level.enabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id < 0 || id >= levels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.manualLevel = levels[id];
|
||||
levels.forEach((level, index) => {
|
||||
level.enabled = index === id;
|
||||
});
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
return this.readManualLevel(this.listLevels()) === null;
|
||||
}
|
||||
|
||||
private readManualLevel(
|
||||
levels: VideoJsQualityLevel[]
|
||||
): VideoJsQualityLevel | null {
|
||||
if (this.manualLevel && !levels.includes(this.manualLevel)) {
|
||||
// The picked rendition left the list (source change or removal):
|
||||
// there is nothing the intent can hold on to, so revert to auto —
|
||||
// including re-enabling the survivors the manual pick disabled,
|
||||
// or ABR would be left with no selectable rendition.
|
||||
this.manualLevel = null;
|
||||
for (const level of levels) {
|
||||
level.enabled = true;
|
||||
}
|
||||
}
|
||||
return this.manualLevel;
|
||||
}
|
||||
|
||||
private listLevels(): VideoJsQualityLevel[] {
|
||||
const levelList = this.levelList;
|
||||
if (!levelList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levels: VideoJsQualityLevel[] = [];
|
||||
for (let index = 0; index < levelList.length; index += 1) {
|
||||
const level = levelList[index];
|
||||
if (level) {
|
||||
levels.push(level);
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
private readLevelList(): VideoJsQualityLevelList | null {
|
||||
try {
|
||||
return this.config.player.qualityLevels?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private detachLevelList(): void {
|
||||
const levelList = this.levelList;
|
||||
if (typeof levelList?.removeEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventName of QUALITY_LEVEL_LISTENERS) {
|
||||
levelList.removeEventListener(eventName, this.handleListChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
describe('buildQualityLevelLabels', () => {
|
||||
it('labels levels by frame height', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 1080, width: 1920, bitrate: 8_000_000 },
|
||||
{ height: 720, width: 1280, bitrate: 4_000_000 },
|
||||
{ height: 480, width: 854, bitrate: 1_500_000 },
|
||||
])
|
||||
).toEqual(['1080p', '720p', '480p']);
|
||||
});
|
||||
|
||||
it('projects a 16:9 height from a width-only level', () => {
|
||||
expect(buildQualityLevelLabels([{ width: 1920 }])).toEqual(['1080p']);
|
||||
});
|
||||
|
||||
it('falls back to the bitrate and then to a positional label', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ bitrate: 4_500_000 },
|
||||
{ bitrate: 640_000 },
|
||||
{},
|
||||
])
|
||||
).toEqual(['4.5 Mbps', '640 kbps', 'Level 3']);
|
||||
});
|
||||
|
||||
it('rounds Mbps values at 10 and above to whole numbers', () => {
|
||||
expect(buildQualityLevelLabels([{ bitrate: 15_400_000 }])).toEqual([
|
||||
'15 Mbps',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disambiguates same-height levels with their bitrates', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 1080, bitrate: 8_000_000 },
|
||||
{ height: 1080, bitrate: 4_000_000 },
|
||||
{ height: 720, bitrate: 2_000_000 },
|
||||
])
|
||||
).toEqual(['1080p (8.0 Mbps)', '1080p (4.0 Mbps)', '720p']);
|
||||
});
|
||||
|
||||
it('keeps colliding labels plain when no bitrate can disambiguate', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([{ height: 1080 }, { height: 1080 }])
|
||||
).toEqual(['1080p', '1080p']);
|
||||
});
|
||||
|
||||
it('ignores non-finite and non-positive facts', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 0, width: NaN, bitrate: -5 },
|
||||
{ height: null, width: null, bitrate: null },
|
||||
])
|
||||
).toEqual(['Level 1', 'Level 2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Rendition facts an engine can report for one quality level. */
|
||||
export interface QualityLevelFacts {
|
||||
height?: number | null;
|
||||
width?: number | null;
|
||||
bitrate?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds display labels for a source's quality levels, shared by the hls.js,
|
||||
* Shaka, and Video.js quality projections so every engine renders the same
|
||||
* vocabulary: "1080p" from the frame height, a bitrate ("4.5 Mbps") when no
|
||||
* dimension is known, and a positional fallback otherwise. When two levels
|
||||
* would collide on the same base label (same height at different bitrates),
|
||||
* every colliding label carries its bitrate as a disambiguating suffix.
|
||||
*/
|
||||
export function buildQualityLevelLabels(
|
||||
levels: readonly QualityLevelFacts[]
|
||||
): string[] {
|
||||
const baseLabels = levels.map((level, index) => baseLabel(level, index));
|
||||
const occurrences = new Map<string, number>();
|
||||
for (const label of baseLabels) {
|
||||
occurrences.set(label, (occurrences.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return baseLabels.map((label, index) => {
|
||||
if ((occurrences.get(label) ?? 0) <= 1) {
|
||||
return label;
|
||||
}
|
||||
const bitrate = formatBitrate(levels[index]?.bitrate);
|
||||
return bitrate ? `${label} (${bitrate})` : label;
|
||||
});
|
||||
}
|
||||
|
||||
function baseLabel(level: QualityLevelFacts, index: number): string {
|
||||
const height = readPositive(level.height);
|
||||
if (height !== null) {
|
||||
return `${height}p`;
|
||||
}
|
||||
|
||||
// Height can be absent while width survives (some panels only fill
|
||||
// RESOLUTION partially); a 16:9 projection is close enough for a label.
|
||||
const width = readPositive(level.width);
|
||||
if (width !== null) {
|
||||
return `${Math.round((width * 9) / 16)}p`;
|
||||
}
|
||||
|
||||
return formatBitrate(level.bitrate) ?? `Level ${index + 1}`;
|
||||
}
|
||||
|
||||
function formatBitrate(bitrate: number | null | undefined): string | null {
|
||||
const value = readPositive(bitrate);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
const mbps = value / 1_000_000;
|
||||
const rendered = mbps >= 10 ? Math.round(mbps).toString() : mbps.toFixed(1);
|
||||
return `${rendered} Mbps`;
|
||||
}
|
||||
return `${Math.round(value / 1000)} kbps`;
|
||||
}
|
||||
|
||||
function readPositive(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.round(value)
|
||||
: null;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import Hls from 'hls.js';
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
export interface WebVideoHlsControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
@@ -77,6 +81,46 @@ export class WebVideoHlsControls {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const hls = this.hls;
|
||||
if (!hls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levels = hls.levels ?? [];
|
||||
const labels = buildQualityLevelLabels(levels);
|
||||
// `manualLevel` is -1 in auto mode, so no level reports selected then.
|
||||
const manualLevel =
|
||||
typeof hls.manualLevel === 'number' ? hls.manualLevel : -1;
|
||||
return levels.map((_level, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: index === manualLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
const hls = this.hls;
|
||||
if (!hls || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `nextLevel` switches at the next fragment instead of flushing the
|
||||
// buffer (`currentLevel` would), so the picture never stalls.
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
hls.nextLevel = -1;
|
||||
return;
|
||||
}
|
||||
if (id >= 0 && id < (hls.levels?.length ?? 0)) {
|
||||
hls.nextLevel = id;
|
||||
}
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
// A fake or torn-down instance without the flag counts as auto.
|
||||
return this.hls ? this.hls.autoLevelEnabled !== false : true;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
if (!this.hls) {
|
||||
return [];
|
||||
@@ -204,5 +248,8 @@ function getHlsRefreshEvents() {
|
||||
Hls.Events.SUBTITLE_TRACKS_CLEARED,
|
||||
Hls.Events.SUBTITLE_TRACK_SWITCH,
|
||||
Hls.Events.MANIFEST_LOADING,
|
||||
Hls.Events.MANIFEST_PARSED,
|
||||
Hls.Events.LEVELS_UPDATED,
|
||||
Hls.Events.LEVEL_SWITCHED,
|
||||
] as const;
|
||||
}
|
||||
@@ -114,3 +114,202 @@ describe('WebVideoShakaControls', () => {
|
||||
expect(environment.instances[0].textTracks[0].active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebVideoShakaControls quality levels', () => {
|
||||
const video = {} as HTMLVideoElement;
|
||||
|
||||
function makeVariant(overrides: {
|
||||
id: number;
|
||||
height: number;
|
||||
bandwidth: number;
|
||||
audioId?: number;
|
||||
active?: boolean;
|
||||
language?: string;
|
||||
}) {
|
||||
return {
|
||||
active: false,
|
||||
language: 'en',
|
||||
label: null,
|
||||
width: Math.round((overrides.height * 16) / 9),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) => {
|
||||
player.variantTracks = [
|
||||
{
|
||||
id: 1,
|
||||
active: false,
|
||||
language: 'en',
|
||||
label: null,
|
||||
height: 720,
|
||||
width: 1280,
|
||||
bandwidth: 4_000_000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
active: true,
|
||||
language: 'en',
|
||||
label: null,
|
||||
height: 1080,
|
||||
width: 1920,
|
||||
bandwidth: 8_000_000,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
active: false,
|
||||
language: 'de',
|
||||
label: null,
|
||||
height: 1080,
|
||||
width: 1920,
|
||||
bandwidth: 8_000_000,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
const session = new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: () => undefined,
|
||||
showCaptions: () => true,
|
||||
loadShaka: environment.loader,
|
||||
});
|
||||
const controls = new WebVideoShakaControls({
|
||||
showCaptions: () => true,
|
||||
refresh: () => undefined,
|
||||
});
|
||||
return { environment, session, controls };
|
||||
}
|
||||
|
||||
it('lists active-language variants sorted by resolution, none selected in auto', async () => {
|
||||
const { session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables ABR before selecting a variant and marks the selection', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
const configureCountBefore = player.configureCalls.length;
|
||||
controls.setQualityLevel(1);
|
||||
|
||||
expect(player.configureCalls.slice(configureCountBefore)).toEqual([
|
||||
{ abr: { enabled: false } },
|
||||
]);
|
||||
expect(player.selectVariantTrackCalls).toEqual([
|
||||
{
|
||||
track: expect.objectContaining({ id: 1, language: 'en' }),
|
||||
clearBuffer: true,
|
||||
},
|
||||
]);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(false);
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-enables ABR through the auto sentinel', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
controls.setQualityLevel(0);
|
||||
controls.setQualityLevel(-1);
|
||||
|
||||
const player = environment.instances[0];
|
||||
expect(player.configureCalls.at(-1)).toEqual({
|
||||
abr: { enabled: true },
|
||||
});
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
controls.getQualityLevels().some((level) => level.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores invalid and out-of-range ids', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
const configureCountBefore = player.configureCalls.length;
|
||||
controls.setQualityLevel(5);
|
||||
controls.setQualityLevel(0.5);
|
||||
controls.setQualityLevel(NaN);
|
||||
|
||||
expect(player.configureCalls.length).toBe(configureCountBefore);
|
||||
expect(player.selectVariantTrackCalls).toEqual([]);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('pins candidates to the active audio id, not just the language', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
// Same language twice: main (audioId 10, active) vs. commentary
|
||||
// (audioId 20). Quality choices must never leak onto the commentary
|
||||
// track's variants.
|
||||
const player = environment.instances[0];
|
||||
player.variantTracks = [
|
||||
makeVariant({ id: 1, height: 720, bandwidth: 4e6, audioId: 10 }),
|
||||
makeVariant({
|
||||
id: 2,
|
||||
height: 1080,
|
||||
bandwidth: 8e6,
|
||||
audioId: 10,
|
||||
active: true,
|
||||
}),
|
||||
makeVariant({ id: 3, height: 720, bandwidth: 4e6, audioId: 20 }),
|
||||
makeVariant({ id: 4, height: 1080, bandwidth: 8e6, audioId: 20 }),
|
||||
];
|
||||
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
|
||||
controls.setQualityLevel(1);
|
||||
expect(player.selectVariantTrackCalls).toEqual([
|
||||
{
|
||||
track: expect.objectContaining({ id: 1, audioId: 10 }),
|
||||
clearBuffer: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forgets a manual selection when the session restarts with a fresh player', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
controls.setQualityLevel(0);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(false);
|
||||
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
expect(environment.instances).toHaveLength(2);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
controls.getQualityLevels().some((level) => level.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import type {
|
||||
ShakaPlayerLike,
|
||||
ShakaTextTrackLike,
|
||||
ShakaVariantTrackLike,
|
||||
} from '../shaka-engine/shaka-module.types';
|
||||
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
export interface WebVideoShakaControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
@@ -29,6 +34,13 @@ export class WebVideoShakaControls {
|
||||
private session: ShakaVideoSession | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private subtitleOverride: number | null = null;
|
||||
/**
|
||||
* The exact player a manual quality selection disabled ABR on. Each
|
||||
* `ShakaVideoSession.start` creates a fresh player with ABR back on, so
|
||||
* keying manual state to the instance (not a boolean) means a session
|
||||
* restart can never render a stale manual selection.
|
||||
*/
|
||||
private manualQualityPlayer: ShakaPlayerLike | null = null;
|
||||
|
||||
constructor(private readonly config: WebVideoShakaControlsConfig) {}
|
||||
|
||||
@@ -47,6 +59,7 @@ export class WebVideoShakaControls {
|
||||
this.unsubscribe = null;
|
||||
this.session = null;
|
||||
this.subtitleOverride = null;
|
||||
this.manualQualityPlayer = null;
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
@@ -78,6 +91,56 @@ export class WebVideoShakaControls {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = listQualityCandidates(player);
|
||||
const labels = buildQualityLevelLabels(
|
||||
candidates.map((track) => ({
|
||||
height: track.height,
|
||||
width: track.width,
|
||||
bitrate: track.bandwidth,
|
||||
}))
|
||||
);
|
||||
const manual = !this.isAutoQualityEnabled();
|
||||
return candidates.map((track, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: manual && track.active,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
const player = this.getPlayer();
|
||||
if (!player || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
player.configure({ abr: { enabled: true } });
|
||||
this.manualQualityPlayer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const track = listQualityCandidates(player)[id];
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ABR must be off first or the ABR manager overrides the selection.
|
||||
player.configure({ abr: { enabled: false } });
|
||||
player.selectVariantTrack(track, true);
|
||||
this.manualQualityPlayer = player;
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
const player = this.getPlayer();
|
||||
return !player || this.manualQualityPlayer !== player;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
@@ -146,3 +209,40 @@ function formatTextTrackLabel(
|
||||
): string {
|
||||
return track.label || track.language || `Subtitle ${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variants are audio+video combinations; picking a quality must not switch
|
||||
* the audio track. Candidates are pinned to the active variant's exact
|
||||
* `audioId` when Shaka reports one — two same-language audio tracks (main vs.
|
||||
* commentary, stereo vs. 5.1) share a language but never an id — falling back
|
||||
* to the language only when no id is available. Sorting (resolution, then
|
||||
* bandwidth, descending) keeps the ids deterministic across refreshes of the
|
||||
* same track set.
|
||||
*/
|
||||
function listQualityCandidates(
|
||||
player: ShakaPlayerLike
|
||||
): ShakaVariantTrackLike[] {
|
||||
const variants = player.getVariantTracks();
|
||||
const active = variants.find((track) => track.active);
|
||||
const candidates = variants.filter((track) =>
|
||||
sharesAudioStream(track, active)
|
||||
);
|
||||
return candidates.sort(
|
||||
(a, b) =>
|
||||
(b.height ?? 0) - (a.height ?? 0) ||
|
||||
(b.bandwidth ?? 0) - (a.bandwidth ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function sharesAudioStream(
|
||||
track: ShakaVariantTrackLike,
|
||||
active: ShakaVariantTrackLike | undefined
|
||||
): boolean {
|
||||
if (!active) {
|
||||
return true;
|
||||
}
|
||||
if (typeof active.audioId === 'number') {
|
||||
return track.audioId === active.audioId;
|
||||
}
|
||||
return track.language === active.language;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export class WebVideoSourceControlsBridge {
|
||||
setAudioTrack: (id) => this.setAudioTrack(id),
|
||||
getSubtitleTracks: () => this.getSubtitleTracks(),
|
||||
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
|
||||
getQualityLevels: () => this.tracks.getQualityLevels(),
|
||||
setQualityLevel: (id) => this.tracks.setQualityLevel(id),
|
||||
isAutoQualityEnabled: () => this.tracks.isAutoQualityEnabled(),
|
||||
});
|
||||
this.attached = true;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,35 @@ export class WebVideoSourceTracks {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getQualityLevels();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.getQualityLevels();
|
||||
}
|
||||
// Native and raw MPEG-TS sources carry a single video rendition.
|
||||
return [];
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setQualityLevel(id);
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.setQualityLevel(id);
|
||||
}
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.isAutoQualityEnabled();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.isAutoQualityEnabled();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getSubtitleTracks();
|
||||
|
||||
Reference in new issue
Block a user