feat(playback): restyle the shared player controls as the Hybrid dock

Phase 1 of the "Hybrid" (design variant E) player controls redesign for
app-player-controls, shared by HTML5, Video.js, ArtPlayer and frame-copy
Embedded MPV:

- dock without a surface of its own over a 55% bottom scrim, mirrored by
  the top scrim; fixed --pc-* overlay palette (accent blue, cyan, violet)
- three-column control row with a filled accent play circle; previous /
  -10s / play / +10s / next transport order
- volume slider inline on wide players, behind the popover when compact
  (ControlsLayout, ResizeObserver at 720px of container width)
- drawn timeline track with knob, remaining time (−7:03) instead of the
  total, and a hover time label (ControlsTimelineHover); the range input
  stays as the interaction and accessibility layer
- glass popovers; 40px/12px icon buttons (32px/9px compact)

Docs: player-controls-contract (dock, scrims, layout modes), UI guidelines
palette note. Release note added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 21:08:15 +02:00
1 parent 587e19541f
commit c5518fa1ff
17 files changed
+2293 -1007

No files matched your search

@@ -0,0 +1,10 @@
---
type: feature
area: playback
highlight: Redesigned player controls
---
The built-in player controls have a new look: a lighter dock that sits
directly on the video, a round accent play button, the volume slider inline
on wide players, a remaining-time counter, and a timeline that shows the time
under your pointer before you seek.
+2 -1
View File
@@ -88,7 +88,8 @@ icons. The dock must never pair a dark fallback surface with inherited light
app text. Loader/stall and transient feedback overlays own a light foreground
and dark scrim because they cover video. Video viewports remain black in both
themes and fullscreen; frame-copy and built-in shared controls keep their
existing light-on-dark overlay palette.
light-on-dark overlay palette — the fixed `--pc-*` token set of the shared
dock (accent blue, cyan, violet and a light text ramp), never the app theme.
EPG timeline, list, empty states and programme details use the library-local
`libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators,
+76 -22
View File
@@ -287,9 +287,50 @@ It owns only transient presentation behavior:
controller state;
- `ControlsShortcuts` — document keyboard routing;
- `ControlsSurface` — pointer/click/double-click surface interactions;
- `ControlsTimeline` — scrub state and timeline projections; and
- `ControlsTimeline` — scrub state and timeline projections;
- `ControlsTimelineHover` — the time under the pointer over the timeline;
- `ControlsLayout` — the compact/wide dock mode from the host's width; and
- `controls-view-model.ts` — derived display state.
### The dock
The controls render as a **dock** (`.player-controls__bar`) with no surface
of its own: a timeline row above a three-column control row, sitting
directly on the video over the bottom scrim. The palette is a fixed set of
`--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the
primary action and progress, cyan `#5cd6ff` for "something is on", violet
`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` /
`#6b7384` text ramp. They are literal on purpose: the overlay is
theme-independent (see the UI guidelines' player theme boundary), and the
app's `--app-selection-color` is a different blue that would fight the video.
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
(`.player-controls__timeline-track` with one segment and an accent fill,
a white knob ringed in translucent blue) · remaining time as `−7:03`
(`formatRemainingTime`; the LIVE badge replaces it on live streams and
`--:--` stands in while no duration is known) · the recording status.
The `<input type="range">` stays as the interaction and accessibility
layer, invisible and full-size over the drawn track: dragging, arrow
keys, `aria-valuetext` and the focus ring (drawn on the track through
`:has(:focus-visible)`) all belong to it, so scrubbing semantics are
unchanged. Hovering the bar with a mouse shows a white marker and a
`1:40` label above the pointer (`ControlsTimelineHover`); touch never
hovers and a non-seekable timeline never labels.
- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume
button, with the slider **inline** (72px) in the wide mode and behind
the hover/tap popover in the compact mode — inline, the button is a
plain mute toggle for every pointer type (`buttonClick(event,
{ inlineSlider: true })`). Center: previous episode · −10s · **play** ·
+10s · next episode. Right: the capability actions, end-aligned.
- **Play button** (`.player-controls__play`, `data-test-id
="player-controls-play"`): a 52px filled accent circle with a white glyph,
not a Material icon button. Hover darkens the fill rather than lightening
it so the glyph keeps ≥3:1 against it — `player-theme.e2e.ts` rasterizes
exactly the hovered and focused states.
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
Material's `--mat-icon-button-*` tokens; their hover is a flat
`rgba(255,255,255,.1)` layer.
### Stream info popover
An `info` button in the **top-right corner** of the overlay opens a popover with
@@ -360,13 +401,18 @@ Per engine:
no info affordance, though its backends plumb the properties for parity. See
[embedded-mpv-native.md](./embedded-mpv-native.md#stream-stats-properties).
### Top scrim
### Scrims
`.player-controls__top-scrim` is a single pointer-transparent gradient at the
top of the player, mirroring the bottom bar's stops so both edges read as one
system. It renders whenever there is top chrome to back — the fullscreen media
title or the corner buttons — and fades with the controls without sliding (a
moving scrim edge is visible against video in a way a moving control is not).
top of the player (`max(28%, 112px)` tall), and
`.player-controls__bottom-scrim` its mirror behind the dock (55% tall, from
`rgba(4,7,11,.92)` at the edge through `.55` to transparent). Both read as
one system. The top one renders whenever there is top chrome to back — the
fullscreen media title or the corner buttons — the bottom one with the dock;
both fade with the controls without sliding (a moving scrim edge is visible
against video in a way a moving control is not). The dock itself has no
background: the bottom scrim is the only thing between the controls and the
picture.
One element, not a background per consumer: the title and the corner overlap,
and two gradients would darken the overlap twice. The title therefore carries
@@ -882,24 +928,30 @@ the last second (`wasTouchInteraction`). Three behaviors diverge from mouse:
popover close (outside taps and other menu buttons dismiss it), and neither
does the `focusout` of a pointer focus release.
- **Coarse-pointer scrub sizing.** Under `@media (pointer: coarse)` the
timeline/volume sliders grow their input hit strip to 28px and the thumb to
18px; the 4px visual track is unchanged.
timeline bar and the volume slider grow their hit strip to 28px and the
volume thumb to 16px; the drawn tracks are unchanged.
### Narrow-player layout
### Compact and wide layout
The controls host is a size query container (`player-controls`). At container
widths of 640px and below — phone-sized PWA viewports, but also small inline
players inside wide desktop windows — the single-row bar reflows to two rows:
the timeline takes a full-width first row, and the transport and actions
clusters split the second. The actions cluster's width is content-dependent
(volume, audio, subtitles, quality, speed, aspect, recording, PiP, and
fullscreen are all conditional), so in the narrow layout the cluster is
end-aligned, capped at the row width, and wraps when even a dedicated row cannot
hold it. Its popover anchors become static at this breakpoint so capability
panels position against the unclipped actions cluster and remain accessible
above every wrapped row. Icon buttons compact from 48px to 40px in this layout.
Between ~640px and the 720px viewport media query, the legacy single-row squeeze
(timeline absorbs the shrink) still applies.
The controls host is a size query container (`player-controls`), and the
dock has two modes split at **720px of container width**: `compact` at
719px and below — phone-sized PWA viewports, but also small inline players
inside wide desktop windows — and `wide` above. The split lives in two
places that must agree: the `@container player-controls (max-width: 719px)`
block in the stylesheet sizes the compact dock (14px gutters, 32px buttons,
36px play circle, 5px track), and `ControlsLayout`
(`COMPACT_LAYOUT_MAX_WIDTH`, a `ResizeObserver` on the host) drives the
template branches CSS cannot express — today the inline volume slider versus
its popover. Without `ResizeObserver` (unit tests) the mode stays `wide`.
Episode navigation stays in the compact transport: the series hosts rely on
those buttons, and the inline series player is often narrower than 720px.
The actions cluster's width is content-dependent (audio, subtitles, quality,
speed, aspect, recording, PiP, and fullscreen are all conditional), so in
the compact layout the cluster is end-aligned, capped at the row width, and
wraps when the row cannot hold it. Its popover anchors become static at this
breakpoint so capability panels position against the unclipped actions
cluster and remain accessible above every wrapped row.
When a volume-capable controller first attaches, an existing `localStorage`
volume preference is applied before the first controller snapshot can reconcile
@@ -1385,6 +1437,8 @@ libs/ui/playback/src/lib/player-controls/
├── player-controls.component.scss
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-layout.ts
├── controls-timeline-hover.ts
├── controls-fullscreen.ts
├── controls-menu-selection.ts
├── controls-menu-state.ts
@@ -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,106 @@
import { COMPACT_LAYOUT_MAX_WIDTH, ControlsLayout } 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('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,66 @@
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;
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');
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);
}
}
detach(): void {
this.observer?.disconnect();
this.observer = null;
}
dispose(): void {
this.detach();
}
}
@@ -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,85 @@
import { Signal, computed, signal } from '@angular/core';
import { formatTime } from './controls-format.utils';
/**
* 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>;
}
/**
* 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));
});
readonly label = computed(() => {
const seconds = this.seconds();
return seconds === null ? null : formatTime(seconds);
});
/** 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);
}
}
}
@@ -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()
) {
@@ -115,7 +115,7 @@ describe('PlayerControlsComponent capability contract', () => {
)?.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');
@@ -348,7 +348,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,378 @@
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('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('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
@@ -416,6 +416,16 @@ describe('PlayerControlsComponent interactions', () => {
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'));
@@ -1,24 +1,36 @@
:host {
--pc-accent: #ffffff;
--pc-track: rgba(255, 255, 255, 0.3);
--pc-thumb-ring: rgba(255, 255, 255, 0.95);
--pc-panel-bg: rgba(18, 18, 20, 0.92);
--pc-panel-border: rgba(255, 255, 255, 0.16);
--pc-text: #ffffff;
// Overlay palette. Deliberately literal and theme-independent: the shared
// controls always sit on video, and the UI guidelines keep them on one
// light-on-dark palette in both app themes. The values are the design
// tokens of the "Hybrid" controls concept (accent blue = primary action
// and progress; cyan = something is on; violet = a value was changed).
--pc-accent-blue: #4f8eff;
--pc-accent-blue-hover: #3f7ef5;
--pc-accent-cyan: #5cd6ff;
--pc-accent-violet: #b599ff;
--pc-text: #e7ecf3;
--pc-text-secondary: #9aa3b2;
--pc-text-tertiary: #6b7384;
--pc-text-dim: rgba(255, 255, 255, 0.82);
--pc-scrim-ink: 4, 7, 11;
--pc-track: rgba(255, 255, 255, 0.18);
--pc-hover: rgba(255, 255, 255, 0.1);
--pc-glass-bg: rgba(12, 16, 23, 0.72);
--pc-glass-border: rgba(255, 255, 255, 0.09);
--pc-font-mono:
ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
position: absolute;
inset: 0;
z-index: 2;
display: block;
// The control layer is transparent to the mouse so the video underneath
// stays interactive; only the bar/popovers opt back in to pointer events.
// stays interactive; only the dock/popovers opt back in to pointer events.
pointer-events: none;
color: var(--pc-text);
// Become a query container so popovers can size against the actual player
// box (the host fills the player via inset:0) instead of fixed pixels or
// the viewport — they grow to fit when there is room and shrink to scroll
// when the player is small, never spilling past its edges.
// Become a query container so the dock and popovers can size against the
// actual player box (the host fills the player via inset:0) instead of
// fixed pixels or the viewport.
container-type: size;
container-name: player-controls;
}
@@ -77,34 +89,59 @@
// footage. Deliberately one element rather than a background per consumer: two
// overlapping gradients would darken the overlap twice.
//
// The stops are the bottom bar's, mirrored, so the two edges of the player read
// as one system.
// The stops mirror the bottom scrim so the two edges of the player read as one
// system; the height follows the player so a tall fullscreen stage gets a
// proportionally deeper fade, never less than the chrome it backs.
.player-controls__top-scrim {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 1;
// Mirrors the bar's 48px top padding + 64px min-height.
height: 112px;
height: max(28%, 112px);
opacity: 0;
// Purely decorative: clicks belong to the video surface underneath.
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 45%,
rgba(var(--pc-scrim-ink), 0.78) 0%,
rgba(var(--pc-scrim-ink), 0.45) 45%,
transparent 100%
);
// Fades with the controls, but does not slide: a moving scrim edge is
// visible against video in a way a moving control is not.
transition: opacity 180ms ease-out;
transition: opacity 200ms ease-out;
}
.player-controls__top-scrim--visible {
opacity: 1;
}
// Backdrop of the dock. Same rule as the top scrim: one gradient for the
// timeline and the button row together, and no surface under the dock itself
// — the controls sit directly on the video.
.player-controls__bottom-scrim {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
height: 55%;
opacity: 0;
pointer-events: none;
background: linear-gradient(
to top,
rgba(var(--pc-scrim-ink), 0.92) 0%,
rgba(var(--pc-scrim-ink), 0.55) 45%,
rgba(var(--pc-scrim-ink), 0) 100%
);
transition: opacity 200ms ease-out;
}
.player-controls__bottom-scrim--visible {
opacity: 1;
}
.player-controls__title {
position: absolute;
top: 0;
@@ -115,20 +152,20 @@
flex-direction: column;
gap: 4px;
// Bottom padding keeps the text inside the top scrim's opaque half.
padding: 20px 24px 40px;
padding: 20px 28px 40px;
opacity: 0;
// Purely informative: never intercept clicks meant for the video surface.
pointer-events: none;
transform: translateY(-8px);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
opacity 200ms ease-out,
transform 220ms ease-out;
}
// Only when the corner button is actually rendered: keep the fullscreen title
// from running underneath it (a long title ellipsizes at the full width).
.player-controls__title:has(~ .player-controls__corner) {
padding-right: 64px;
padding-right: 72px;
}
.player-controls__title--visible {
@@ -139,8 +176,8 @@
.player-controls__title-primary {
overflow: hidden;
color: var(--pc-text);
font-size: 1.15rem;
font-weight: 600;
font-size: 17px;
font-weight: 650;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
@@ -149,29 +186,29 @@
.player-controls__title-secondary {
overflow: hidden;
color: var(--pc-text-dim);
font-size: 0.85rem;
color: var(--pc-text-secondary);
font-size: 12.5px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
// Top-right corner chrome (stream-info button). It fades with the bottom bar
// Top-right corner chrome (stream-info button). It fades with the dock
// but is its own layer: the title scrim above it is click-through, so the
// button opts back into pointer events on its own.
.player-controls__corner {
position: absolute;
top: 8px;
right: 8px;
top: 16px;
right: 20px;
z-index: 3;
display: flex;
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
opacity 200ms ease-out,
transform 220ms ease-out;
}
.player-controls__corner--visible {
@@ -180,34 +217,23 @@
transform: translateY(0);
}
// The dock: timeline row above, control row below. No background of its own;
// the bottom scrim is the backdrop.
.player-controls__bar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
right: 32px;
bottom: 22px;
left: 32px;
z-index: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
align-content: end;
min-height: 64px;
// Generous top padding lets the scrim rise above the controls and fade out.
padding: 48px 16px 12px;
display: flex;
flex-direction: column;
gap: 12px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
// True-overlay scrim: opaque-ish black at the bottom fading to fully
// transparent at the top, so controls read clearly over full-bleed video.
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 45%,
transparent 100%
);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
opacity 200ms ease-out,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.player-controls__bar--visible {
@@ -216,27 +242,118 @@
transform: translateY(0);
}
.player-controls__transport {
display: flex;
align-items: center;
gap: 8px;
}
// --- Timeline row -----------------------------------------------------------
.player-controls__timeline {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 14px;
min-width: 0;
}
.player-controls__time {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: var(--pc-text-dim);
font-size: 0.78rem;
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;
display: flex;
gap: 3px;
height: 6px;
transform: translateY(-50%);
}
.player-controls__timeline-segment {
position: relative;
flex: 1 1 auto;
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 {
@@ -244,7 +361,7 @@
align-items: center;
gap: 6px;
min-width: 0;
color: rgba(255, 255, 255, 0.78);
color: var(--pc-text-dim);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
@@ -300,10 +417,35 @@
}
}
// --- Control row ------------------------------------------------------------
.player-controls__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.player-controls__left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.player-controls__transport {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.player-controls__actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
gap: 4px;
min-width: 0;
}
.player-controls__popover-anchor {
@@ -311,47 +453,113 @@
display: flex;
}
.player-controls__volume {
align-items: center;
gap: 4px;
}
.player-controls__record-button--active {
color: var(--mat-sys-error, #ff5252) !important;
}
// Scope to :host — the controls render directly under the component host
// (`.player-controls-host`); there is no `.player-controls` wrapper element, so
// selectors prefixed with `.player-controls ` never matched (which is why the
// icons were only white on :hover, the one rule scoped to `.player-controls__bar`).
:host :is(button[mat-icon-button]) {
// Material drives the icon colour through MDC tokens, not plain `color`,
// so force both — otherwise the icons render in the dim theme colour.
// Primary action: a filled circle in the accent colour. Not a Material icon
// button on purpose — the ring and the fill are the design, and Material's
// state layer would tint them.
.player-controls__play {
display: grid;
flex: 0 0 auto;
place-items: center;
width: 52px;
height: 52px;
margin: 0 6px;
padding: 0;
border: 0;
border-radius: 50%;
color: #ffffff;
--mdc-icon-button-icon-color: #ffffff;
--mat-icon-button-icon-color: #ffffff;
background: var(--pc-accent-blue);
box-shadow:
0 0 0 6px rgba(79, 142, 255, 0.18),
0 6px 20px rgba(79, 142, 255, 0.35);
cursor: pointer;
transition:
background 120ms ease-out,
transform 120ms ease-out;
}
.player-controls__play mat-icon {
width: 30px;
height: 30px;
font-size: 30px;
color: #ffffff;
}
// Hover darkens rather than lightens: the white glyph keeps its contrast
// against the fill (the theme E2E rasterizes exactly this state).
.player-controls__play:hover:not(:disabled) {
background: var(--pc-accent-blue-hover);
transform: scale(1.04);
}
.player-controls__play:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
.player-controls__play:disabled {
opacity: 0.55;
cursor: default;
}
// Scope to :host — the controls render directly under the component host
// (`.player-controls-host`); there is no `.player-controls` wrapper element.
:host :is(button[mat-icon-button]) {
width: 40px;
height: 40px;
padding: 8px;
// Material drives the icon colour and geometry through its tokens, not
// plain `color`/`width`, so set both — otherwise the icons render in the
// dim theme colour inside a 48px circle.
color: var(--pc-text);
--mdc-icon-button-icon-color: var(--pc-text);
--mat-icon-button-icon-color: var(--pc-text);
--mat-icon-button-state-layer-size: 40px;
--mdc-icon-button-state-layer-size: 40px;
--mat-icon-button-container-shape: 12px;
--mdc-icon-button-container-shape: 12px;
--mat-icon-button-hover-state-layer-opacity: 0;
border-radius: 12px;
transition: background 120ms ease-out;
}
:host :is(button[mat-icon-button]) mat-icon {
color: #ffffff;
color: var(--pc-text);
}
:host :is(button[mat-icon-button][disabled]) {
// Keep disabled affordances visibly muted.
--mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38);
color: rgba(255, 255, 255, 0.38);
--mdc-icon-button-icon-color: var(--pc-text-tertiary);
--mat-icon-button-icon-color: var(--pc-text-tertiary);
color: var(--pc-text-tertiary);
}
:host :is(button[mat-icon-button][disabled]) mat-icon {
color: rgba(255, 255, 255, 0.38);
color: var(--pc-text-tertiary);
}
:host
:is(.player-controls__bar, .player-controls__corner)
:is(button[mat-icon-button]:hover) {
:is(button[mat-icon-button]:hover:not([disabled])) {
color: #ffffff;
background: rgba(255, 255, 255, 0.16);
background: var(--pc-hover);
}
// --- Sliders ----------------------------------------------------------------
.player-controls__slider {
--slider-progress: 0%;
width: 100%;
height: 18px;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
@@ -359,7 +567,7 @@
}
.player-controls__slider:focus-visible {
outline: 2px solid var(--pc-accent);
outline: 2px solid var(--pc-text);
outline-offset: 4px;
border-radius: 4px;
}
@@ -369,7 +577,7 @@
border-radius: 999px;
background: linear-gradient(
to right,
var(--pc-accent) var(--slider-progress),
var(--pc-text) var(--slider-progress),
var(--pc-track) var(--slider-progress)
);
}
@@ -377,13 +585,13 @@
.player-controls__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border: 2px solid var(--pc-thumb-ring);
width: 12px;
height: 12px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: var(--pc-accent);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
@@ -398,7 +606,6 @@
.player-controls__slider:disabled::-webkit-slider-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
@@ -411,16 +618,16 @@
.player-controls__slider::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--pc-accent);
background: var(--pc-text);
}
.player-controls__slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid var(--pc-thumb-ring);
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: var(--pc-accent);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
@@ -431,24 +638,52 @@
.player-controls__slider:disabled::-moz-range-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
// The timeline's range input is the interaction and accessibility layer over
// the drawn track: invisible, full-size, and its focus ring is drawn on the
// track instead (an outline on a transparent element is invisible).
.player-controls__slider--timeline {
position: absolute;
inset: 0;
z-index: 1;
height: 100%;
opacity: 0;
}
.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;
}
.player-controls__slider--inline {
width: 72px;
margin-left: 4px;
}
// --- Popovers ---------------------------------------------------------------
.player-controls__panel {
min-width: 0;
color: var(--pc-text);
background: var(--pc-panel-bg);
border: 1px solid var(--pc-panel-border);
background: var(--pc-glass-bg);
border: 1px solid var(--pc-glass-border);
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px);
backdrop-filter: blur(18px) saturate(1.3);
}
.player-controls__volume-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
left: 0;
display: grid;
grid-template-columns: 1fr;
gap: 4px;
@@ -493,9 +728,9 @@
// where container units are unavailable.
max-width: min(420px, calc(100cqw - 24px), 80vw);
// Height grows to fit the list; only when it would exceed the room above
// the controls bar does the inner list start scrolling. 96px ≈ the controls
// bar height plus the 6px gap and a little breathing room.
max-height: min(calc(100cqh - 96px), 70vh);
// the dock does the inner list start scrolling. 120px ≈ the dock height
// plus the 6px gap and a little breathing room.
max-height: min(calc(100cqh - 120px), 70vh);
padding: 10px 12px;
z-index: 3;
}
@@ -503,7 +738,7 @@
.player-controls__menu-title {
flex: 0 0 auto;
padding: 0 2px 6px;
color: rgba(255, 255, 255, 0.7);
color: var(--pc-text-tertiary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -521,7 +756,7 @@
flex-direction: column;
min-width: 240px;
max-width: min(360px, calc(100cqw - 24px), 80vw);
max-height: min(calc(100cqh - 96px), 70vh);
max-height: min(calc(100cqh - 120px), 70vh);
padding: 10px 12px;
overflow-y: auto;
scrollbar-width: thin;
@@ -617,7 +852,7 @@
// Single scroll owner for the subtitle popover's list + delay/style sections:
// on short players (e.g. the 220px portal player) the panel's max-height cap
// must scroll ALL content — sections outside the list's own scroll area would
// otherwise clip or overflow into the controls bar.
// otherwise clip or overflow into the dock.
.player-controls__subtitle-body {
display: flex;
flex-direction: column;
@@ -642,7 +877,7 @@
.player-controls__subtitle-section-title {
padding: 0 2px 6px;
color: rgba(255, 255, 255, 0.7);
color: var(--pc-text-tertiary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -718,102 +953,132 @@
border-color: #ffffff;
}
@media (max-width: 720px) {
// Moderately tight viewports keep the single-row layout: the transport and
// action buttons hold their natural width while the middle timeline column
// (the seek slider) absorbs the shrink. Genuinely narrow players reflow to
// two rows via the container query below, which wins over this gap.
.player-controls__bar {
gap: 8px;
}
.player-controls__actions {
min-width: 0;
}
.player-controls__volume-popover,
.player-controls__track-popover {
right: 0;
left: auto;
}
}
// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse
// slider; the 4px visual track is unchanged — only the input's touch area and
// the thumb grow.
// slider; the drawn track is unchanged — only the input's touch area and the
// volume thumb grow.
@media (pointer: coarse) {
.player-controls__slider {
height: 28px;
}
.player-controls__timeline-bar {
height: 28px;
}
.player-controls__slider::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
width: 16px;
height: 16px;
margin-top: -6px;
}
.player-controls__slider::-moz-range-thumb {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
}
}
// Narrow players — phone-sized PWA viewports and small windowed/inline
// players — cannot hold transport + timeline + the variable-count actions
// cluster in one row (every action button from volume to fullscreen is
// conditional, so the cluster's width is content-dependent). Give the
// timeline its own full-width row and split the second row between the two
// button clusters. Container-based on purpose: a small inline player inside a
// wide desktop window needs the same treatment as a phone viewport.
@container player-controls (max-width: 640px) {
// Matches the bar's reduced padding below: on a small inline player two
// full-height scrims would tint most of the picture.
// Compact dock — phone-sized PWA viewports and small windowed/inline players.
// Keep this in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts,
// which decides what is rendered; this block only sizes it. Container-based
// on purpose: a small inline player inside a wide desktop window needs the
// same treatment as a phone viewport.
@container player-controls (max-width: 719px) {
.player-controls__top-scrim {
height: 88px;
height: max(28%, 88px);
}
.player-controls__title {
padding: 12px 16px 28px;
}
.player-controls__corner {
top: 8px;
right: 8px;
}
.player-controls__bar {
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
'timeline timeline'
'transport actions';
gap: 0 8px;
padding: 36px 10px 8px;
}
.player-controls__transport {
grid-area: transport;
right: 14px;
bottom: 10px;
left: 14px;
gap: 6px;
}
.player-controls__timeline {
grid-area: timeline;
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);
}
.player-controls__row {
gap: 4px;
}
.player-controls__transport {
gap: 4px;
}
.player-controls__play {
width: 36px;
height: 36px;
margin: 0 4px;
box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.18);
}
.player-controls__play mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
}
.player-controls__actions {
grid-area: actions;
position: relative;
justify-self: end;
// When even a dedicated row cannot hold the cluster (worst case:
// series transport plus audio/subtitles/quality/speed/aspect/PiP),
// wrap the actions instead of making them a scroll container. A scroll
// container would clip every absolutely positioned capability panel.
// When even the row cannot hold the cluster (worst case: audio/
// subtitles/quality/speed/aspect/PiP), wrap the actions instead of
// making them a scroll container. A scroll container would clip
// every absolutely positioned capability panel.
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
overflow: visible;
}
// Anchor narrow-player panels to the whole actions cluster. This keeps the
// right-aligned panels inside the player and above every wrapped action row.
// Anchor compact-player panels to the whole actions cluster. This keeps
// the right-aligned panels inside the player and above every wrapped
// action row.
.player-controls__actions .player-controls__popover-anchor {
position: static;
}
// Compact icon buttons: wins back roughly one button of width per four.
.player-controls__bar :is(button[mat-icon-button]) {
width: 40px;
height: 40px;
padding: 8px;
--mdc-icon-button-state-layer-size: 40px;
width: 32px;
height: 32px;
padding: 6px;
border-radius: 9px;
--mat-icon-button-state-layer-size: 32px;
--mdc-icon-button-state-layer-size: 32px;
--mat-icon-button-container-shape: 9px;
--mdc-icon-button-container-shape: 9px;
--mat-icon-button-icon-size: 20px;
--mdc-icon-button-icon-size: 20px;
}
.player-controls__bar :is(button[mat-icon-button]) mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
}
@@ -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,18 +17,24 @@ 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 { 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 { 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,
formatTime,
speedLabel,
} from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
@@ -74,6 +80,9 @@ export class PlayerControlsComponent implements OnDestroy {
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
private readonly shortcuts = new ControlsShortcuts();
private 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(),
@@ -134,6 +143,16 @@ export class PlayerControlsComponent implements OnDestroy {
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
readonly timelineHover = new ControlsTimelineHover({
duration: this.timelineDuration,
interactive: computed(
() => this.capabilities().seek && this.state().canSeek
),
});
/** `−7:03` while a finite duration is known; the dock prefers it to the total. */
readonly remainingTimeText = computed(() =>
formatRemainingTime(this.timelineValue(), this.timelineDuration())
);
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
@@ -189,6 +208,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 +302,7 @@ export class PlayerControlsComponent implements OnDestroy {
});
}
ngOnDestroy(): void {
this.layout.dispose();
this.shortcuts.detach();
this.feedback.dispose();
this.visibility.dispose();