diff --git a/.changes/playback-hybrid-player-controls.md b/.changes/playback-hybrid-player-controls.md new file mode 100644 index 000000000..9d2fb0174 --- /dev/null +++ b/.changes/playback-hybrid-player-controls.md @@ -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. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9ed67dbe5..7ab521618 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index aa8762eed..626b70eec 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 `` 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 diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts index 95513efd2..30963461e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.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); diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts index 397611283..86b104650 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts new file mode 100644 index 000000000..7a93b7e5d --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.ts new file mode 100644 index 000000000..bf18d0d5a --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.ts @@ -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('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(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts new file mode 100644 index 000000000..6ce9d5106 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts new file mode 100644 index 000000000..ff53ce757 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts @@ -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, + 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; + /** Whether the timeline accepts pointer interaction at all. */ + interactive: Signal; +} + +/** + * 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(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); + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts index 7c3dd81fc..d19b89966 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts @@ -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() ) { diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index 0b23e2f27..25d69a531 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -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') diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts new file mode 100644 index 000000000..fa2287d70 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -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 = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + 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; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + 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 + ).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(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 2de53d6f3..468cde5b7 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -55,7 +55,9 @@ data-test-id="player-controls-stream-info-button" (click)="toggleMenu('stats')" [attr.aria-expanded]="menus.statsOpen()" - [attr.aria-label]="'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate" + [attr.aria-label]=" + 'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate + " [matTooltip]="'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate" matTooltipPosition="below" > @@ -75,7 +77,10 @@ @if (streamStats.hasRows()) {
- @for (row of streamStats.rows(); track row.labelKey) { + @for ( + row of streamStats.rows(); + track row.labelKey + ) {
{{ row.labelKey | translate }}
{{ row.value }}
@@ -97,10 +102,20 @@ } @if (showControls()) { + +
-
- - - @if (capabilities().seriesNavigation) { - - - - } - - @if (capabilities().seek) { - - - - } -
-
+ {{ formatTime(timelineValue()) }} @if (capabilities().seek) { - - } -
- {{ formatTime(timelineValue()) }} - @if (state().canSeek) { - {{ formatTime(timelineDuration()) }} - } @else if (state().isLive) { - + +
+ @if (state().canSeek) { + + >
+ } + @if (timelineHover.percent() !== null) { + + + } + +
+ } @else { +
+ } + @if (state().canSeek && remainingTimeText(); as remaining) { + {{ remaining }} + } @else if (state().isLive) { + + + LIVE + + } @else { + --:-- + } @if (recordingStatusText(); as recordingStatus) {
-
- @if (capabilities().volume) { -
- - @if (menus.volumeOpen()) { - - } -
- } + } @else if (menus.volumeOpen()) { + + } +
+ } +
- @if (hasAudioTracks()) { -
+
+ @if (capabilities().seriesNavigation) { - @if (menus.audioOpen()) { - - } -
- } + } - @if (hasSubtitleTracks()) { -
+ @if (capabilities().seek) { + } + + + + @if (capabilities().seek) { + + } + + @if (capabilities().seriesNavigation) { + + } +
+ +
+ @if (hasAudioTracks()) { +
+ + @if (menus.audioOpen()) { + + } +
+ } + + @if (hasSubtitleTracks()) { +
+ + @if (menus.subtitleOpen()) { + + } +
+ } + + @if (hasQualityLevels()) { +
+ + @if (menus.qualityOpen()) { + + } +
+ } + + @if (capabilities().playbackSpeed) { +
+ + @if (menus.speedOpen()) { + + } +
+ } + + @if (capabilities().aspectRatio) { +
+ + @if (menus.aspectOpen()) { + + } +
+ } + + @if (canRecord()) { + - @if (menus.subtitleOpen()) { - - } -
- } + } - @if (hasQualityLevels()) { -
+ @if (capabilities().pictureInPicture) { - @if (menus.qualityOpen()) { - - } -
- } - - @if (capabilities().playbackSpeed) { -
- - @if (menus.speedOpen()) { - - } -
- } + } - @if (capabilities().aspectRatio) { -
+ @if (capabilities().fullscreen) { - @if (menus.aspectOpen()) { - - } -
- } - - @if (canRecord()) { - - } - - @if (capabilities().pictureInPicture) { - - } - - @if (capabilities().fullscreen) { - - } + } +
} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts index b8e7e24cf..217bb33f8 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -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')); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 6b11fa467..6b2835f6c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -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; } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts index f40a5a654..a11cb97c1 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts @@ -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; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index daea28aef..b4a4b45df 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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();