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

@@ -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();