feat(playback): Hybrid redesign of the shared player controls (#1709)

This commit is contained in:
4gray authored and GitHub committed 2026-09-28 07:32:55 +02:00
1 parent 8b570e3390
commit be217d5cf5
87 files changed
+6152 -1670

No files matched your search

@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -20,6 +20,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -59,6 +60,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -103,6 +103,7 @@
[playerSurface]="playerSurface()"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -26,7 +26,10 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type { PlayerMediaTitle } from '../player-controls/player-controls.model';
import type {
PlayerMediaTitle,
PlayerUpNextItem,
} from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
@@ -82,6 +85,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -29,6 +29,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -81,6 +82,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<{
@@ -1,4 +1,5 @@
import {
formatRemainingTime,
formatTime,
persistVolume,
readStoredVolume,
@@ -18,6 +19,20 @@ describe('controls format utilities', () => {
expect(formatTime(3661)).toBe('1:01:01');
});
it('formats the remaining time with a minus sign, rounding up', () => {
expect(formatRemainingTime(30, 600)).toBe('−9:30');
expect(formatRemainingTime(599.2, 600)).toBe('−0:01');
expect(formatRemainingTime(700, 600)).toBe('−0:00');
expect(formatRemainingTime(-5, 60)).toBe('−1:00');
});
it('has no remaining time without a finite positive duration', () => {
expect(formatRemainingTime(30, null)).toBeNull();
expect(formatRemainingTime(30, undefined)).toBeNull();
expect(formatRemainingTime(30, 0)).toBeNull();
expect(formatRemainingTime(30, Number.POSITIVE_INFINITY)).toBeNull();
});
it('clamps stored volume reads and persists raw volume values', () => {
localStorage.setItem('volume', '2');
expect(readStoredVolume()).toBe(1);
@@ -15,6 +15,29 @@ export function formatTime(value: number | null | undefined): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
/**
* Time left until the end, as `−7:03`. The dock shows it in place of the
* total duration: it is the number a viewer actually wants at a glance.
* Null when there is no finite duration to count down from.
*/
export function formatRemainingTime(
positionSeconds: number,
durationSeconds: number | null | undefined
): string | null {
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
durationSeconds - Math.max(0, positionSeconds)
);
return `−${formatTime(Math.ceil(remaining))}`;
}
export function volumeIcon(value: number): string {
if (value <= 0) {
return 'volume_off';
@@ -0,0 +1,127 @@
import {
COMPACT_LAYOUT_MAX_WIDTH,
ControlsLayout,
ROOMY_LAYOUT_MIN_WIDTH,
} from './controls-layout';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
describe('ControlsLayout', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let callbacks: ResizeCallback[];
let observed: Element[];
let disconnected: number;
function entry(width: number): ResizeObserverEntry {
return {
borderBoxSize: [{ inlineSize: width, blockSize: 100 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry;
}
beforeEach(() => {
callbacks = [];
observed = [];
disconnected = 0;
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
callbacks.push(callback);
}
observe(target: Element): void {
observed.push(target);
}
disconnect(): void {
disconnected += 1;
}
unobserve(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('starts wide and observes the host', () => {
const layout = new ControlsLayout();
const host = document.createElement('div');
layout.attach(host);
expect(layout.mode()).toBe('wide');
expect(observed).toEqual([host]);
});
it('switches to compact at the breakpoint and back above it', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH)]);
expect(layout.mode()).toBe('compact');
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH + 1)]);
expect(layout.mode()).toBe('wide');
});
it('reports room for chips and the side panel only from the roomy width', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
expect(layout.roomy()).toBe(true);
callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH - 1)]);
expect(layout.mode()).toBe('wide');
expect(layout.roomy()).toBe(false);
callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH)]);
expect(layout.roomy()).toBe(true);
callbacks[0]([entry(400)]);
expect(layout.mode()).toBe('compact');
expect(layout.roomy()).toBe(false);
});
it('uses the last entry of a batch and ignores zero widths', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([entry(1200), entry(400)]);
expect(layout.mode()).toBe('compact');
callbacks[0]([entry(0)]);
expect(layout.mode()).toBe('compact');
});
it('falls back to the content rect when border box sizes are missing', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
callbacks[0]([
{
contentRect: { width: 300 } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
expect(layout.mode()).toBe('compact');
});
it('disconnects on dispose and on re-attach', () => {
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
layout.attach(document.createElement('div'));
expect(disconnected).toBe(1);
layout.dispose();
expect(disconnected).toBe(2);
});
it('stays wide without ResizeObserver support', () => {
globalThis.ResizeObserver =
undefined as unknown as typeof ResizeObserver;
const layout = new ControlsLayout();
layout.attach(document.createElement('div'));
expect(layout.mode()).toBe('wide');
expect(callbacks).toHaveLength(0);
});
});
@@ -0,0 +1,83 @@
import { signal } from '@angular/core';
/**
* Container width (px) below which the controls switch to the compact
* layout: smaller buttons, volume behind a popover, secondary actions
* folded away. Mirrors the `@container player-controls (max-width: 719px)`
* query in the stylesheet, which owns the purely visual sizing; the signal
* exists for the template branches CSS cannot express (which elements are
* rendered at all).
*/
export const COMPACT_LAYOUT_MAX_WIDTH = 719;
/**
* Container width (px) from which the wide dock has room for its extras:
* the subtitle/speed value chips beside the action buttons, and the
* settings panel beside the video (the panel takes 370px of the dock).
* Between the compact breakpoint and this width the dock stays wide but
* folds the chips into `tune` and opens settings as a bottom sheet.
* Sized from the widest action row: volume + series transport + two chips
* + tune/record/PiP/fullscreen, and dock + panel with only tune/fullscreen.
*/
export const ROOMY_LAYOUT_MIN_WIDTH = 960;
export type ControlsLayoutMode = 'compact' | 'wide';
/**
* Observes the controls host's own width and reports the layout mode. The
* host fills the player box (`inset: 0`), so its width is the player's width
* — a small inline player inside a wide desktop window is compact, a
* fullscreen phone is compact, and a windowed desktop player is wide.
*
* Without `ResizeObserver` (unit tests, very old runtimes) the mode stays
* `wide`, which renders every control and is the safe default.
*/
export class ControlsLayout {
readonly mode = signal<ControlsLayoutMode>('wide');
/** Room for chips and the side panel; see {@link ROOMY_LAYOUT_MIN_WIDTH}. */
readonly roomy = signal(true);
private observer: ResizeObserver | null = null;
attach(host: HTMLElement): void {
this.detach();
if (typeof ResizeObserver === 'undefined') {
return;
}
this.observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (!entry) {
return;
}
const width =
entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
this.applyWidth(width);
});
this.observer.observe(host);
this.applyWidth(host.getBoundingClientRect().width);
}
/** Exposed for hosts and tests that already know the width. */
applyWidth(width: number): void {
if (!Number.isFinite(width) || width <= 0) {
return;
}
const next: ControlsLayoutMode =
width <= COMPACT_LAYOUT_MAX_WIDTH ? 'compact' : 'wide';
if (this.mode() !== next) {
this.mode.set(next);
}
const roomy = width >= ROOMY_LAYOUT_MIN_WIDTH;
if (this.roomy() !== roomy) {
this.roomy.set(roomy);
}
}
detach(): void {
this.observer?.disconnect();
this.observer = null;
}
dispose(): void {
this.detach();
}
}
@@ -1,12 +1,8 @@
import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
describe('ControlsMenuSelection', () => {
let commands: jest.Mocked<PlayerControlsCommands>;
let menus: ControlsMenuState;
let visibility: ControlsVisibility;
let revealSticky: jest.Mock;
let selection: ControlsMenuSelection;
@@ -27,75 +23,35 @@ describe('ControlsMenuSelection', () => {
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
menus = new ControlsMenuState();
visibility = new ControlsVisibility(() => false);
jest.spyOn(visibility, 'scheduleHide');
revealSticky = jest.fn();
selection = new ControlsMenuSelection({
commands: () => commands,
menus,
visibility,
revealSticky,
});
});
it('toggles a menu through the menu state', () => {
selection.toggle('audio');
expect(menus.audioOpen()).toBe(true);
selection.toggle('audio');
expect(menus.audioOpen()).toBe(false);
});
it('selects an audio track: reveal sticky, command, close menu, reschedule hide', () => {
menus.open('audio');
it('selects an audio track: reveal sticky, then the command', () => {
selection.audioTrack(3);
expect(revealSticky).toHaveBeenCalledTimes(1);
expect(commands.setAudioTrack).toHaveBeenCalledWith(3);
expect(menus.audioOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalledTimes(1);
});
it('selects a subtitle track and closes the subtitle menu', () => {
menus.open('subtitle');
selection.subtitleTrack(-1);
it.each([
['subtitleTrack', -1, 'setSubtitleTrack'],
['qualityLevel', -1, 'setQualityLevel'],
['speed', 1.5, 'setPlaybackSpeed'],
['aspect', '16:9', 'setAspectRatio'],
] as const)('routes %s to the engine command', (method, value, command) => {
(selection[method] as (arg: unknown) => void)(value);
expect(commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(menus.subtitleOpen()).toBe(false);
expect(visibility.scheduleHide).toHaveBeenCalled();
expect(revealSticky).toHaveBeenCalledTimes(1);
expect(commands[command]).toHaveBeenCalledWith(value);
});
it('selects a quality level and closes the quality menu', () => {
menus.open('quality');
selection.qualityLevel(-1);
it('opens the subtitle file picker', () => {
selection.externalSubtitle();
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);
expect(commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(menus.speedOpen()).toBe(false);
});
it('applies an aspect ratio and closes the aspect menu', () => {
menus.open('aspect');
selection.aspect('16:9');
expect(commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(menus.aspectOpen()).toBe(false);
});
it('only closes the menu the selection belongs to', () => {
menus.open('volume');
selection.speed(2);
// Closing 'speed' must not blindly close every popover.
expect(menus.volumeOpen()).toBe(true);
expect(commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1);
});
});
@@ -1,58 +1,47 @@
import type { ControlsMenuState } from './controls-menu-state';
import type { ControlsVisibility } from './controls-visibility';
import type { PlayerControlsCommands } from './player-controls.model';
type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect';
export interface MenuSelectionDeps {
commands: () => PlayerControlsCommands;
menus: ControlsMenuState;
visibility: ControlsVisibility;
/** Reveal without rescheduling the auto-hide (kept open while choosing). */
/** Reveal without rescheduling the auto-hide (the panel stays open). */
revealSticky: () => void;
}
/**
* Owns track/speed/aspect menu selection: reveal-without-hide, run the command,
* close the menu, then reschedule the auto-hide. Keeps the component lean while
* the template still binds to thin delegating methods.
* Owns track/quality/speed/aspect selection from the settings panel: reveal
* without hiding, then run the command. The panel deliberately stays open —
* a choice applies immediately and is judged against the running video, so
* closing it on every click would cost a reopen per comparison.
*/
export class ControlsMenuSelection {
constructor(private readonly deps: MenuSelectionDeps) {}
toggle(menu: MenuKey): void {
this.deps.menus.toggle(menu);
}
audioTrack(trackId: number): void {
this.apply('audio', (c) => c.setAudioTrack(trackId));
this.apply((c) => c.setAudioTrack(trackId));
}
subtitleTrack(trackId: number): void {
this.apply('subtitle', (c) => c.setSubtitleTrack(trackId));
this.apply((c) => c.setSubtitleTrack(trackId));
}
/** Opens the engine's subtitle file picker and closes the popover. */
/** Opens the engine's subtitle file picker. */
externalSubtitle(): void {
this.apply('subtitle', (c) => c.addExternalSubtitleFile());
this.apply((c) => c.addExternalSubtitleFile());
}
qualityLevel(levelId: number): void {
this.apply('quality', (c) => c.setQualityLevel(levelId));
this.apply((c) => c.setQualityLevel(levelId));
}
speed(value: number): void {
this.apply('speed', (c) => c.setPlaybackSpeed(value));
this.apply((c) => c.setPlaybackSpeed(value));
}
aspect(value: string): void {
this.apply('aspect', (c) => c.setAspectRatio(value));
this.apply((c) => c.setAspectRatio(value));
}
private apply(menu: MenuKey, run: (c: PlayerControlsCommands) => void): void {
private apply(run: (c: PlayerControlsCommands) => void): void {
this.deps.revealSticky();
run(this.deps.commands());
this.deps.menus.close(menu);
this.deps.visibility.scheduleHide();
}
}
@@ -16,31 +16,43 @@ describe('ControlsMenuState', () => {
expect(menus.volumeOpen()).toBe(true);
expect(menus.anyOpen()).toBe(true);
menus.open('audio');
menus.open('settings');
expect(menus.volumeOpen()).toBe(false);
expect(menus.audioOpen()).toBe(true);
expect(menus.settingsOpen()).toBe(true);
menus.toggle('audio');
expect(menus.audioOpen()).toBe(false);
menus.toggle('settings');
expect(menus.settingsOpen()).toBe(false);
expect(menus.anyOpen()).toBe(false);
});
it('closes all menus', () => {
const menus = new ControlsMenuState();
menus.open('speed');
menus.open('settings');
menus.closeAll();
expect(menus.anyOpen()).toBe(false);
});
it.each([
'volume',
'audio',
'subtitle',
'quality',
'speed',
'aspect',
'stats',
] as const)(
it('remembers the group the settings panel was opened for', () => {
const menus = new ControlsMenuState();
menus.openSettings('speed');
expect(menus.settingsOpen()).toBe(true);
expect(menus.settingsFocus()).toBe('speed');
// Re-opening on another group keeps the panel and moves the focus.
menus.openSettings('subtitles');
expect(menus.settingsOpen()).toBe(true);
expect(menus.settingsFocus()).toBe('subtitles');
menus.close('settings');
expect(menus.settingsFocus()).toBeNull();
menus.openSettings('audio');
menus.closeAll();
expect(menus.settingsFocus()).toBeNull();
});
it.each(['volume', 'settings', 'stats'] as const)(
'closes an open %s menu when it becomes unavailable',
(menu) => {
const menus = new ControlsMenuState();
@@ -48,11 +60,7 @@ describe('ControlsMenuState', () => {
const changed = menus.reconcile({
volume: menu !== 'volume',
audio: menu !== 'audio',
subtitle: menu !== 'subtitle',
quality: menu !== 'quality',
speed: menu !== 'speed',
aspect: menu !== 'aspect',
settings: menu !== 'settings',
stats: menu !== 'stats',
});
@@ -63,37 +71,21 @@ describe('ControlsMenuState', () => {
it('leaves an available menu open without reporting a change', () => {
const menus = new ControlsMenuState();
menus.open('speed');
menus.open('settings');
expect(
menus.reconcile({
volume: true,
audio: true,
subtitle: true,
quality: true,
speed: true,
aspect: true,
stats: true,
})
menus.reconcile({ volume: true, settings: true, stats: true })
).toBe(false);
expect(menus.speedOpen()).toBe(true);
expect(menus.settingsOpen()).toBe(true);
});
it('closes every unavailable menu if state was made inconsistent', () => {
const menus = new ControlsMenuState();
menus.volumeOpen.set(true);
menus.speedOpen.set(true);
menus.settingsOpen.set(true);
expect(
menus.reconcile({
volume: false,
audio: false,
subtitle: false,
quality: false,
speed: false,
aspect: false,
stats: false,
})
menus.reconcile({ volume: false, settings: false, stats: false })
).toBe(true);
expect(menus.anyOpen()).toBe(false);
});
@@ -106,12 +98,20 @@ describe('ControlsMenuState', () => {
]
>([
['volume', { volume: false }, {}],
['audio', {}, { audioTracks: [] }],
['subtitle', {}, { subtitleTracks: [] }],
['quality', {}, { qualityLevels: [] }],
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
['stats', { streamStats: false }, {}],
[
'settings',
{
audioTracks: false,
subtitles: false,
externalSubtitles: false,
qualityLevels: false,
playbackSpeed: false,
aspectRatio: false,
},
{},
],
['settings', { playbackSpeed: false, aspectRatio: false }, {}],
])(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
@@ -123,32 +123,48 @@ describe('ControlsMenuState', () => {
{
...DEFAULT_PLAYER_CAPABILITIES,
volume: true,
audioTracks: true,
subtitles: true,
qualityLevels: true,
streamStats: true,
playbackSpeed: true,
aspectRatio: true,
streamStats: true,
...capabilityOverrides,
},
{
...createEmptyControlsState(),
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [
{ id: 1, label: 'English', selected: true },
],
qualityLevels: [
{ id: 0, label: '1080p', selected: false },
{ id: 1, label: '720p', selected: false },
],
...stateOverrides,
}
{ ...createEmptyControlsState(), ...stateOverrides }
);
expect(menus.anyOpen()).toBe(false);
}
);
it('keeps the settings panel while any group still exists', () => {
const menus = new ControlsMenuState();
menus.openSettings('speed');
menus.reconcileControllerAvailability(
true,
{
...DEFAULT_PLAYER_CAPABILITIES,
playbackSpeed: true,
audioTracks: true,
},
{
...createEmptyControlsState(),
audioTracks: [{ id: 1, label: 'Only', selected: true }],
}
);
expect(menus.settingsOpen()).toBe(true);
});
it('closes every menu while controls are hidden', () => {
const menus = new ControlsMenuState();
menus.open('settings');
menus.reconcileControllerAvailability(
false,
{ ...DEFAULT_PLAYER_CAPABILITIES, playbackSpeed: true },
createEmptyControlsState()
);
expect(menus.anyOpen()).toBe(false);
});
});
@@ -1,18 +1,15 @@
import { computed, signal } from '@angular/core';
import {
anySettingsGroupAvailable,
getSettingsGroupAvailability,
type SettingsGroup,
} from './controls-settings-groups';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
const CONTROL_MENUS = [
'volume',
'audio',
'subtitle',
'quality',
'speed',
'aspect',
'stats',
] as const;
const CONTROL_MENUS = ['volume', 'settings', 'stats'] as const;
export type ControlsMenu = (typeof CONTROL_MENUS)[number];
export type ControlsMenuAvailability = Readonly<Record<ControlsMenu, boolean>>;
@@ -24,49 +21,30 @@ function getControlsMenuAvailability(
): ControlsMenuAvailability {
return {
volume: showControls && capabilities.volume,
audio:
settings:
showControls &&
capabilities.audioTracks &&
state.audioTracks.length > 1,
// External subtitle loading keeps the menu reachable with an empty
// track list — the "Load subtitle file…" action is how the first
// track appears.
subtitle:
showControls &&
((capabilities.subtitles && state.subtitleTracks.length > 0) ||
capabilities.externalSubtitles),
quality:
showControls &&
capabilities.qualityLevels &&
state.qualityLevels.length > 1,
speed: showControls && capabilities.playbackSpeed,
aspect: showControls && capabilities.aspectRatio,
anySettingsGroupAvailable(
getSettingsGroupAvailability(capabilities, state)
),
stats: showControls && capabilities.streamStats,
};
}
/**
* Tracks which menu/popover is currently open and exposes individual signals
* the template binds to. Only one menu can be open at a time.
* the template binds to. Only one menu can be open at a time. The settings
* panel is one menu: every track, quality, speed and aspect choice lives
* inside it, and `settingsFocus` names the group it was opened for (a chip
* click), so the panel can bring that group into view.
*/
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);
readonly settingsOpen = signal(false);
readonly statsOpen = signal(false);
readonly settingsFocus = signal<SettingsGroup | null>(null);
readonly anyOpen = computed(
() =>
this.volumeOpen() ||
this.audioOpen() ||
this.subtitleOpen() ||
this.qualityOpen() ||
this.speedOpen() ||
this.aspectOpen() ||
this.statsOpen()
() => this.volumeOpen() || this.settingsOpen() || this.statsOpen()
);
toggle(menu: ControlsMenu): void {
@@ -84,18 +62,24 @@ export class ControlsMenuState {
this.signalFor(menu).set(true);
}
/** Opens the settings panel on one group (or wherever it was). */
openSettings(group: SettingsGroup | null = null): void {
this.open('settings');
this.settingsFocus.set(group);
}
close(menu: ControlsMenu): void {
this.signalFor(menu).set(false);
if (menu === 'settings') {
this.settingsFocus.set(null);
}
}
closeAll(): void {
this.volumeOpen.set(false);
this.audioOpen.set(false);
this.subtitleOpen.set(false);
this.qualityOpen.set(false);
this.speedOpen.set(false);
this.aspectOpen.set(false);
this.settingsOpen.set(false);
this.statsOpen.set(false);
this.settingsFocus.set(null);
}
reconcile(availability: ControlsMenuAvailability): boolean {
@@ -103,7 +87,7 @@ export class ControlsMenuState {
for (const menu of CONTROL_MENUS) {
const open = this.signalFor(menu);
if (open() && !availability[menu]) {
open.set(false);
this.close(menu);
changed = true;
}
}
@@ -124,16 +108,8 @@ export class ControlsMenuState {
switch (menu) {
case 'volume':
return this.volumeOpen;
case 'audio':
return this.audioOpen;
case 'subtitle':
return this.subtitleOpen;
case 'quality':
return this.qualityOpen;
case 'speed':
return this.speedOpen;
case 'aspect':
return this.aspectOpen;
case 'settings':
return this.settingsOpen;
case 'stats':
return this.statsOpen;
}
@@ -0,0 +1,47 @@
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
/** The groups of the settings panel, in the order they are rendered. */
export const SETTINGS_GROUPS = [
'audio',
'subtitles',
'quality',
'speed',
'aspect',
] as const;
export type SettingsGroup = (typeof SETTINGS_GROUPS)[number];
export type SettingsGroupAvailability = Readonly<
Record<SettingsGroup, boolean>
>;
/**
* Which settings groups the current engine and stream offer. A group with
* nothing to choose from is not rendered, exactly like the popover buttons
* it replaces were not.
*/
export function getSettingsGroupAvailability(
capabilities: PlayerControlsCapabilities,
state: PlayerControlsState
): SettingsGroupAvailability {
return {
audio: capabilities.audioTracks && state.audioTracks.length > 1,
// External subtitle loading keeps the group reachable with an empty
// track list — the "Load subtitle file…" action is how the first
// track appears.
subtitles:
(capabilities.subtitles && state.subtitleTracks.length > 0) ||
capabilities.externalSubtitles,
quality: capabilities.qualityLevels && state.qualityLevels.length > 1,
speed: capabilities.playbackSpeed,
aspect: capabilities.aspectRatio,
};
}
export function anySettingsGroupAvailable(
availability: SettingsGroupAvailability
): boolean {
return SETTINGS_GROUPS.some((group) => availability[group]);
}
@@ -0,0 +1,194 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsSettings } from './controls-settings';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
} from './player-controls.model';
describe('ControlsSettings', () => {
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let menus: ControlsMenuState;
let commands: jest.Mocked<PlayerControlsCommands>;
let reveal: jest.Mock;
let settings: ControlsSettings;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
beforeEach(() => {
state = signal(createEmptyControlsState());
capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES });
showControls = signal(true);
menus = new ControlsMenuState();
commands = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
reveal = jest.fn();
settings = new ControlsSettings({
state,
capabilities,
showControls,
menus,
commands: () => commands,
reveal,
});
});
it('is unavailable without any group and cannot open', () => {
expect(settings.available()).toBe(false);
settings.open('speed');
settings.toggle();
expect(menus.settingsOpen()).toBe(false);
expect(settings.isOpen()).toBe(false);
expect(reveal).not.toHaveBeenCalled();
});
it('opens on a group with a sticky reveal and toggles closed', () => {
setCapabilities({ playbackSpeed: true });
settings.open('speed');
expect(settings.isOpen()).toBe(true);
expect(settings.focusGroup()).toBe('speed');
expect(reveal).toHaveBeenCalledWith({ scheduleHide: false });
settings.toggle();
expect(settings.isOpen()).toBe(false);
expect(settings.focusGroup()).toBeNull();
});
it('is hidden with the controls even while the menu flag is set', () => {
setCapabilities({ playbackSpeed: true });
settings.open();
showControls.set(false);
expect(settings.available()).toBe(false);
expect(settings.isOpen()).toBe(false);
});
it('derives on/modified state and the tune dots', () => {
setCapabilities({
audioTracks: true,
subtitles: true,
playbackSpeed: true,
aspectRatio: true,
qualityLevels: true,
});
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
qualityLevels: [
{ id: 0, label: '1080p', selected: false },
{ id: 1, label: '720p', selected: false },
],
qualityAutoEnabled: true,
});
expect(settings.dots()).toEqual({ cyan: false, violet: false });
expect(settings.hasDots()).toBe(false);
expect(settings.subtitleLabel()).toBeNull();
expect(settings.speedLabel()).toBe('1×');
setState({
audioTracks: [
{ id: 1, label: 'English', selected: false },
{ id: 2, label: 'German', selected: true },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
qualityLevels: [
{ id: 0, label: '1080p', selected: true },
{ id: 1, label: '720p', selected: false },
],
qualityAutoEnabled: false,
playbackSpeed: 1.25,
aspectRatio: '16:9',
});
expect(settings.subtitlesOn()).toBe(true);
expect(settings.subtitleLabel()).toBe('Russian');
expect(settings.audioModified()).toBe(true);
expect(settings.qualityModified()).toBe(true);
expect(settings.speedModified()).toBe(true);
expect(settings.speedLabel()).toBe('1.25×');
expect(settings.aspectModified()).toBe(true);
expect(settings.dots()).toEqual({ cyan: true, violet: true });
});
it('treats the first aspect preset as the unmodified default', () => {
setCapabilities({ aspectRatio: true });
setState({
aspectRatio: 'auto',
aspectPresets: [
{ value: 'auto', label: 'Auto' },
{ value: '16:9', label: '16:9' },
],
});
expect(settings.defaultAspect()).toBe('auto');
expect(settings.aspectModified()).toBe(false);
});
it('toggles subtitles from the chip without opening the panel', () => {
setCapabilities({ subtitles: true });
setState({
subtitleTracks: [
{ id: 7, label: 'English', selected: false },
{ id: 8, label: 'German', selected: false },
],
});
const event = { preventDefault: jest.fn() } as unknown as Event;
settings.onSubtitleChipContextMenu(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(commands.setSubtitleTrack).toHaveBeenCalledWith(7);
expect(menus.settingsOpen()).toBe(false);
setState({
subtitleTracks: [{ id: 7, label: 'English', selected: true }],
subtitlesEnabled: true,
});
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).toHaveBeenLastCalledWith(-1);
});
it('opens the subtitles group instead when there is no track to turn on', () => {
setCapabilities({ externalSubtitles: true });
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).not.toHaveBeenCalled();
expect(settings.isOpen()).toBe(true);
expect(settings.focusGroup()).toBe('subtitles');
});
it('ignores the subtitle toggle without a subtitle group', () => {
settings.toggleSubtitles();
expect(commands.setSubtitleTrack).not.toHaveBeenCalled();
expect(reveal).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,164 @@
import { Signal, computed } from '@angular/core';
import { speedLabel } from './controls-format.utils';
import type { ControlsMenuState } from './controls-menu-state';
import {
anySettingsGroupAvailable,
getSettingsGroupAvailability,
type SettingsGroup,
} from './controls-settings-groups';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
} from './player-controls.model';
export interface ControlsSettingsDeps {
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
showControls: Signal<boolean>;
menus: ControlsMenuState;
commands: () => PlayerControlsCommands;
reveal: (options?: { scheduleHide?: boolean }) => void;
}
/** Colored state dots on the compact `tune` button. */
export interface SettingsStateDots {
/** Something is on: subtitles enabled, a non-default audio track. */
cyan: boolean;
/** A value was changed: speed, aspect ratio, manual quality. */
violet: boolean;
}
/**
* State behind the `tune` button, its chips and the settings panel: which
* groups exist, what is on or modified (the color-as-state system), and the
* open/close/toggle transitions. The panel itself only renders.
*/
export class ControlsSettings {
constructor(private readonly deps: ControlsSettingsDeps) {}
readonly groups = computed(() =>
getSettingsGroupAvailability(
this.deps.capabilities(),
this.deps.state()
)
);
readonly available = computed(
() =>
this.deps.showControls() && anySettingsGroupAvailable(this.groups())
);
readonly isOpen = computed(
() => this.available() && this.deps.menus.settingsOpen()
);
readonly focusGroup = computed(() => this.deps.menus.settingsFocus());
readonly subtitlesOn = computed(
() => this.groups().subtitles && this.deps.state().subtitlesEnabled
);
/** The selected subtitle track's label, for the chip; null while off. */
readonly subtitleLabel = computed(() => {
if (!this.subtitlesOn()) {
return null;
}
return (
this.deps.state().subtitleTracks.find((track) => track.selected)
?.label ?? null
);
});
/** A track other than the first (the engine's default) is selected. */
readonly audioModified = computed(() => {
if (!this.groups().audio) {
return false;
}
const tracks = this.deps.state().audioTracks;
const selected = tracks.findIndex((track) => track.selected);
return selected > 0;
});
readonly qualityModified = computed(
() => this.groups().quality && !this.deps.state().qualityAutoEnabled
);
readonly speedModified = computed(
() => this.groups().speed && this.deps.state().playbackSpeed !== 1
);
readonly speedLabel = computed(() =>
speedLabel(this.deps.state().playbackSpeed)
);
readonly defaultAspect = computed(
() => this.deps.state().aspectPresets[0]?.value ?? 'no'
);
readonly aspectModified = computed(
() =>
this.groups().aspect &&
this.deps.state().aspectRatio !== this.defaultAspect()
);
readonly dots = computed<SettingsStateDots>(() => ({
cyan: this.subtitlesOn() || this.audioModified(),
violet:
this.speedModified() ||
this.aspectModified() ||
this.qualityModified(),
}));
readonly hasDots = computed(() => this.dots().cyan || this.dots().violet);
open(group: SettingsGroup | null = null): void {
if (!this.available()) {
return;
}
this.deps.reveal({ scheduleHide: false });
this.deps.menus.openSettings(group);
}
toggle(): void {
if (!this.available()) {
return;
}
this.deps.reveal();
this.deps.menus.toggle('settings');
}
close(): void {
this.deps.menus.close('settings');
this.deps.reveal();
}
/**
* Secondary action of the subtitle chip (right-click, long-press):
* flip subtitles without opening the panel. With no embedded track to
* turn on, the panel opens instead so the file loader is reachable.
*/
onSubtitleChipContextMenu(event: Event): void {
event.preventDefault();
this.toggleSubtitles();
}
toggleSubtitles(): void {
if (!this.groups().subtitles) {
return;
}
const state = this.deps.state();
if (state.subtitlesEnabled) {
this.deps.reveal();
this.deps.commands().setSubtitleTrack(-1);
return;
}
const first = state.subtitleTracks[0];
if (!first) {
this.open('subtitles');
return;
}
this.deps.reveal();
this.deps.commands().setSubtitleTrack(first.id);
}
}
@@ -0,0 +1,97 @@
import { signal } from '@angular/core';
import {
ControlsTimelineHover,
projectPointerToSeconds,
} from './controls-timeline-hover';
describe('projectPointerToSeconds', () => {
const rect = { left: 100, width: 200 };
it('maps the pointer across the bar to the duration', () => {
expect(projectPointerToSeconds(100, rect, 600)).toBe(0);
expect(projectPointerToSeconds(200, rect, 600)).toBe(300);
expect(projectPointerToSeconds(300, rect, 600)).toBe(600);
});
it('clamps positions outside the bar', () => {
expect(projectPointerToSeconds(20, rect, 600)).toBe(0);
expect(projectPointerToSeconds(999, rect, 600)).toBe(600);
});
it('returns null without a usable bar or duration', () => {
expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe(
null
);
expect(projectPointerToSeconds(150, rect, 0)).toBeNull();
expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull();
});
});
describe('ControlsTimelineHover', () => {
function createHover(duration = 600, interactive = true) {
const durationSignal = signal(duration);
const interactiveSignal = signal(interactive);
const hover = new ControlsTimelineHover({
duration: durationSignal,
interactive: interactiveSignal,
});
const bar = document.createElement('div');
bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect;
return { hover, bar, durationSignal, interactiveSignal };
}
function pointer(clientX: number, pointerType = 'mouse'): PointerEvent {
return { clientX, pointerType } as PointerEvent;
}
it('reports the hovered time, percent and label', () => {
const { hover, bar } = createHover();
hover.move(pointer(100), bar);
expect(hover.seconds()).toBe(150);
expect(hover.percent()).toBe(25);
expect(hover.label()).toBe('2:30');
});
it('clears on leave', () => {
const { hover, bar } = createHover();
hover.move(pointer(100), bar);
hover.clear();
expect(hover.seconds()).toBeNull();
expect(hover.percent()).toBeNull();
expect(hover.label()).toBeNull();
});
it('ignores touch pointers and non-interactive timelines', () => {
const { hover, bar, interactiveSignal } = createHover();
hover.move(pointer(100, 'touch'), bar);
expect(hover.seconds()).toBeNull();
interactiveSignal.set(false);
hover.move(pointer(100), bar);
expect(hover.seconds()).toBeNull();
});
it('reads the bar from the event target when none is passed', () => {
const { hover, bar } = createHover();
hover.move({ ...pointer(200), currentTarget: bar } as PointerEvent);
expect(hover.seconds()).toBe(300);
hover.move({ ...pointer(200), currentTarget: null } as PointerEvent);
expect(hover.seconds()).toBeNull();
});
it('drops a stale hover once the duration disappears', () => {
const { hover, bar, durationSignal } = createHover();
hover.move(pointer(100), bar);
durationSignal.set(0);
expect(hover.percent()).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { Signal, computed, signal } from '@angular/core';
import { formatTime } from './controls-format.utils';
import {
findTimelineSegment,
type TimelineSegmentView,
} from './controls-timeline-segments';
/**
* Projects a pointer's horizontal position over the timeline bar onto a
* playback time (seconds), clamped to the bar. Null when the bar has no
* width or the duration is unknown.
*/
export function projectPointerToSeconds(
clientX: number,
rect: Pick<DOMRect, 'left' | 'width'>,
durationSeconds: number
): number | null {
if (
!Number.isFinite(clientX) ||
!Number.isFinite(rect.width) ||
rect.width <= 0 ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
const fraction = Math.min(
1,
Math.max(0, (clientX - rect.left) / rect.width)
);
return fraction * durationSeconds;
}
export interface ControlsTimelineHoverDeps {
duration: Signal<number>;
/** Whether the timeline accepts pointer interaction at all. */
interactive: Signal<boolean>;
/** Drawn segments, for the title in the label; none means time only. */
segments?: Signal<readonly TimelineSegmentView[]>;
}
/**
* Hover state of the timeline: the time under the pointer, its position as
* a percentage of the bar (for the marker and label), and the label text.
* Touch never hovers, so a coarse pointer leaves this empty.
*/
export class ControlsTimelineHover {
readonly seconds = signal<number | null>(null);
constructor(private readonly deps: ControlsTimelineHoverDeps) {}
readonly percent = computed(() => {
const seconds = this.seconds();
const duration = this.deps.duration();
if (seconds === null || duration <= 0) {
return null;
}
return Math.min(100, Math.max(0, (seconds / duration) * 100));
});
/** The titled segment under the pointer, if any. */
readonly segmentTitle = computed(() => {
const seconds = this.seconds();
const segments = this.deps.segments?.();
if (seconds === null || !segments) {
return null;
}
return findTimelineSegment(segments, seconds)?.title ?? null;
});
/** `Chapter 2 · 12:40` over a titled segment, else the time alone. */
readonly label = computed(() => {
const seconds = this.seconds();
if (seconds === null) {
return null;
}
const time = formatTime(seconds);
const title = this.segmentTitle();
return title ? `${title} \u00b7 ${time}` : time;
});
/** Bound to `pointermove` on the bar element itself (`currentTarget`). */
move(event: PointerEvent, bar?: HTMLElement): void {
const target = bar ?? event.currentTarget;
if (
!(target instanceof HTMLElement) ||
!this.deps.interactive() ||
event.pointerType === 'touch'
) {
this.clear();
return;
}
this.seconds.set(
projectPointerToSeconds(
event.clientX,
target.getBoundingClientRect(),
this.deps.duration()
)
);
}
clear(): void {
if (this.seconds() !== null) {
this.seconds.set(null);
}
}
}
@@ -0,0 +1,150 @@
import {
findTimelineSegment,
normalizeTimelineSegments,
segmentFillPercent,
} from './controls-timeline-segments';
describe('normalizeTimelineSegments', () => {
it('renders one untitled segment without input or duration', () => {
expect(normalizeTimelineSegments(null, 600)).toEqual([
expect.objectContaining({
startSeconds: 0,
endSeconds: 600,
title: null,
share: 1,
}),
]);
expect(normalizeTimelineSegments([], 600)).toHaveLength(1);
expect(
normalizeTimelineSegments(
[{ startSeconds: 0, endSeconds: 10, title: 'Intro' }],
0
)
).toEqual([
expect.objectContaining({ startSeconds: 0, title: null, share: 1 }),
]);
expect(
normalizeTimelineSegments([], Number.POSITIVE_INFINITY)
).toHaveLength(1);
});
it('sorts, fills the gaps and shares the duration', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 300, endSeconds: 450, title: 'Chapter 2' },
{ startSeconds: 60, endSeconds: 300, title: 'Chapter 1' },
],
600
);
expect(segments).toEqual([
expect.objectContaining({
startSeconds: 0,
endSeconds: 60,
title: null,
share: 0.1,
}),
expect.objectContaining({
startSeconds: 60,
endSeconds: 300,
title: 'Chapter 1',
share: 0.4,
}),
expect.objectContaining({
startSeconds: 300,
endSeconds: 450,
title: 'Chapter 2',
share: 0.25,
}),
expect.objectContaining({
startSeconds: 450,
endSeconds: 600,
title: null,
share: 0.25,
}),
]);
expect(
segments.reduce((total, segment) => total + segment.share, 0)
).toBeCloseTo(1);
});
it('places segments at their time positions with a gap after all but the last', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 0, endSeconds: 150, title: 'A' },
{ startSeconds: 150, endSeconds: 600, title: 'B' },
],
600
);
expect(segments.map((s) => s.startPercent)).toEqual([0, 25]);
expect(segments.map((s) => s.width)).toEqual([
'max(0px, calc(25% - 3px))',
'75%',
]);
});
it('clamps to the duration, cuts overlaps and drops empty segments', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: -20, endSeconds: 100, title: ' A ' },
{ startSeconds: 50, endSeconds: 200, title: 'B' },
{ startSeconds: 200, endSeconds: 200, title: 'empty' },
{ startSeconds: 400, endSeconds: 300, title: 'reversed' },
{ startSeconds: 150, endSeconds: 180, title: 'swallowed' },
{ startSeconds: 500, endSeconds: 900, title: '' },
],
600
);
expect(
segments.map((s) => [s.startSeconds, s.endSeconds, s.title])
).toEqual([
[0, 100, 'A'],
[100, 200, 'B'],
[200, 500, null],
[500, 600, null],
]);
});
});
describe('segmentFillPercent', () => {
const segment = { startSeconds: 100, endSeconds: 300 };
it('measures the played share of a segment and clamps outside it', () => {
expect(segmentFillPercent(segment, 50)).toBe(0);
expect(segmentFillPercent(segment, 150)).toBe(25);
expect(segmentFillPercent(segment, 300)).toBe(100);
expect(segmentFillPercent(segment, 999)).toBe(100);
});
it('is empty for degenerate segments and positions', () => {
expect(segmentFillPercent({ startSeconds: 5, endSeconds: 5 }, 5)).toBe(
0
);
expect(segmentFillPercent(segment, Number.NaN)).toBe(0);
});
});
describe('findTimelineSegment', () => {
const segments = normalizeTimelineSegments(
[
{ startSeconds: 0, endSeconds: 100, title: 'First' },
{ startSeconds: 100, endSeconds: 200, title: 'Second' },
],
200
);
it('returns the segment containing the time, the last one at the end', () => {
expect(findTimelineSegment(segments, 0)?.title).toBe('First');
expect(findTimelineSegment(segments, 99.9)?.title).toBe('First');
expect(findTimelineSegment(segments, 100)?.title).toBe('Second');
expect(findTimelineSegment(segments, 200)?.title).toBe('Second');
});
it('returns null for nothing to match', () => {
expect(findTimelineSegment([], 10)).toBeNull();
expect(findTimelineSegment(segments, Number.NaN)).toBeNull();
expect(findTimelineSegment(segments, -1)).toBeNull();
});
});
@@ -0,0 +1,128 @@
import type { PlayerTimelineSegment } from './player-controls.model';
/** A normalized segment as the track renders it. */
export interface TimelineSegmentView {
startSeconds: number;
endSeconds: number;
title: string | null;
/** Share of the whole duration, 0..1. */
share: number;
/** Left edge on the track, 0..100 — the same mapping the seek input uses. */
startPercent: number;
/** CSS width: the share, minus the gap every segment but the last keeps. */
width: string;
}
/** Visual gap (px) after every segment except the last. */
export const TIMELINE_SEGMENT_GAP_PX = 3;
const WHOLE_TIMELINE: TimelineSegmentView = {
startSeconds: 0,
endSeconds: 0,
title: null,
share: 1,
startPercent: 0,
width: '100%',
};
/**
* Turns host-supplied segments (chapters, programmes) into a gapless,
* non-overlapping cover of `[0, duration]`, in playback order. Segments are
* clamped to the duration, empty and reversed ones dropped, overlaps cut at
* the previous segment's end, and every uncovered stretch — before the
* first, between two, after the last — becomes an untitled segment so the
* track always adds up to the full duration. Without a usable duration or
* without segments the whole timeline is one untitled segment, which is
* exactly the pre-segment rendering.
*/
export function normalizeTimelineSegments(
segments: readonly PlayerTimelineSegment[] | null | undefined,
durationSeconds: number
): TimelineSegmentView[] {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
return [WHOLE_TIMELINE];
}
const clamp = (value: number) =>
Number.isFinite(value)
? Math.min(durationSeconds, Math.max(0, value))
: 0;
const ordered = (segments ?? [])
.map((segment) => ({
startSeconds: clamp(segment.startSeconds),
endSeconds: clamp(segment.endSeconds),
title: segment.title?.trim() || null,
}))
.filter((segment) => segment.endSeconds > segment.startSeconds)
.sort((a, b) => a.startSeconds - b.startSeconds);
const cover: Pick<
TimelineSegmentView,
'startSeconds' | 'endSeconds' | 'title'
>[] = [];
let cursor = 0;
for (const segment of ordered) {
const startSeconds = Math.max(segment.startSeconds, cursor);
if (startSeconds >= segment.endSeconds) {
continue;
}
if (startSeconds > cursor) {
cover.push({
startSeconds: cursor,
endSeconds: startSeconds,
title: null,
});
}
cover.push({ ...segment, startSeconds });
cursor = segment.endSeconds;
}
if (cursor < durationSeconds) {
cover.push({
startSeconds: cursor,
endSeconds: durationSeconds,
title: null,
});
}
return cover.map((segment, index) => {
const share =
(segment.endSeconds - segment.startSeconds) / durationSeconds;
const last = index === cover.length - 1;
return {
...segment,
share,
startPercent: (segment.startSeconds / durationSeconds) * 100,
width: last
? `${share * 100}%`
: `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`,
};
});
}
/** How much of one segment the position has played through, 0..100. */
export function segmentFillPercent(
segment: Pick<TimelineSegmentView, 'startSeconds' | 'endSeconds'>,
positionSeconds: number
): number {
const length = segment.endSeconds - segment.startSeconds;
if (length <= 0 || !Number.isFinite(positionSeconds)) {
return 0;
}
const fraction = (positionSeconds - segment.startSeconds) / length;
return Math.min(100, Math.max(0, fraction * 100));
}
/** The segment containing `seconds`; the last one at the very end. */
export function findTimelineSegment(
segments: readonly TimelineSegmentView[],
seconds: number
): TimelineSegmentView | null {
if (!Number.isFinite(seconds)) {
return null;
}
for (const segment of segments) {
if (seconds >= segment.startSeconds && seconds < segment.endSeconds) {
return segment;
}
}
const last = segments[segments.length - 1];
return last && seconds >= last.endSeconds ? last : null;
}
@@ -1,5 +1,13 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerControlsState } from './player-controls.model';
import {
normalizeTimelineSegments,
segmentFillPercent,
type TimelineSegmentView,
} from './controls-timeline-segments';
import type {
PlayerControlsState,
PlayerTimelineSegment,
} from './player-controls.model';
/**
* Owns the scrub state and timeline projections for the controls bar: the
@@ -9,7 +17,12 @@ import type { PlayerControlsState } from './player-controls.model';
export class ControlsTimeline {
readonly scrubPosition = signal<number | null>(null);
constructor(private readonly state: Signal<PlayerControlsState>) {}
constructor(
private readonly state: Signal<PlayerControlsState>,
private readonly hostSegments: Signal<
readonly PlayerTimelineSegment[] | null
> = signal(null)
) {}
readonly duration = computed(() => {
const duration = this.state().durationSeconds;
@@ -32,6 +45,19 @@ export class ControlsTimeline {
: 0;
});
/** The drawn track: host segments over the duration, else one segment. */
readonly segments = computed<TimelineSegmentView[]>(() =>
normalizeTimelineSegments(this.hostSegments(), this.duration())
);
/** Played share of one segment for the current (scrub or playback) value. */
fillPercent(segment: TimelineSegmentView): number {
if (!this.state().canSeek) {
return 0;
}
return segmentFillPercent(segment, this.value());
}
readEventValue(event: Event): number | null {
return this.normalize(Number((event.target as HTMLInputElement).value));
}
@@ -0,0 +1,142 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
describe('ControlsUpNext', () => {
const next: PlayerUpNextItem = {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
};
let item: WritableSignal<PlayerUpNextItem | null>;
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let settingsOpen: WritableSignal<boolean>;
let upNext: ControlsUpNext;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
beforeEach(() => {
item = signal(next);
state = signal(createEmptyControlsState());
capabilities = signal({
...DEFAULT_PLAYER_CAPABILITIES,
seriesNavigation: true,
});
showControls = signal(true);
settingsOpen = signal(false);
upNext = new ControlsUpNext({
item,
state,
capabilities,
showControls,
settingsOpen,
});
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 7 * 60 - 3,
});
});
it('shows the next episode inside the threshold with minutes left', () => {
expect(upNext.visible()).toBe(true);
expect(upNext.item()).toBe(next);
expect(upNext.remainingSeconds()).toBe(423);
expect(upNext.minutesLeft()).toBe(8);
});
it('stays hidden until the threshold and reports at least one minute', () => {
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1199.5,
});
expect(upNext.minutesLeft()).toBe(1);
});
it('needs a supplied episode, a finite duration and a series-capable engine', () => {
item.set(null);
expect(upNext.visible()).toBe(false);
item.set(next);
// The host's item is authoritative: at a season's last episode the
// transport cannot step forward, but the next season's first can.
setState({
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: null,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
expect(upNext.remainingSeconds()).toBeNull();
setState({
canNextEpisode: true,
isLive: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
expect(upNext.visible()).toBe(false);
});
it('does not count down once the episode has ended', () => {
setState({
status: 'ended',
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200,
});
expect(upNext.visible()).toBe(false);
});
it('yields to hidden controls and to the open settings panel', () => {
showControls.set(false);
expect(upNext.visible()).toBe(false);
showControls.set(true);
settingsOpen.set(true);
expect(upNext.visible()).toBe(false);
settingsOpen.set(false);
expect(upNext.visible()).toBe(true);
});
});
@@ -0,0 +1,69 @@
import { Signal, computed } from '@angular/core';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
/** The card appears once this little of the episode is left. */
export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60;
export interface ControlsUpNextDeps {
item: Signal<PlayerUpNextItem | null>;
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
showControls: Signal<boolean>;
/** The settings panel covers the card's corner; it yields while open. */
settingsOpen: Signal<boolean>;
}
/**
* When the "Up next" card shows: a series host supplied the next episode
* (possibly the first of the following season, which the transport's
* season-local `canNextEpisode` does not cover), the engine plays series,
* the episode has a known length, and no more than the threshold is left. Live streams and open-ended
* VOD never qualify because they have no remaining time to count down.
*/
export class ControlsUpNext {
constructor(private readonly deps: ControlsUpNextDeps) {}
readonly remainingSeconds = computed(() => {
const { durationSeconds, positionSeconds } = this.deps.state();
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
return Math.max(0, durationSeconds - Math.max(0, positionSeconds));
});
readonly item = computed<PlayerUpNextItem | null>(() => {
const item = this.deps.item();
const remaining = this.remainingSeconds();
const state = this.deps.state();
if (
!item ||
remaining === null ||
remaining > UP_NEXT_THRESHOLD_SECONDS ||
state.isLive ||
// An ended episode with autoplay off schedules no switch: a
// countdown would promise one. Autoplay replaces the playback.
state.status === 'ended' ||
!this.deps.capabilities().seriesNavigation ||
!this.deps.showControls() ||
this.deps.settingsOpen()
) {
return null;
}
return item;
});
readonly visible = computed(() => this.item() !== null);
/** Whole minutes left, never below one while the card is showing. */
readonly minutesLeft = computed(() =>
Math.max(1, Math.ceil((this.remainingSeconds() ?? 0) / 60))
);
}
@@ -45,8 +45,14 @@ export class ControlsVolumeInteractions {
this.deps.volume.hoverLeave();
}
buttonClick(event?: Event): void {
/**
* With the slider rendered inline next to the button (wide dock), there
* is no popover to open first: the button is plainly a mute toggle for
* every pointer type.
*/
buttonClick(event?: Event, options: { inlineSlider?: boolean } = {}): void {
if (
!options.inlineSlider &&
this.deps.wasTouchInteraction(event) &&
!this.deps.menus.volumeOpen()
) {
@@ -108,21 +108,18 @@ describe('PlayerControlsComponent capability contract', () => {
return event.defaultPrevented;
}
it('keeps narrow-player popovers outside clipping scroll containers', () => {
it('lets the compact action cluster wrap instead of clipping or scrolling', () => {
const narrowActionsRule =
PLAYER_CONTROLS_STYLE_SOURCE.match(
/\.player-controls__actions\s*\{([\s\S]*?)\}/g
)?.at(-1) ?? '';
expect(PLAYER_CONTROLS_STYLE_SOURCE).toContain(
'@container player-controls (max-width: 640px)'
'@container player-controls (max-width: 719px)'
);
expect(narrowActionsRule).toContain('flex-wrap: wrap');
expect(narrowActionsRule).toContain('overflow: visible');
expect(narrowActionsRule).not.toContain('overflow-x: auto');
expect(PLAYER_CONTROLS_STYLE_SOURCE).toMatch(
/\.player-controls__actions \.player-controls__popover-anchor\s*\{\s*position:\s*static;/
);
});
it('routes shortcuts to the player the user interacted with most recently', () => {
@@ -236,8 +233,8 @@ describe('PlayerControlsComponent capability contract', () => {
],
});
fixture.detectChanges();
component.toggleMenu('audio');
expect(component.menus.audioOpen()).toBe(true);
component.settings.open('audio');
expect(component.menus.settingsOpen()).toBe(true);
fake.state.set({
...createEmptyControlsState(),
@@ -251,8 +248,8 @@ describe('PlayerControlsComponent capability contract', () => {
playbackSpeed: true,
});
fixture.detectChanges();
component.toggleMenu('speed');
expect(component.menus.speedOpen()).toBe(true);
component.settings.open('speed');
expect(component.menus.settingsOpen()).toBe(true);
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
@@ -292,16 +289,16 @@ describe('PlayerControlsComponent capability contract', () => {
const root = fixture.nativeElement as HTMLElement;
const trigger = root.querySelector<HTMLButtonElement>(
'[data-test-id="player-controls-quality"]'
'[data-test-id="player-controls-settings-button"]'
);
expect(trigger).not.toBeNull();
trigger?.click();
fixture.detectChanges();
expect(component.menus.qualityOpen()).toBe(true);
expect(component.menus.settingsOpen()).toBe(true);
const entries = Array.from(
root.querySelectorAll<HTMLButtonElement>(
'.player-controls__track-popover .player-controls__track'
'[data-test-id="player-settings-quality"] .player-settings__option'
)
);
expect(entries.map((entry) => entry.textContent?.trim())).toEqual([
@@ -314,9 +311,9 @@ describe('PlayerControlsComponent capability contract', () => {
entries[2].click();
fixture.detectChanges();
expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1);
expect(component.menus.qualityOpen()).toBe(false);
// The panel stays open so the next level can be compared in place.
expect(component.menus.settingsOpen()).toBe(true);
component.toggleMenu('quality');
fake.state.set({
...createEmptyControlsState(),
qualityLevels: [{ id: 0, label: '1080p', selected: false }],
@@ -324,7 +321,9 @@ describe('PlayerControlsComponent capability contract', () => {
fixture.detectChanges();
expect(component.anyMenuOpen()).toBe(false);
expect(
root.querySelector('[data-test-id="player-controls-quality"]')
root.querySelector(
'[data-test-id="player-controls-settings-button"]'
)
).toBeNull();
});
@@ -348,7 +347,7 @@ describe('PlayerControlsComponent capability contract', () => {
const root = fixture.nativeElement as HTMLElement;
expect(
root.querySelector('.player-controls__timeline > input')
root.querySelector('.player-controls__slider--timeline')
).toBeNull();
expect(
root.querySelector('.player-controls__live-badge')
@@ -0,0 +1,567 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { COMPACT_LAYOUT_MAX_WIDTH } from './controls-layout';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
/**
* The "Hybrid" dock: scrim backdrop, remaining time, timeline hover label,
* the accent play button, and the compact/wide layout branches.
*/
describe('PlayerControlsComponent dock', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallbacks: ResizeCallback[];
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const resizeTo = (width: number) => {
for (const callback of resizeCallbacks) {
callback([
{
borderBoxSize: [{ inlineSize: width, blockSize: 300 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
}
fixture.detectChanges();
};
beforeEach(async () => {
resizeCallbacks = [];
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
resizeCallbacks.push(callback);
}
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
PLAY: 'Play',
PAUSE: 'Pause',
PREVIOUS_EPISODE: 'Previous episode',
NEXT_EPISODE: 'Next episode',
BACK_10_SECONDS: 'Back 10 seconds',
FORWARD_10_SECONDS: 'Forward 10 seconds',
MUTE: 'Mute',
UNMUTE: 'Unmute',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
globalThis.ResizeObserver = originalResizeObserver;
});
it('backs the dock with a bottom scrim that fades with the controls', () => {
const scrim = query('[data-test-id="player-controls-bottom-scrim"]');
expect(scrim).not.toBeNull();
expect(scrim?.classList).toContain(
'player-controls__bottom-scrim--visible'
);
expect(scrim?.getAttribute('aria-hidden')).toBe('true');
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-bottom-scrim"]')
).toBeNull();
});
it('renders the accent play button with Play/Pause names', () => {
const play = query('[data-test-id="player-controls-play"]');
expect(play?.tagName).toBe('BUTTON');
expect(play?.getAttribute('aria-label')).toBe('Play');
expect(play?.querySelector('mat-icon')?.textContent).toBe('play_arrow');
setState({ status: 'playing' });
fixture.detectChanges();
expect(play?.getAttribute('aria-label')).toBe('Pause');
expect(play?.querySelector('mat-icon')?.textContent).toBe('pause');
play?.click();
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
});
it('orders the wide transport as previous, back, play, forward, next', () => {
setCapabilities({ seek: true, seriesNavigation: true });
setState({ canSeek: true, durationSeconds: 100 });
fixture.detectChanges();
const labels = Array.from(
fixture.nativeElement.querySelectorAll(
'.player-controls__transport button'
) as NodeListOf<HTMLElement>
).map((button) => button.getAttribute('aria-label'));
expect(labels).toEqual([
'Previous episode',
'Back 10 seconds',
'Play',
'Forward 10 seconds',
'Next episode',
]);
});
describe('timeline row', () => {
it('shows the remaining time instead of the total duration', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 30,
});
fixture.detectChanges();
expect(query('.player-controls__time--current')?.textContent).toBe(
'0:30'
);
expect(
query('[data-test-id="player-controls-remaining-time"]')
?.textContent
).toBe('−9:30');
expect(query('.player-controls__live-badge')).toBeNull();
});
it('follows the scrub preview in the remaining time', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 600,
positionSeconds: 30,
});
fixture.detectChanges();
const slider = query(
'.player-controls__slider--timeline'
) as HTMLInputElement;
slider.value = '540';
slider.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-remaining-time"]')
?.textContent
).toBe('−1:00');
});
it('shows a placeholder for unknown durations and LIVE for live streams', () => {
setState({ canSeek: false, isLive: false });
fixture.detectChanges();
expect(query('.player-controls__time--end')?.textContent).toBe(
'--:--'
);
setState({ canSeek: false, isLive: true });
fixture.detectChanges();
expect(query('.player-controls__time--end')).toBeNull();
expect(query('.player-controls__live-badge')).not.toBeNull();
});
it('draws the progress fill and knob from the timeline progress', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 200,
positionSeconds: 50,
});
fixture.detectChanges();
expect(query('.player-controls__timeline-fill')?.style.width).toBe(
'25%'
);
expect(query('.player-controls__timeline-knob')?.style.left).toBe(
'25%'
);
setState({
canSeek: false,
durationSeconds: 200,
positionSeconds: 50,
});
fixture.detectChanges();
expect(query('.player-controls__timeline-knob')).toBeNull();
expect(
query('.player-controls__timeline-bar')?.classList
).toContain('player-controls__timeline-bar--disabled');
});
it('shows the hovered time above the bar and clears it on leave', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 0,
});
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 100, width: 200 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 150, bubbles: true })
);
fixture.detectChanges();
const label = query(
'[data-test-id="player-controls-timeline-label"]'
);
expect(label?.textContent?.trim()).toBe('1:40');
expect(label?.style.getPropertyValue('--hover-x')).toBe('25%');
expect(query('.player-controls__timeline-marker')?.style.left).toBe(
'25%'
);
expect(fake.commands.seekTo).not.toHaveBeenCalled();
bar.dispatchEvent(
new MouseEvent('pointerleave', { bubbles: true })
);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-timeline-label"]')
).toBeNull();
});
it('draws host segments proportionally with per-segment fills', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 150,
});
fixture.componentRef.setInput('timelineSegments', [
{ startSeconds: 100, endSeconds: 300, title: 'Chapter 2' },
{ startSeconds: 0, endSeconds: 100, title: 'Chapter 1' },
]);
fixture.detectChanges();
const segments = Array.from(
fixture.nativeElement.querySelectorAll(
'.player-controls__timeline-segment'
) as NodeListOf<HTMLElement>
);
expect(
segments.map((s) => s.dataset['segmentTitle'] ?? null)
).toEqual(['Chapter 1', 'Chapter 2', null]);
// Positioned by time, so boundaries match the linear seek input.
expect(segments.map((s) => s.style.left)).toEqual([
'0%',
'25%',
'75%',
]);
expect(segments.at(-1)?.style.width).toBe('25%');
expect(
segments.map(
(s) =>
(
s.querySelector(
'.player-controls__timeline-fill'
) as HTMLElement
).style.width
)
).toEqual(['100%', '25%', '0%']);
// The knob still reads the overall progress.
expect(query('.player-controls__timeline-knob')?.style.left).toBe(
'37.5%'
);
});
it('names the hovered segment in the timeline label', () => {
setCapabilities({ seek: true });
setState({
canSeek: true,
durationSeconds: 400,
positionSeconds: 0,
});
fixture.componentRef.setInput('timelineSegments', [
{ startSeconds: 0, endSeconds: 200, title: 'Intro' },
]);
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 0, width: 400 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 100, bubbles: true })
);
fixture.detectChanges();
expect(
query(
'[data-test-id="player-controls-timeline-label"]'
)?.textContent?.trim()
).toBe('Intro \u00b7 1:40');
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 300, bubbles: true })
);
fixture.detectChanges();
expect(
query(
'[data-test-id="player-controls-timeline-label"]'
)?.textContent?.trim()
).toBe('5:00');
});
it('does not hover-label a non-seekable timeline', () => {
setCapabilities({ seek: true });
setState({ canSeek: false, durationSeconds: 400 });
fixture.detectChanges();
const bar = query('.player-controls__timeline-bar') as HTMLElement;
bar.getBoundingClientRect = () =>
({ left: 0, width: 200 }) as DOMRect;
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 50, bubbles: true })
);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-timeline-label"]')
).toBeNull();
});
});
describe('up next card', () => {
const nearTheEnd = () =>
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
});
beforeEach(() => {
setCapabilities({
seek: true,
seriesNavigation: true,
playbackSpeed: true,
});
fixture.componentRef.setInput('upNext', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
});
fixture.detectChanges();
});
it('appears within the last minutes and plays the next episode on click', () => {
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 60,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
nearTheEnd();
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
expect(card).not.toBeNull();
expect(card?.textContent).toContain('The Third One');
const next = jest.fn();
component.nextEpisodeRequested.subscribe(next);
card?.click();
expect(next).toHaveBeenCalledTimes(1);
});
it('offers the next season across the season boundary', () => {
setState({
canSeek: true,
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
});
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
expect(card).not.toBeNull();
// The transport's next button is disabled, but the card still
// hands the request to the host, which picks the next season.
expect(
(
query(
'[data-test-id="player-controls-next-episode"]'
) as HTMLButtonElement
).disabled
).toBe(true);
const next = jest.fn();
component.nextEpisodeRequested.subscribe(next);
card?.click();
expect(next).toHaveBeenCalledTimes(1);
});
it('yields to the settings panel and needs a next episode', () => {
nearTheEnd();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
component.settings.open('speed');
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
component.settings.close();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
fixture.componentRef.setInput('upNext', null);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
});
it('uses the compact card on a compact dock', () => {
nearTheEnd();
resizeTo(400);
expect(
query('[data-test-id="player-controls-up-next"]')?.closest(
'.player-controls__up-next--compact'
)
).not.toBeNull();
});
});
describe('layout modes', () => {
beforeEach(() => {
setCapabilities({ volume: true });
fixture.detectChanges();
});
it('starts wide with the volume slider inline', () => {
expect(component.isCompact()).toBe(false);
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).not.toBeNull();
expect(query('.player-controls__volume-popover')).toBeNull();
});
it('mutes on a wide volume button click without opening a popover', () => {
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
expect(query('.player-controls__volume-popover')).toBeNull();
(query('[aria-label="Mute"]') as HTMLButtonElement).dispatchEvent(
new MouseEvent('click', { bubbles: true })
);
fixture.detectChanges();
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(query('[aria-label="Unmute"]')).not.toBeNull();
});
it('moves the volume slider behind a popover once compact', () => {
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
expect(component.isCompact()).toBe(true);
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).toBeNull();
expect(query('.player-controls__volume-popover')).toBeNull();
component.volumeInteractions.hoverEnter();
fixture.detectChanges();
expect(query('.player-controls__volume-popover')).not.toBeNull();
resizeTo(COMPACT_LAYOUT_MAX_WIDTH + 200);
expect(component.isCompact()).toBe(false);
expect(query('.player-controls__slider--inline')).not.toBeNull();
});
it('keeps episode navigation reachable in the compact transport', () => {
setCapabilities({ volume: true, seriesNavigation: true });
resizeTo(400);
expect(
query('[data-test-id="player-controls-next-episode"]')
).not.toBeNull();
expect(
query('[data-test-id="player-controls-previous-episode"]')
).not.toBeNull();
});
});
});
File diff suppressed because it is too large. Load diff
@@ -144,7 +144,9 @@ describe('PlayerControlsComponent interactions', () => {
expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95);
expect(component.feedback.current()?.label).toBe('95%');
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: -100 })
);
expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1);
});
@@ -170,7 +172,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('keeps optimistic volume across capability and visibility changes', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
expect(component.displayVolume()).toBe(0.95);
setState({ positionSeconds: 15, volume: 1 });
@@ -187,7 +191,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('reapplies persisted volume when the capability returns', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
fake.commands.setVolume.mockClear();
setCapabilities({ volume: false });
@@ -202,7 +208,9 @@ describe('PlayerControlsComponent interactions', () => {
});
it('reconciles volume when the controller changes at the same value', () => {
component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 }));
component.volumeInteractions.wheel(
new WheelEvent('wheel', { deltaY: 100 })
);
expect(component.displayVolume()).toBe(0.95);
localStorage.removeItem('volume');
@@ -218,40 +226,40 @@ describe('PlayerControlsComponent interactions', () => {
});
});
describe('menu selections', () => {
it('applies a speed preset and closes the menu', () => {
describe('settings panel selections', () => {
it('applies a speed preset from the chip-opened panel and keeps it open', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click();
query('[data-test-id="player-controls-speed-chip"]')?.click();
fixture.detectChanges();
expect(component.menus.speedOpen()).toBe(true);
expect(component.menus.settingsOpen()).toBe(true);
expect(component.menus.settingsFocus()).toBe('speed');
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('1.5×')
);
const preset = queryAll(
'[data-test-id="player-settings-speed"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('1.5×'));
preset?.click();
fixture.detectChanges();
expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5);
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.settingsOpen()).toBe(true);
});
it('applies an aspect preset and closes the menu', () => {
it('applies an aspect preset from the panel', () => {
setCapabilities({ aspectRatio: true });
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click();
component.settings.open('aspect');
fixture.detectChanges();
const preset = queryAll('.player-controls__track').find((item) =>
item.textContent?.includes('16:9')
);
const preset = queryAll(
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('16:9'));
preset?.click();
fixture.detectChanges();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.menus.aspectOpen()).toBe(false);
});
it('disables subtitles via the Off entry', () => {
@@ -262,22 +270,24 @@ describe('PlayerControlsComponent interactions', () => {
});
fixture.detectChanges();
query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click();
query('[data-test-id="player-controls-subtitle-chip"]')?.click();
fixture.detectChanges();
expect(component.menus.settingsFocus()).toBe('subtitles');
queryAll('.player-controls__track')[0]?.click();
queryAll(
'[data-test-id="player-settings-subtitles"] .player-settings__option'
)[0]?.click();
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1);
expect(component.menus.subtitleOpen()).toBe(false);
});
it('opening one menu closes the others', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
setCapabilities({ playbackSpeed: true, volume: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.toggleMenu('aspect');
expect(component.menus.speedOpen()).toBe(false);
expect(component.menus.aspectOpen()).toBe(true);
component.settings.open('speed');
component.toggleMenu('volume');
expect(component.menus.settingsOpen()).toBe(false);
expect(component.menus.volumeOpen()).toBe(true);
expect(component.anyMenuOpen()).toBe(true);
});
});
@@ -339,17 +349,25 @@ describe('PlayerControlsComponent interactions', () => {
it('keeps the controls visible while a menu is open, hides after close', () => {
setCapabilities({ playbackSpeed: true });
fixture.detectChanges();
component.toggleMenu('speed');
component.settings.open('speed');
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
// A choice keeps the panel (and the controls) on screen…
component.menuSelection.speed(1.25);
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(true);
// …closing it re-arms the auto-hide.
component.settings.close();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
});
@@ -403,21 +421,39 @@ describe('PlayerControlsComponent interactions', () => {
});
it('opens the popover on the first tap instead of muting', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
expect(component.menus.volumeOpen()).toBe(true);
expect(fake.commands.setVolume).not.toHaveBeenCalled();
});
it('toggles mute on a tap while the popover is open', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
});
it('mutes on a tap when the slider is already inline (wide dock)', () => {
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch'),
{ inlineSlider: true }
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
});
it('mutes directly on a mouse click without opening the popover', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'mouse'));
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'mouse')
);
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
expect(component.menus.volumeOpen()).toBe(false);
@@ -440,7 +476,9 @@ describe('PlayerControlsComponent interactions', () => {
component.volumeInteractions.buttonClick(
pointerTypedEvent('click', 'touch')
);
component.volumeInteractions.hoverLeave(new FocusEvent('focusout'));
component.volumeInteractions.hoverLeave(
new FocusEvent('focusout')
);
jest.advanceTimersByTime(1000);
expect(component.menus.volumeOpen()).toBe(true);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,485 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
COMPACT_LAYOUT_MAX_WIDTH,
ROOMY_LAYOUT_MIN_WIDTH,
} from './controls-layout';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
/**
* The settings panel behind the `tune` button: chips on wide players, the
* panel beside the video, the compact bottom sheet, and the dock changes
* that come with each.
*/
describe('PlayerControlsComponent settings panel', () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallbacks: ResizeCallback[];
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const queryAll = (selector: string) =>
Array.from(
fixture.nativeElement.querySelectorAll(
selector
) as NodeListOf<HTMLElement>
);
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const resizeTo = (width: number) => {
for (const callback of resizeCallbacks) {
callback([
{
borderBoxSize: [{ inlineSize: width, blockSize: 300 }],
contentRect: { width } as DOMRectReadOnly,
} as unknown as ResizeObserverEntry,
]);
}
fixture.detectChanges();
};
const withEverything = () => {
setCapabilities({
volume: true,
audioTracks: true,
subtitles: true,
externalSubtitles: true,
playbackSpeed: true,
aspectRatio: true,
pictureInPicture: true,
fullscreen: true,
});
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
{ id: 2, label: 'German', selected: false },
],
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
canPictureInPicture: true,
});
fixture.detectChanges();
};
beforeEach(async () => {
resizeCallbacks = [];
class FakeResizeObserver {
constructor(callback: ResizeCallback) {
resizeCallbacks.push(callback);
}
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
}
globalThis.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
SETTINGS: 'Settings',
CLOSE_SETTINGS: 'Close settings',
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
PLAYBACK_SPEED: 'Playback speed',
AUDIO_TRACKS: 'Audio tracks',
ASPECT_RATIO: 'Aspect ratio',
ASPECT_DEFAULT: 'Default',
ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture',
ENTER_FULLSCREEN: 'Enter fullscreen',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
globalThis.ResizeObserver = originalResizeObserver;
});
describe('wide dock', () => {
beforeEach(withEverything);
it('shows subtitle and speed chips with their values and state colors', () => {
const subtitles = query(
'[data-test-id="player-controls-subtitle-chip"]'
);
const speed = query('[data-test-id="player-controls-speed-chip"]');
expect(subtitles?.textContent).toContain('Off');
expect(subtitles?.classList).not.toContain(
'player-controls__chip--on'
);
expect(speed?.textContent).toContain('1×');
expect(speed?.classList).not.toContain(
'player-controls__chip--modified'
);
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 1.25,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.textContent
).toContain('Russian');
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
?.classList
).toContain('player-controls__chip--on');
expect(
query('[data-test-id="player-controls-speed-chip"]')
?.textContent
).toContain('1.25×');
expect(
query('[data-test-id="player-controls-speed-chip"]')?.classList
).toContain('player-controls__chip--modified');
});
it('opens the panel on the chip group, shifts the dock and folds the chips away', () => {
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
query('[data-test-id="player-controls-speed-chip"]')?.click();
fixture.detectChanges();
const panel = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(panel).not.toBeNull();
expect(panel?.classList).not.toContain(
'player-controls__settings--sheet'
);
expect(panel?.getAttribute('role')).toBe('dialog');
expect(panel?.getAttribute('aria-label')).toBe('Settings');
expect(
query('[data-test-id="player-settings-speed"]')?.classList
).toContain('player-settings__group--focused');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--panel-open'
);
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(query('[aria-label="Enter picture-in-picture"]')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
?.classList
).toContain('player-controls__tune--active');
});
it('renders every available group in order and applies choices in place', () => {
component.settings.open();
fixture.detectChanges();
expect(
queryAll('.player-settings__group').map((group) =>
group.getAttribute('data-group')
)
).toEqual(['audio', 'subtitles', 'speed', 'aspect']);
queryAll(
'[data-test-id="player-settings-audio"] .player-settings__option'
)[1]?.click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
const preset = queryAll(
'[data-test-id="player-settings-aspect"] .player-settings__seg-item'
).find((item) => item.textContent?.includes('16:9'));
preset?.click();
expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9');
expect(component.settings.isOpen()).toBe(true);
});
it('closes from the panel button and from the tune button', () => {
component.settings.open();
fixture.detectChanges();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
it('toggles subtitles from the chip context menu without opening the panel', () => {
const chip = query(
'[data-test-id="player-controls-subtitle-chip"]'
) as HTMLElement;
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
chip.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(5);
expect(component.settings.isOpen()).toBe(false);
});
it('closes the panel once no group is left', () => {
component.settings.open('audio');
fixture.detectChanges();
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
fixture.detectChanges();
expect(component.settings.isOpen()).toBe(false);
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
});
});
describe('wide dock without room for extras', () => {
beforeEach(() => {
withEverything();
resizeTo(ROOMY_LAYOUT_MIN_WIDTH - 1);
});
it('stays wide but folds the chips into tune and opens a sheet', () => {
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--compact'
);
expect(query('.player-controls__slider--inline')).not.toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).not.toBeNull();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-panel"]')
?.classList
).toContain('player-controls__settings--sheet');
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--panel-open'
);
});
});
describe('compact dock', () => {
beforeEach(() => {
withEverything();
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
});
it('folds the chips into the tune button with state dots', () => {
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
setState({
audioTracks: fake.state().audioTracks,
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
subtitlesEnabled: true,
playbackSpeed: 2,
});
fixture.detectChanges();
const dots = query(
'[data-test-id="player-controls-settings-dots"]'
);
expect(
dots?.querySelector('.player-controls__tune-dot--cyan')
).not.toBeNull();
expect(
dots?.querySelector('.player-controls__tune-dot--violet')
).not.toBeNull();
});
it('makes the hidden dock inert and moves keyboard focus into the sheet and back', async () => {
const tune = query(
'[data-test-id="player-controls-settings-button"]'
) as HTMLButtonElement;
document.body.appendChild(fixture.nativeElement);
tune.focus();
// jsdom has no keyboard modality: report the focused tune button
// as :focus-visible, as a keyboard focus would be.
const matches = tune.matches.bind(tune);
Object.defineProperty(tune, 'matches', {
configurable: true,
value: (selector: string) =>
selector === ':focus-visible' || matches(selector),
});
tune.click();
fixture.detectChanges();
await fixture.whenStable();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
) as HTMLElement;
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
true
);
expect(document.activeElement).toBe(sheet);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe(
false
);
expect(document.activeElement).toBe(
query('[data-test-id="player-controls-settings-button"]')
);
fixture.nativeElement.remove();
});
it('leaves focus alone when a pointer opens the sheet', async () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).not.toBe(
query('[data-test-id="player-controls-settings-panel"]')
);
});
it('opens a bottom sheet that replaces the dock and keeps PiP reachable after close', () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
);
expect(sheet?.classList).toContain(
'player-controls__settings--sheet'
);
expect(
sheet?.querySelector('.player-settings__grip')
).not.toBeNull();
expect(query('.player-controls__bar')?.classList).toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[data-test-id="player-controls-settings-dots"]')
).toBeNull();
query('[data-test-id="player-settings-close"]')?.click();
fixture.detectChanges();
expect(query('.player-controls__bar')?.classList).not.toContain(
'player-controls__bar--sheet-open'
);
expect(
query('[aria-label="Enter picture-in-picture"]')
).not.toBeNull();
});
});
});
@@ -77,6 +77,7 @@ describe('PlayerControlsComponent', () => {
FORWARD_10_SECONDS: 'Forward 10 seconds',
LIVE_STREAM: 'Live stream',
PLAYBACK_POSITION: 'Playback position',
SETTINGS: 'Settings',
MUTE: 'Mute',
UNMUTE: 'Unmute',
VOLUME: 'Volume',
@@ -107,10 +108,15 @@ describe('PlayerControlsComponent', () => {
describe('capability gating', () => {
it('hides every optional control when no capability is enabled', () => {
expect(query('[aria-label="Back 10 seconds"]')).toBeNull();
expect(query('[aria-label="Audio tracks"]')).toBeNull();
expect(query('[aria-label="Subtitles"]')).toBeNull();
expect(query('[aria-label="Playback speed"]')).toBeNull();
expect(query('[aria-label="Aspect ratio"]')).toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-chip"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).toBeNull();
expect(query('.player-controls__record-button')).toBeNull();
expect(query('[aria-label="Enter fullscreen"]')).toBeNull();
expect(query('[aria-label="Mute"]')).toBeNull();
@@ -137,8 +143,16 @@ describe('PlayerControlsComponent', () => {
expect(query('.player-controls__record-button')).toBeNull();
});
it('shows the audio menu only with the capability and more than one track', () => {
it('shows the settings button only with the capability and more than one audio track', () => {
setCapabilities({ audioTracks: true });
setState({
audioTracks: [{ id: 1, label: 'English', selected: true }],
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-settings-button"]')
).toBeNull();
setState({
audioTracks: [
{ id: 1, label: 'English', selected: true },
@@ -146,14 +160,23 @@ describe('PlayerControlsComponent', () => {
],
});
fixture.detectChanges();
expect(query('[aria-label="Audio tracks"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).not.toBeNull();
expect(query('[aria-label="Settings"]')).not.toBeNull();
});
it('shows speed and aspect controls per their capabilities', () => {
it('shows the speed chip and the settings button per their capabilities', () => {
setCapabilities({ playbackSpeed: true, aspectRatio: true });
fixture.detectChanges();
expect(query('[aria-label="Playback speed"]')).not.toBeNull();
expect(query('[aria-label="Aspect ratio"]')).not.toBeNull();
expect(
query('[data-test-id="player-controls-speed-chip"]')
).not.toBeNull();
// Aspect ratio has no chip of its own: it lives in the panel.
expect(query('[aria-label="Aspect ratio"]')).toBeNull();
expect(
query('[data-test-id="player-controls-settings-button"]')
).not.toBeNull();
});
it('shows the fullscreen button when supported', () => {
@@ -201,10 +224,10 @@ describe('PlayerControlsComponent', () => {
});
fixture.detectChanges();
query('[aria-label="Audio tracks"]')?.click();
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll(
'.player-controls__track'
'[data-test-id="player-settings-audio"] .player-settings__option'
) as NodeListOf<HTMLElement>;
items[1].click();
expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2);
@@ -61,7 +61,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const openSubtitleMenu = () => {
fixture.componentInstance.toggleMenu('subtitle');
fixture.componentInstance.settings.open('subtitles');
fixture.detectChanges();
};
@@ -98,7 +98,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
fixture.detectChanges();
});
it('renders the subtitle button with zero tracks when external loading exists', () => {
it('renders the subtitle chip with zero tracks when external loading exists', () => {
expect(query('[aria-label="Subtitles"]')).toBeNull();
setCapabilities({ externalSubtitles: true });
@@ -107,17 +107,17 @@ describe('PlayerControlsComponent subtitle settings', () => {
expect(query('[aria-label="Subtitles"]')).not.toBeNull();
openSubtitleMenu();
// No track list entries: no Off row without a selectable track…
expect(query('.player-controls__track--selected')).toBeNull();
expect(query('.player-settings__option--selected')).toBeNull();
// …but the load action is present.
const load = query('[data-test-id="player-controls-load-subtitle"]');
expect(load).not.toBeNull();
load?.click();
expect(fake.commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1);
fixture.detectChanges();
// The pick closes the popover (a file dialog opens on top).
// The panel stays open: the loaded file appears as a track in it.
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
).not.toBeNull();
});
it('hides the load action, delay, and style sections without the capabilities', () => {
@@ -154,8 +154,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
);
expect(section).not.toBeNull();
expect(
section?.querySelector('.player-controls__subtitle-delay-value')
?.textContent
section?.querySelector('.player-settings__delay-value')?.textContent
).toContain('+0.5 s');
(
@@ -199,7 +198,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
) as HTMLElement;
const chips = Array.from(
style.querySelectorAll<HTMLButtonElement>(
'.player-controls__subtitle-chip'
'.player-settings__seg-item'
)
);
const largeChip = chips.find((chip) =>
@@ -222,7 +221,13 @@ describe('PlayerControlsComponent subtitle settings', () => {
it('guards the new commands behind their capabilities', () => {
const component = fixture.componentInstance;
component.loadExternalSubtitle();
// Without a subtitle group the panel cannot open, and without the
// capability the load action is not rendered inside it.
component.settings.open('subtitles');
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
component.subtitleSettings.adjustDelay(0.5);
component.subtitleSettings.resetDelay();
component.subtitleSettings.setSize(150);
@@ -205,7 +205,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
it('a click while a menu is open dismisses it instead of pausing', () => {
jest.useFakeTimers();
component.toggleMenu('speed');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
surface.dispatchEvent(new MouseEvent('click', { bubbles: true }));
@@ -216,7 +216,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
});
it('an outside pointerdown closes open menus', () => {
component.toggleMenu('audio');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
document.body.dispatchEvent(
@@ -230,7 +230,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
const popoverChild = document.createElement('button');
controlsRoot.appendChild(popoverChild);
document.body.appendChild(controlsRoot);
component.toggleMenu('audio');
component.toggleMenu('settings');
popoverChild.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
@@ -350,7 +350,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
);
it('Escape closes an open menu', () => {
component.toggleMenu('subtitle');
component.toggleMenu('settings');
expect(component.anyMenuOpen()).toBe(true);
pressKey('Escape');
@@ -56,7 +56,7 @@ describe('PlayerControlsComponent timeline scrubbing', () => {
const currentTimeText = () =>
(
fixture.nativeElement.querySelector(
'.player-controls__time > span:first-child'
'.player-controls__time--current'
) as HTMLElement
).textContent;
@@ -17,34 +17,45 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ControlsChromeInteractions } from './controls-chrome-interactions';
import { ControlsFeedback } from './controls-feedback';
import { ControlsFullscreen } from './controls-fullscreen';
import { ControlsLayout } from './controls-layout';
import { ControlsMenuSelection } from './controls-menu-selection';
import { type ControlsMenu, ControlsMenuState } from './controls-menu-state';
import { ControlsSettings } from './controls-settings';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
import { ControlsTimelineHover } from './controls-timeline-hover';
import { ControlsUpNext } from './controls-up-next';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { ControlsVolumeInteractions } from './controls-volume-interactions';
import { ControlsSubtitleSettings } from './controls-subtitle-settings';
import { formatTime, speedLabel } from './controls-format.utils';
import { formatRemainingTime } from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
PlayerTimelineSegment,
PlayerUpNextItem,
} from './player-controls.model';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
import { PlayerSettingsPanelComponent } from './player-settings-panel.component';
import { PlayerTimelineComponent } from './player-timeline.component';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
@Component({
selector: 'app-player-controls',
templateUrl: './player-controls.component.html',
styleUrl: './player-controls.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
imports: [
MatButtonModule,
MatIconModule,
MatTooltipModule,
TranslatePipe,
PlayerSettingsPanelComponent,
PlayerTimelineComponent,
PlayerUpNextCardComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-controls-host',
@@ -68,12 +79,21 @@ export class PlayerControlsComponent implements OnDestroy {
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Chapters / programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** The next episode, for the "Up next" card near the end of this one. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly menus = new ControlsMenuState();
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
private readonly shortcuts = new ControlsShortcuts();
readonly layout = new ControlsLayout();
/** Compact dock: narrow inline players and phone-sized viewports. */
readonly isCompact = computed(() => this.layout.mode() === 'compact');
private readonly visibility = new ControlsVisibility(() => this.canHide());
private readonly fullscreen = new ControlsFullscreen(
() => this.fullscreenTarget() ?? this.playerSurface(),
@@ -105,8 +125,6 @@ export class PlayerControlsComponent implements OnDestroy {
});
readonly menuSelection = new ControlsMenuSelection({
commands: () => this.controller().commands,
menus: this.menus,
visibility: this.visibility,
revealSticky: () => this.reveal({ scheduleHide: false }),
});
readonly volumeInteractions = new ControlsVolumeInteractions({
@@ -128,12 +146,35 @@ export class PlayerControlsComponent implements OnDestroy {
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
readonly settings = new ControlsSettings({
state: this.state,
capabilities: this.capabilities,
showControls: this.showControls,
menus: this.menus,
commands: () => this.controller().commands,
reveal: (options) => this.reveal(options),
});
readonly upNextCard = new ControlsUpNext({
item: this.upNext,
state: this.state,
capabilities: this.capabilities,
showControls: this.showControls,
settingsOpen: this.settings.isOpen,
});
private readonly controllerVolume = computed(() => this.state().volume);
private readonly timeline = new ControlsTimeline(this.state);
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
readonly scrubPosition = this.timeline.scrubPosition;
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
readonly timelineHover = new ControlsTimelineHover({
duration: this.timeline.duration,
interactive: computed(
() => this.capabilities().seek && this.state().canSeek
),
segments: this.timeline.segments,
});
/** `−7:03` while a finite duration is known; the dock prefers it to the total. */
readonly remainingTimeText = computed(() =>
formatRemainingTime(this.timeline.value(), this.timeline.duration())
);
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
@@ -175,9 +216,6 @@ export class PlayerControlsComponent implements OnDestroy {
readonly isPaused = this.vm.isPaused;
readonly isPlaying = this.vm.isPlaying;
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;
@@ -189,6 +227,7 @@ export class PlayerControlsComponent implements OnDestroy {
readonly controlsAreVisible = this.vm.controlsAreVisible;
readonly hideCursor = this.vm.hideCursor;
constructor() {
this.layout.attach(this.host);
this.shortcuts.attach({
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
hostElement: () => this.host,
@@ -282,6 +321,7 @@ export class PlayerControlsComponent implements OnDestroy {
});
}
ngOnDestroy(): void {
this.layout.dispose();
this.shortcuts.detach();
this.feedback.dispose();
this.visibility.dispose();
@@ -290,12 +330,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.surface.dispose();
this.streamStats.dispose();
}
formatTime = formatTime;
speedLabel = speedLabel;
subtitleDelayLabel = subtitleDelayLabel;
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
togglePlay(): void {
this.reveal();
if (!this.canTogglePlay()) {
@@ -350,12 +384,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.reveal();
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.menuSelection.externalSubtitle();
}
toggleRecording(): void {
if (!this.canRecord()) {
return;
@@ -3,12 +3,7 @@ import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces';
import type { PlayerStreamStatsSource } from './player-stream-stats.model';
export type PlayerStatus =
| 'idle'
| 'loading'
| 'playing'
| 'paused'
| 'ended'
| 'error';
'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error';
/** Which controls an engine supports. A control is only rendered when its flag is true. */
export interface PlayerControlsCapabilities {
@@ -62,6 +57,31 @@ export interface PlayerMediaTitle {
secondary?: string | null;
}
/**
* One stretch of the timeline the track draws as its own segment — a chapter
* of a film, a programme of a catch-up recording. Hosts pass display-ready
* titles; the controls clamp, order and gap-fill the list themselves.
*/
export interface PlayerTimelineSegment {
startSeconds: number;
endSeconds: number;
/** Shown in the hover label as `Title · 12:40`; null for a plain gap. */
title: string | null;
}
/**
* The episode after the current one, for the "Up next" card. Series hosts
* build it from their episode list; the controls decide when to show it.
*/
export interface PlayerUpNextItem {
/** `S01E03`. */
label: string;
title: string;
thumbnailUrl: string | null;
/** Watch progress in percent (0–100); null when never started. */
progressPercent: number | null;
}
export interface PlayerPreset<T> {
value: T;
label: string;
@@ -0,0 +1,439 @@
@if (mode() === 'sheet') {
<div class="player-settings__grip" aria-hidden="true"></div>
}
<div class="player-settings__header">
<div class="player-settings__title">{{ title() }}</div>
<button
mat-icon-button
type="button"
class="player-settings__close"
data-test-id="player-settings-close"
(click)="closeRequested.emit()"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
[matTooltip]="'EMBEDDED_MPV.PLAYER.CLOSE_SETTINGS' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
<div class="player-settings__body">
@if (groups().audio) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('audio')"
data-group="audio"
data-test-id="player-settings-audio"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
"
>
@for (track of state().audioTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().audioTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().subtitles) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('subtitles')"
data-group="subtitles"
data-test-id="player-settings-subtitles"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--cyan"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate"
>
@if (
capabilities().subtitles &&
state().subtitleTracks.length > 0
) {
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[class.player-settings__option--selected]="
!state().subtitlesEnabled
"
[attr.aria-checked]="!state().subtitlesEnabled"
(click)="selection().subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (track of state().subtitleTracks; track track.id) {
<button
type="button"
role="menuitemradio"
class="player-settings__option"
[class.player-settings__option--selected]="
track.selected
"
[attr.aria-checked]="track.selected"
(click)="selection().subtitleTrack(track.id)"
>
<span>{{ track.label }}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
@if (capabilities().externalSubtitles) {
<button
type="button"
role="menuitem"
class="player-settings__option player-settings__option--action"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
</div>
@if (capabilities().subtitleDelay) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-delay"
>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
</div>
<div class="player-settings__delay-row">
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
-subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
>
<mat-icon>remove</mat-icon>
</button>
<span
class="player-settings__delay-value"
aria-live="polite"
>{{
subtitleDelayLabel(state().subtitleDelaySeconds)
}}</span
>
<button
mat-icon-button
type="button"
(click)="
subtitleSettings().adjustDelay(
subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="state().subtitleDelaySeconds === 0"
(click)="subtitleSettings().resetDelay()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
>
<mat-icon>restart_alt</mat-icon>
</button>
</div>
</div>
}
@if (capabilities().subtitleStyle) {
<div
class="player-settings__subsection"
data-test-id="player-controls-subtitle-style"
>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate
"
>
@for (
preset of subtitleSizePresets;
track preset.value
) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--selected]="
preset.value ===
state().subtitleStyle.sizePercent
"
[attr.aria-checked]="
preset.value ===
state().subtitleStyle.sizePercent
"
(click)="
subtitleSettings().setSize(preset.value)
"
>
{{ preset.label }}
</button>
}
</div>
<div class="player-settings__subheading">
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
</div>
<div
class="player-settings__swatches"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate
"
>
@for (
preset of subtitleColorPresets;
track preset.label
) {
<button
type="button"
role="radio"
class="player-settings__swatch"
[class.player-settings__swatch--default]="
preset.value === null
"
[class.player-settings__swatch--selected]="
preset.value === state().subtitleStyle.color
"
[style.--swatch-color]="preset.value"
[attr.aria-checked]="
preset.value === state().subtitleStyle.color
"
[attr.aria-label]="preset.label | translate"
[matTooltip]="preset.label | translate"
(click)="
subtitleSettings().setColor(preset.value)
"
></button>
}
</div>
</div>
}
</section>
}
@if (groups().quality) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('quality')"
data-group="quality"
data-test-id="player-settings-quality"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
</div>
<div
class="player-settings__options"
role="group"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
>
<button
type="button"
role="menuitemradio"
class="player-settings__option player-settings__option--default"
[class.player-settings__option--selected]="
state().qualityAutoEnabled
"
[attr.aria-checked]="state().qualityAutoEnabled"
(click)="selection().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"
class="player-settings__option"
[class.player-settings__option--selected]="
level.selected
"
[attr.aria-checked]="level.selected"
(click)="selection().qualityLevel(level.id)"
>
<span>{{ level.label }}</span>
@if (level.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</section>
}
@if (groups().speed) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('speed')"
data-group="speed"
data-test-id="player-settings-speed"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--violet"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
"
>
@for (preset of state().speedPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--default]="
preset.value === 1
"
[class.player-settings__seg-item--selected]="
preset.value === state().playbackSpeed
"
[attr.aria-checked]="
preset.value === state().playbackSpeed
"
(click)="selection().speed(preset.value)"
>
{{ preset.label }}
</button>
}
</div>
</section>
}
@if (groups().aspect) {
<section
class="player-settings__group"
[class.player-settings__group--focused]="isFocused('aspect')"
data-group="aspect"
data-test-id="player-settings-aspect"
>
<div class="player-settings__heading">
<span
class="player-settings__dot player-settings__dot--neutral"
aria-hidden="true"
></span>
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
</div>
<div
class="player-settings__seg"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
"
>
@for (preset of state().aspectPresets; track preset.value) {
<button
type="button"
role="radio"
class="player-settings__seg-item"
[class.player-settings__seg-item--default]="
preset.value === defaultAspect()
"
[class.player-settings__seg-item--selected]="
preset.value === state().aspectRatio
"
[attr.aria-checked]="
preset.value === state().aspectRatio
"
(click)="selection().aspect(preset.value)"
>
{{
preset.value === 'no'
? ('EMBEDDED_MPV.PLAYER.ASPECT_DEFAULT'
| translate)
: preset.label
}}
</button>
}
</div>
</section>
}
</div>
@@ -0,0 +1,448 @@
// The settings panel / bottom sheet. Reads the `--pc-*` palette that the
// controls host declares and places itself inside that host (the
// `player-controls` container): beside the video as a panel, or rising from
// the bottom edge as a sheet.
:host {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
z-index: 4;
width: 330px;
max-width: calc(100cqw - 32px);
animation: player-settings-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex;
flex-direction: column;
gap: 18px;
box-sizing: border-box;
padding: 18px 16px;
color: var(--pc-text, #e7ecf3);
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
border-radius: 20px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px) saturate(1.3);
overflow: hidden;
pointer-events: auto;
}
:host(:focus) {
outline: none;
}
:host(:focus-visible) {
box-shadow:
0 0 0 2px var(--pc-text, #e7ecf3),
0 12px 32px rgba(0, 0, 0, 0.45);
}
:host(.player-settings--sheet) {
top: auto;
right: 8px;
bottom: 0;
left: 8px;
width: auto;
max-width: none;
max-height: min(70%, calc(100cqh - 24px));
animation-name: player-settings-sheet-in;
gap: 9px;
padding: 10px 14px 12px;
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
.player-settings__grip {
flex: 0 0 auto;
align-self: center;
width: 36px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
}
.player-settings__header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.player-settings__title {
overflow: hidden;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
:host(.player-settings--sheet) .player-settings__title {
font-size: 13px;
}
:host :is(button[mat-icon-button]) {
width: 32px;
height: 32px;
padding: 4px;
color: var(--pc-text, #e7ecf3);
--mat-icon-button-icon-color: var(--pc-text, #e7ecf3);
--mat-icon-button-state-layer-size: 32px;
--mat-icon-button-container-shape: 10px;
--mat-icon-button-icon-size: 20px;
--mat-icon-button-hover-state-layer-opacity: 0;
border-radius: 10px;
}
:host :is(button[mat-icon-button]) mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
color: var(--pc-text, #e7ecf3);
}
:host :is(button[mat-icon-button]:hover:not([disabled])) {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
:host :is(button[mat-icon-button][disabled]),
:host :is(button[mat-icon-button][disabled]) mat-icon {
color: var(--pc-text-tertiary, #6b7384);
--mat-icon-button-icon-color: var(--pc-text-tertiary, #6b7384);
}
:host(.player-settings--sheet) .player-settings__close {
width: 26px;
height: 26px;
padding: 3px;
--mat-icon-button-state-layer-size: 26px;
}
// Single scroll owner: on a short player the whole content scrolls, never
// one group inside another.
.player-settings__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
:host(.player-settings--sheet) .player-settings__body {
gap: 9px;
}
.player-settings__group {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
border-radius: 12px;
transition: box-shadow 220ms ease-out;
}
// The group a chip opened the panel for: a brief ring, no layout change.
.player-settings__group--focused {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.player-settings__heading {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.player-settings__dot--cyan {
background: var(--pc-accent-cyan, #5cd6ff);
}
.player-settings__dot--violet {
background: var(--pc-accent-violet, #b599ff);
}
.player-settings__dot--neutral {
background: rgba(255, 255, 255, 0.25);
}
.player-settings__options {
display: flex;
flex-direction: column;
gap: 2px;
}
.player-settings__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 36px;
padding: 0 12px;
color: var(--pc-text, #e7ecf3);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
font-size: 14px;
text-align: left;
}
.player-settings__option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-settings__option mat-icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
font-size: 18px;
}
// "Off" / "Auto" rows read as the neutral choice until selected.
.player-settings__option--default {
color: var(--pc-text-secondary, #9aa3b2);
}
.player-settings__option--action {
color: var(--pc-text-secondary, #9aa3b2);
}
.player-settings__option:hover,
.player-settings__option:focus-visible {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
.player-settings__option--selected {
color: var(--pc-accent-cyan, #5cd6ff);
background: rgba(92, 214, 255, 0.14);
font-weight: 600;
}
.player-settings__option:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: -2px;
}
.player-settings__seg {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
.player-settings__seg-item {
flex: 1 1 auto;
height: 30px;
padding: 0 8px;
color: var(--pc-text-secondary, #9aa3b2);
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.player-settings__seg-item--default {
color: var(--pc-text, #e7ecf3);
}
.player-settings__seg-item:hover,
.player-settings__seg-item:focus-visible {
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
}
.player-settings__seg-item--selected {
color: var(--pc-accent-violet, #b599ff);
background: rgba(181, 153, 255, 0.2);
font-weight: 650;
}
// The default value selected is "nothing changed": neutral, not violet.
.player-settings__seg-item--default.player-settings__seg-item--selected {
color: var(--pc-text, #e7ecf3);
background: rgba(255, 255, 255, 0.1);
}
.player-settings__seg-item:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: -2px;
}
.player-settings__subsection {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.player-settings__subheading {
padding: 0 4px;
color: var(--pc-text-tertiary, #6b7384);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-settings__delay-row {
display: flex;
align-items: center;
gap: 4px;
}
.player-settings__delay-value {
min-width: 58px;
color: var(--pc-text, #e7ecf3);
font-variant-numeric: tabular-nums;
text-align: center;
}
.player-settings__swatches {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 2px 4px 4px;
}
.player-settings__swatch {
width: 26px;
height: 26px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
cursor: pointer;
background: var(--swatch-color, transparent);
}
// The "engine default" swatch has no color of its own: render a diagonal
// slash so it reads as "no override".
.player-settings__swatch--default {
background:
linear-gradient(
135deg,
transparent 44%,
rgba(255, 255, 255, 0.7) 46%,
rgba(255, 255, 255, 0.7) 54%,
transparent 56%
),
rgba(255, 255, 255, 0.08);
}
.player-settings__swatch:hover,
.player-settings__swatch:focus-visible,
.player-settings__swatch--selected {
border-color: #ffffff;
}
// Sheet density: two-column rows (heading | control) so five groups fit a
// phone-height player without scrolling in the common case.
:host(.player-settings--sheet) .player-settings__group {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 4px 10px;
align-items: start;
}
// The heading column is narrow: let "Playback speed" wrap onto two lines
// instead of running under the control.
:host(.player-settings--sheet) .player-settings__heading {
align-items: flex-start;
min-width: 0;
padding: 6px 0 0;
font-size: 10px;
line-height: 1.25;
white-space: normal;
}
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
flex: 0 0 auto;
margin-top: 4px;
}
:host(.player-settings--sheet) .player-settings__dot {
width: 5px;
height: 5px;
}
:host(.player-settings--sheet) .player-settings__options {
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
:host(.player-settings--sheet) .player-settings__option {
flex: 1 1 auto;
justify-content: center;
height: 24px;
padding: 0 8px;
border-radius: 8px;
font-size: 11.5px;
}
:host(.player-settings--sheet) .player-settings__option mat-icon {
display: none;
}
:host(.player-settings--sheet) .player-settings__seg-item {
height: 24px;
font-size: 11.5px;
}
:host(.player-settings--sheet) .player-settings__subsection {
grid-column: 1 / -1;
margin-top: 2px;
padding-top: 6px;
}
@keyframes player-settings-panel-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes player-settings-sheet-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:host {
animation: none;
}
}
@@ -0,0 +1,162 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
afterNextRender,
computed,
effect,
inject,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type { ControlsMenuSelection } from './controls-menu-selection';
import type { ControlsSettings } from './controls-settings';
import type { SettingsGroup } from './controls-settings-groups';
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
import type { PlayerController } from './player-controls.model';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
/**
* The settings surface behind the dock's `tune` button: audio, subtitles
* (tracks, file loading, delay, size and color), quality, speed and aspect
* ratio in one place. Wide players show it as a right-hand panel beside the
* video, compact players as a bottom sheet. It only renders state and
* forwards choices to the collaborators the controls component owns.
*/
@Component({
selector: 'app-player-settings-panel',
templateUrl: './player-settings-panel.component.html',
styleUrl: './player-settings-panel.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-settings',
role: 'dialog',
tabindex: '-1',
'[class.player-settings--sheet]': 'mode() === "sheet"',
'[attr.aria-label]': 'title()',
'(focusin)': 'focusInside = true',
'(focusout)': 'onFocusOut($event)',
},
})
export class PlayerSettingsPanelComponent implements OnDestroy {
private readonly host: HTMLElement =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
readonly controller = input.required<PlayerController>();
readonly settings = input.required<ControlsSettings>();
readonly selection = input.required<ControlsMenuSelection>();
readonly subtitleSettings = input.required<ControlsSubtitleSettings>();
readonly mode = input<PlayerSettingsPanelMode>('panel');
readonly title = input('');
readonly closeRequested = output<void>();
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
readonly groups = computed(() => this.settings().groups());
readonly focusGroup = computed(() => this.settings().focusGroup());
readonly defaultAspect = computed(() => this.settings().defaultAspect());
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
readonly subtitleDelayLabel = subtitleDelayLabel;
/** The control that opened the panel, and whether the keyboard did. */
private readonly opener = document.activeElement;
private readonly openedByKeyboard =
this.opener instanceof HTMLElement &&
this.opener !== document.body &&
!!this.opener.closest('.player-controls-host') &&
matchesFocusVisible(this.opener);
focusInside = false;
/** Recorded after render: on destroy the panel is already detached. */
private controlsHost: Element | null = null;
constructor() {
// Keyboard users land inside the dialog; a pointer open leaves focus
// alone (a focused control would capture Space from the shortcuts).
afterNextRender(() => {
this.controlsHost = this.host.closest('.player-controls-host');
if (this.openedByKeyboard) {
this.host.focus({ preventScroll: true });
this.focusInside = this.host.contains(document.activeElement);
}
});
// A chip click opens the panel on its group: bring that group into
// view so a long audio list cannot push the speed row off-screen.
effect(() => {
const group = this.focusGroup();
if (!group) {
return;
}
queueMicrotask(() => this.scrollGroupIntoView(group));
});
}
onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
this.focusInside = next instanceof Node && this.host.contains(next);
}
/**
* Closing with focus inside (Escape, the close button by keyboard)
* returns it to the `tune` button rather than dropping it on the page.
* The chip that may have opened the panel is re-rendered on close, so
* `tune` is the stable target. Tracked by flag, not `activeElement`:
* the view's DOM is already detached when this hook runs.
*/
ngOnDestroy(): void {
if (!this.focusInside) {
return;
}
const controls = this.controlsHost;
queueMicrotask(() =>
controls
?.querySelector<HTMLElement>('.player-controls__tune')
?.focus({ preventScroll: true })
);
}
isFocused(group: SettingsGroup): boolean {
return this.focusGroup() === group;
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.selection().externalSubtitle();
}
private scrollGroupIntoView(group: SettingsGroup): void {
const element = this.host.querySelector(`[data-group="${group}"]`);
if (
element instanceof HTMLElement &&
typeof element.scrollIntoView === 'function'
) {
element.scrollIntoView({ block: 'nearest' });
}
}
}
function matchesFocusVisible(element: Element | null): boolean {
try {
return (
element instanceof HTMLElement && element.matches(':focus-visible')
);
} catch {
return false;
}
}
@@ -0,0 +1,98 @@
<span class="player-controls__time player-controls__time--current">{{
formatTime(timeline().value())
}}</span>
@if (seekable()) {
<div
class="player-controls__timeline-bar"
[class.player-controls__timeline-bar--disabled]="!canSeek()"
(pointermove)="hover().move($event)"
(pointerleave)="hover().clear()"
>
<div class="player-controls__timeline-track" aria-hidden="true">
@for (segment of timeline().segments(); track $index) {
<div
class="player-controls__timeline-segment"
[class.player-controls__timeline-segment--titled]="
segment.title !== null
"
[style.left.%]="segment.startPercent"
[style.width]="segment.width"
[attr.data-segment-title]="segment.title"
>
<div
class="player-controls__timeline-fill"
[style.width.%]="timeline().fillPercent(segment)"
></div>
</div>
}
</div>
@if (canSeek()) {
<div
class="player-controls__timeline-knob"
[style.left.%]="timeline().progress()"
aria-hidden="true"
></div>
}
@if (hover().percent() !== null) {
<div
class="player-controls__timeline-marker"
[style.left.%]="hover().percent()"
aria-hidden="true"
></div>
<div
class="player-controls__timeline-label"
[style.--hover-x.%]="hover().percent()"
data-test-id="player-controls-timeline-label"
aria-hidden="true"
>
{{ hover().label() }}
</div>
}
<input
class="player-controls__slider player-controls__slider--timeline"
[style.--slider-progress.%]="timeline().progress()"
type="range"
min="0"
[max]="timeline().duration()"
[value]="timeline().value()"
[disabled]="!canSeek()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
"
[attr.aria-valuetext]="formatTime(timeline().value())"
(input)="scrubInput.emit($event)"
(change)="scrubCommit.emit($event)"
/>
</div>
} @else {
<div class="player-controls__timeline-spacer"></div>
}
@if (canSeek() && remaining(); as remaining) {
<span
class="player-controls__time player-controls__time--end"
data-test-id="player-controls-remaining-time"
>{{ remaining }}</span
>
} @else if (isLive()) {
<span
class="player-controls__live-badge"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate"
>
<span class="player-controls__live-dot" aria-hidden="true"></span>
LIVE
</span>
} @else {
<span class="player-controls__time player-controls__time--end">--:--</span>
}
@if (recordingStatus(); as status) {
<div
class="player-controls__recording-status"
[class.player-controls__recording-status--active]="recording()"
[attr.aria-live]="recording() ? 'off' : 'polite'"
>
<mat-icon>{{
recording() ? 'fiber_manual_record' : 'check_circle'
}}</mat-icon>
<span>{{ status }}</span>
</div>
}
@@ -0,0 +1,233 @@
// The dock's timeline row. Reads the `--pc-*` palette the controls host
// declares; the host is the `player-controls` size container queried below.
:host {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.player-controls__time {
flex: 0 0 auto;
color: var(--pc-text);
font-family: var(--pc-font-mono);
font-size: 14px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player-controls__time--end {
color: var(--pc-text-secondary);
}
.player-controls__timeline-spacer {
flex: 1 1 auto;
}
// Pointer target of the bar. Taller than the 6px track so it is easy to hit;
// the transparent range input on top owns dragging and the keyboard.
.player-controls__timeline-bar {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 18px;
cursor: pointer;
}
.player-controls__timeline-bar--disabled {
cursor: default;
}
.player-controls__timeline-track {
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 6px;
transform: translateY(-50%);
}
// Segments sit at their exact time positions (`left` = start, `width` =
// share, minus the 3px gap at each segment's end), so a drawn boundary is
// where the linear seek input and the hover label change segment. A segment
// shorter than the gap simply collapses rather than shifting its neighbours.
.player-controls__timeline-segment {
position: absolute;
top: 0;
bottom: 0;
overflow: hidden;
border-radius: 3px;
background: var(--pc-track);
}
.player-controls__timeline-fill {
height: 100%;
background: var(--pc-accent-blue);
border-radius: inherit;
}
.player-controls__timeline-knob {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
pointer-events: none;
transition: transform 120ms ease-out;
}
.player-controls__timeline-bar:hover .player-controls__timeline-knob,
.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
.player-controls__timeline-knob {
transform: scale(1.15);
}
.player-controls__timeline-marker {
position: absolute;
top: 50%;
width: 2px;
height: 12px;
margin: -6px 0 0 -1px;
border-radius: 1px;
background: #ffffff;
pointer-events: none;
}
.player-controls__timeline-label {
--hover-x: 0%;
position: absolute;
bottom: calc(100% + 2px);
// Centered on the pointer, but never past the bar's ends.
left: clamp(28px, var(--hover-x), calc(100% - 28px));
transform: translateX(-50%);
padding: 5px 8px;
color: var(--pc-text);
background: rgba(12, 16, 23, 0.85);
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.player-controls__recording-status {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--pc-text-dim);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.player-controls__recording-status mat-icon {
flex: 0 0 auto;
width: 16px;
height: 16px;
font-size: 16px;
color: #ffffff;
}
.player-controls__recording-status span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__recording-status--active {
color: var(--mat-sys-error, #ff5252);
font-weight: 700;
}
.player-controls__recording-status--active mat-icon {
color: var(--mat-sys-error, #ff5252);
}
.player-controls__live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 4px;
color: #ffffff;
background: var(--mat-sys-error, #e53935);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.player-controls__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: player-controls-live-pulse 1.6s ease-in-out infinite;
}
@keyframes player-controls-live-pulse {
50% {
opacity: 0.35;
}
}
// The range input is the interaction and accessibility layer over the drawn
// track: invisible and full-size. Its focus ring is drawn on the track
// instead, because an outline on a transparent element is invisible.
.player-controls__slider--timeline {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.player-controls__slider--timeline:disabled {
cursor: default;
}
.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
.player-controls__timeline-track {
outline: 2px solid var(--pc-text);
outline-offset: 5px;
border-radius: 4px;
}
// Touch scrubbing needs a taller hit strip; the 6px track is unchanged.
@media (pointer: coarse) {
.player-controls__timeline-bar {
height: 28px;
}
}
// Keep in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts.
@container player-controls (max-width: 719px) {
:host {
gap: 10px;
}
.player-controls__time {
font-size: 13px;
}
.player-controls__timeline-track {
height: 5px;
}
.player-controls__timeline-knob {
width: 11px;
height: 11px;
margin: -5.5px 0 0 -5.5px;
box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
}
}
@@ -0,0 +1,44 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { formatTime } from './controls-format.utils';
import type { ControlsTimeline } from './controls-timeline';
import type { ControlsTimelineHover } from './controls-timeline-hover';
/**
* The dock's timeline row: current time, the drawn segment track with its
* knob and hover label, the remaining time (or LIVE / `--:--`), and the
* recording status. Presentation only — scrub state lives in
* {@link ControlsTimeline}, and the range input's `input`/`change` events
* go back to the controls component, which owns reveal and seeking.
*/
@Component({
selector: 'app-player-timeline',
templateUrl: './player-timeline.component.html',
styleUrl: './player-timeline.component.scss',
imports: [MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'player-controls__timeline' },
})
export class PlayerTimelineComponent {
readonly timeline = input.required<ControlsTimeline>();
readonly hover = input.required<ControlsTimelineHover>();
/** The engine can seek at all; without it the track is not drawn. */
readonly seekable = input(false);
/** Seeking is possible right now. */
readonly canSeek = input(false);
readonly isLive = input(false);
/** `−7:03`, or null without a finite duration. */
readonly remaining = input<string | null>(null);
readonly recordingStatus = input<string | null>(null);
readonly recording = input(false);
readonly scrubInput = output<Event>();
readonly scrubCommit = output<Event>();
readonly formatTime = formatTime;
}
@@ -0,0 +1,52 @@
<button
type="button"
class="player-up-next__button"
data-test-id="player-controls-up-next"
(click)="selected.emit()"
[attr.aria-label]="
('EMBEDDED_MPV.PLAYER.UP_NEXT' | translate) +
': ' +
item().label +
(item().title ? ' – ' + item().title : '')
"
>
<span class="player-up-next__thumb" aria-hidden="true">
@if (thumbnail(); as src) {
<img
class="player-up-next__image"
[src]="src"
alt=""
loading="lazy"
(error)="onThumbnailError()"
/>
} @else {
<span class="player-up-next__placeholder">{{ item().label }}</span>
}
@if (item().progressPercent !== null) {
<span class="player-up-next__progress">
<span
class="player-up-next__progress-bar"
[style.width.%]="item().progressPercent"
></span>
</span>
}
</span>
<span class="player-up-next__meta">
<span class="player-up-next__eyebrow">
{{ 'EMBEDDED_MPV.PLAYER.UP_NEXT' | translate }}
·
{{
'EMBEDDED_MPV.PLAYER.UP_NEXT_IN'
| translate: { minutes: minutesLeft() }
}}
</span>
<span class="player-up-next__title">
{{ item().title || item().label }}
</span>
</span>
@if (!compact()) {
<mat-icon class="player-up-next__icon" aria-hidden="true"
>skip_next</mat-icon
>
}
</button>
@@ -0,0 +1,161 @@
// Glass card, bottom-right above the dock, inside the controls host (the
// `player-controls` container) whose `--pc-*` palette it reads. It does not
// fade with the controls: near the end of an episode it is worth seeing.
:host {
position: absolute;
right: 32px;
bottom: 136px;
z-index: 3;
display: block;
max-width: calc(100cqw - 64px);
pointer-events: auto;
animation: player-up-next-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
:host(.player-up-next--compact) {
right: 12px;
bottom: 92px;
max-width: calc(100cqw - 24px);
}
@keyframes player-up-next-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
:host {
animation: none;
}
}
.player-up-next__button {
display: flex;
align-items: center;
gap: 12px;
max-width: 320px;
padding: 8px 14px 8px 8px;
color: var(--pc-text, #e7ecf3);
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px) saturate(1.3);
cursor: pointer;
font: inherit;
text-align: left;
transition:
background 120ms ease-out,
transform 120ms ease-out;
}
.player-up-next__button:hover {
background: rgba(12, 16, 23, 0.84);
transform: translateY(-1px);
}
.player-up-next__button:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: 2px;
}
.player-up-next__thumb {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 88px;
height: 50px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.player-up-next__image {
width: 100%;
height: 100%;
object-fit: cover;
}
// No still: the episode label as a large numeral tile, so the card still
// looks designed without artwork.
.player-up-next__placeholder {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.player-up-next__progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255, 255, 255, 0.18);
}
.player-up-next__progress-bar {
display: block;
height: 100%;
background: var(--pc-accent-blue, #4f8eff);
}
.player-up-next__meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.player-up-next__eyebrow {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.player-up-next__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-up-next__icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
font-size: 22px;
color: var(--pc-text-secondary, #9aa3b2);
}
:host(.player-up-next--compact) .player-up-next__button {
gap: 10px;
max-width: 240px;
padding: 5px 10px 5px 5px;
border-radius: 10px;
}
:host(.player-up-next--compact) .player-up-next__thumb {
width: 56px;
height: 32px;
border-radius: 6px;
}
:host(.player-up-next--compact) .player-up-next__eyebrow {
font-size: 9px;
}
:host(.player-up-next--compact) .player-up-next__title {
font-size: 11.5px;
}
@@ -0,0 +1,109 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
describe('PlayerUpNextCardComponent', () => {
let fixture: ComponentFixture<PlayerUpNextCardComponent>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerUpNextCardComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
UP_NEXT: 'Up next',
UP_NEXT_IN: 'in {{minutes}} min',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlayerUpNextCardComponent);
fixture.componentRef.setInput('item', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: 'https://img.example/still.jpg',
progressPercent: 40,
});
fixture.componentRef.setInput('minutesLeft', 7);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
it('renders the still, progress, countdown and title, and names itself', () => {
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
'https://img.example/still.jpg'
);
expect(query('.player-up-next__progress-bar')?.style.width).toBe('40%');
expect(
query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ')
).toContain('Up next · in 7 min');
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'The Third One'
);
expect(
query('[data-test-id="player-controls-up-next"]')?.getAttribute(
'aria-label'
)
).toBe('Up next: S01E03 – The Third One');
expect(query('.player-up-next__icon')).not.toBeNull();
});
it('falls back to the label tile without a still and hides the icon when compact', () => {
fixture.componentRef.setInput('item', {
label: 'S02E01',
title: '',
thumbnailUrl: null,
progressPercent: null,
});
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
expect(query('.player-up-next__image')).toBeNull();
expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__progress')).toBeNull();
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__icon')).toBeNull();
expect(fixture.nativeElement.classList).toContain(
'player-up-next--compact'
);
});
it('falls back to the label tile when the still fails, and retries a new URL', () => {
query('.player-up-next__image')?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(query('.player-up-next__image')).toBeNull();
expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe(
'S01E03'
);
fixture.componentRef.setInput('item', {
label: 'S01E04',
title: 'Four',
thumbnailUrl: 'https://img.example/other.jpg',
progressPercent: null,
});
fixture.detectChanges();
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
'https://img.example/other.jpg'
);
});
it('emits on click', () => {
const selected = jest.fn();
fixture.componentInstance.selected.subscribe(selected);
query('[data-test-id="player-controls-up-next"]')?.click();
expect(selected).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,45 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { PlayerUpNextItem } from './player-controls.model';
/**
* The "Up next" card in the player's bottom-right corner: thumbnail, the
* minutes left, the next episode's label and title. One click plays it
* through the host's ordinary next-episode path, so fullscreen survives.
*/
@Component({
selector: 'app-player-up-next-card',
templateUrl: './player-up-next-card.component.html',
styleUrl: './player-up-next-card.component.scss',
imports: [MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-up-next',
'[class.player-up-next--compact]': 'compact()',
},
})
export class PlayerUpNextCardComponent {
readonly item = input.required<PlayerUpNextItem>();
readonly minutesLeft = input.required<number>();
readonly compact = input(false);
readonly selected = output<void>();
/** The still that failed to load; a new URL gets its own attempt. */
private readonly failedThumbnail = signal<string | null>(null);
readonly thumbnail = computed(() => {
const url = this.item().thumbnailUrl;
return url && url !== this.failedThumbnail() ? url : null;
});
onThumbnailError(): void {
this.failedThumbnail.set(this.item().thumbnailUrl);
}
}
@@ -24,6 +24,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -22,6 +22,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -115,6 +116,69 @@ describe('PortalInlinePlayerComponent up next rail', () => {
},
];
it('hands the episode after the playing one to the player as "up next"', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.detectChanges();
expect(component.playerUpNext()).toEqual({
label: 'S01E03',
title: 'Episode 3',
thumbnailUrl: null,
progressPercent: null,
});
// The last episode of the list has nothing after it.
fixture.componentRef.setInput('upNextEpisodes', [
{ ...upNextItems[1], isPlaying: true },
]);
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
// Only episodes get a card: a movie never has a "next".
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.componentRef.setInput('playback', {
...seriesPlayback,
contentInfo: {
...seriesPlayback.contentInfo,
contentType: 'movie',
},
});
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
});
it('plays the next season through the rail path once the season is over', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
const selected = jest.fn();
const next = jest.fn();
component.upNextEpisodeSelected.subscribe(selected);
component.nextEpisodeRequested.subscribe(next);
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: false,
});
fixture.detectChanges();
component.onNextEpisodeRequested();
expect(selected).toHaveBeenCalledWith(upNextItems[1]);
expect(next).not.toHaveBeenCalled();
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: true,
autoplayEnabled: false,
});
fixture.detectChanges();
component.onNextEpisodeRequested();
expect(next).toHaveBeenCalledTimes(1);
expect(selected).toHaveBeenCalledTimes(1);
});
/** Renders, then feeds a stage size as the ResizeObserver would. */
function renderWithStage(width: number, height: number): void {
fixture.detectChanges();
@@ -77,6 +77,7 @@
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[upNext]="playerUpNext()"
[playback]="playback()"
[volume]="volume()"
[playerOverride]="playerOverride()"
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -28,7 +28,7 @@ import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
import type { PlayerMediaTitle, PlayerUpNextItem } from '../player-controls';
import {
FULLSCREEN_CHANNEL_PANEL,
type FullscreenChannelPanelContext,
@@ -224,6 +224,28 @@ export class PortalInlinePlayerComponent {
readonly upNextRailItems = computed<UpNextRailItem[]>(
() => this.upNextEpisodes() ?? []
);
/** The rail entry after the playing one, across season boundaries. */
private readonly upNextRailNext = computed<UpNextRailItem | null>(() => {
const items = this.upNextEpisodes() ?? [];
const playing = items.findIndex((item) => item.isPlaying);
const next = playing >= 0 ? items[playing + 1] : undefined;
return next && this.playback()?.contentInfo?.contentType === 'episode'
? next
: null;
});
/** The episode after the playing one, for the controls' "Up next" card. */
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
const next = this.upNextRailNext();
if (!next) {
return null;
}
return {
label: next.label,
title: next.title,
thumbnailUrl: next.thumbnailUrl,
progressPercent: next.progressPercent,
};
});
private readonly fullscreenEpisodePanelTemplate = viewChild<
TemplateRef<FullscreenChannelPanelContext>
@@ -323,6 +345,14 @@ export class PortalInlinePlayerComponent {
}
onNextEpisodeRequested(): void {
// Season-local navigation ends at a season's last episode; the Up
// next card can still offer the next season's first, which only
// the rail selection path knows how to play.
const next = this.upNextRailNext();
if (this.seriesNavigation()?.canNext === false && next) {
this.upNextEpisodeSelected.emit(next);
return;
}
this.nextEpisodeRequested.emit();
}
@@ -14,6 +14,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -24,6 +24,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -7,6 +7,7 @@
[options]="options"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -35,6 +36,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -64,6 +66,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -93,6 +96,7 @@
[playback]="resolvedPlayback()"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
[seriesNavigation]="seriesNavigation()"
@@ -1,4 +1,3 @@
import { Component, input, output, signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
@@ -13,12 +12,7 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
VideoPlayer,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
@@ -35,7 +29,13 @@ import {
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -45,88 +45,6 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-vjs-player',
template: '<div data-test-id="stub-vjs-player"></div>',
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-html-video-player',
template: '<div data-test-id="stub-html-player"></div>',
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-art-player',
template: '<div data-test-id="stub-art-player"></div>',
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-embedded-mpv-player',
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
})
class StubEmbeddedMpvPlayerComponent {
/** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */
readonly support = signal<EmbeddedMpvSupport | null>(null);
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
describe('WebPlayerViewComponent', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
@@ -38,6 +38,7 @@ import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
} from '../player-controls';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
@@ -134,6 +135,8 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playerOverride = input<VideoPlayer | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Next episode for the shared controls' "Up next" card; series hosts only. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
@@ -20,6 +20,7 @@ export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -63,6 +65,7 @@ export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);