mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
587e19541f
commit
c5518fa1ff
17 files changed
+2293
-1007
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
highlight: Redesigned player controls
|
||||
---
|
||||
|
||||
The built-in player controls have a new look: a lighter dock that sits
|
||||
directly on the video, a round accent play button, the volume slider inline
|
||||
on wide players, a remaining-time counter, and a timeline that shows the time
|
||||
under your pointer before you seek.
|
||||
@@ -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,
|
||||
|
||||
@@ -287,9 +287,50 @@ It owns only transient presentation behavior:
|
||||
controller state;
|
||||
- `ControlsShortcuts` — document keyboard routing;
|
||||
- `ControlsSurface` — pointer/click/double-click surface interactions;
|
||||
- `ControlsTimeline` — scrub state and timeline projections; and
|
||||
- `ControlsTimeline` — scrub state and timeline projections;
|
||||
- `ControlsTimelineHover` — the time under the pointer over the timeline;
|
||||
- `ControlsLayout` — the compact/wide dock mode from the host's width; and
|
||||
- `controls-view-model.ts` — derived display state.
|
||||
|
||||
### The dock
|
||||
|
||||
The controls render as a **dock** (`.player-controls__bar`) with no surface
|
||||
of its own: a timeline row above a three-column control row, sitting
|
||||
directly on the video over the bottom scrim. The palette is a fixed set of
|
||||
`--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the
|
||||
primary action and progress, cyan `#5cd6ff` for "something is on", violet
|
||||
`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` /
|
||||
`#6b7384` text ramp. They are literal on purpose: the overlay is
|
||||
theme-independent (see the UI guidelines' player theme boundary), and the
|
||||
app's `--app-selection-color` is a different blue that would fight the video.
|
||||
|
||||
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
|
||||
(`.player-controls__timeline-track` with one segment and an accent fill,
|
||||
a white knob ringed in translucent blue) · remaining time as `−7:03`
|
||||
(`formatRemainingTime`; the LIVE badge replaces it on live streams and
|
||||
`--:--` stands in while no duration is known) · the recording status.
|
||||
The `<input type="range">` stays as the interaction and accessibility
|
||||
layer, invisible and full-size over the drawn track: dragging, arrow
|
||||
keys, `aria-valuetext` and the focus ring (drawn on the track through
|
||||
`:has(:focus-visible)`) all belong to it, so scrubbing semantics are
|
||||
unchanged. Hovering the bar with a mouse shows a white marker and a
|
||||
`1:40` label above the pointer (`ControlsTimelineHover`); touch never
|
||||
hovers and a non-seekable timeline never labels.
|
||||
- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume
|
||||
button, with the slider **inline** (72px) in the wide mode and behind
|
||||
the hover/tap popover in the compact mode — inline, the button is a
|
||||
plain mute toggle for every pointer type (`buttonClick(event,
|
||||
{ inlineSlider: true })`). Center: previous episode · −10s · **play** ·
|
||||
+10s · next episode. Right: the capability actions, end-aligned.
|
||||
- **Play button** (`.player-controls__play`, `data-test-id
|
||||
="player-controls-play"`): a 52px filled accent circle with a white glyph,
|
||||
not a Material icon button. Hover darkens the fill rather than lightening
|
||||
it so the glyph keeps ≥3:1 against it — `player-theme.e2e.ts` rasterizes
|
||||
exactly the hovered and focused states.
|
||||
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
|
||||
Material's `--mat-icon-button-*` tokens; their hover is a flat
|
||||
`rgba(255,255,255,.1)` layer.
|
||||
|
||||
### Stream info popover
|
||||
|
||||
An `info` button in the **top-right corner** of the overlay opens a popover with
|
||||
@@ -360,13 +401,18 @@ Per engine:
|
||||
no info affordance, though its backends plumb the properties for parity. See
|
||||
[embedded-mpv-native.md](./embedded-mpv-native.md#stream-stats-properties).
|
||||
|
||||
### Top scrim
|
||||
### Scrims
|
||||
|
||||
`.player-controls__top-scrim` is a single pointer-transparent gradient at the
|
||||
top of the player, mirroring the bottom bar's stops so both edges read as one
|
||||
system. It renders whenever there is top chrome to back — the fullscreen media
|
||||
title or the corner buttons — and fades with the controls without sliding (a
|
||||
moving scrim edge is visible against video in a way a moving control is not).
|
||||
top of the player (`max(28%, 112px)` tall), and
|
||||
`.player-controls__bottom-scrim` its mirror behind the dock (55% tall, from
|
||||
`rgba(4,7,11,.92)` at the edge through `.55` to transparent). Both read as
|
||||
one system. The top one renders whenever there is top chrome to back — the
|
||||
fullscreen media title or the corner buttons — the bottom one with the dock;
|
||||
both fade with the controls without sliding (a moving scrim edge is visible
|
||||
against video in a way a moving control is not). The dock itself has no
|
||||
background: the bottom scrim is the only thing between the controls and the
|
||||
picture.
|
||||
|
||||
One element, not a background per consumer: the title and the corner overlap,
|
||||
and two gradients would darken the overlap twice. The title therefore carries
|
||||
@@ -882,24 +928,30 @@ the last second (`wasTouchInteraction`). Three behaviors diverge from mouse:
|
||||
popover close (outside taps and other menu buttons dismiss it), and neither
|
||||
does the `focusout` of a pointer focus release.
|
||||
- **Coarse-pointer scrub sizing.** Under `@media (pointer: coarse)` the
|
||||
timeline/volume sliders grow their input hit strip to 28px and the thumb to
|
||||
18px; the 4px visual track is unchanged.
|
||||
timeline bar and the volume slider grow their hit strip to 28px and the
|
||||
volume thumb to 16px; the drawn tracks are unchanged.
|
||||
|
||||
### Narrow-player layout
|
||||
### Compact and wide layout
|
||||
|
||||
The controls host is a size query container (`player-controls`). At container
|
||||
widths of 640px and below — phone-sized PWA viewports, but also small inline
|
||||
players inside wide desktop windows — the single-row bar reflows to two rows:
|
||||
the timeline takes a full-width first row, and the transport and actions
|
||||
clusters split the second. The actions cluster's width is content-dependent
|
||||
(volume, audio, subtitles, quality, speed, aspect, recording, PiP, and
|
||||
fullscreen are all conditional), so in the narrow layout the cluster is
|
||||
end-aligned, capped at the row width, and wraps when even a dedicated row cannot
|
||||
hold it. Its popover anchors become static at this breakpoint so capability
|
||||
panels position against the unclipped actions cluster and remain accessible
|
||||
above every wrapped row. Icon buttons compact from 48px to 40px in this layout.
|
||||
Between ~640px and the 720px viewport media query, the legacy single-row squeeze
|
||||
(timeline absorbs the shrink) still applies.
|
||||
The controls host is a size query container (`player-controls`), and the
|
||||
dock has two modes split at **720px of container width**: `compact` at
|
||||
719px and below — phone-sized PWA viewports, but also small inline players
|
||||
inside wide desktop windows — and `wide` above. The split lives in two
|
||||
places that must agree: the `@container player-controls (max-width: 719px)`
|
||||
block in the stylesheet sizes the compact dock (14px gutters, 32px buttons,
|
||||
36px play circle, 5px track), and `ControlsLayout`
|
||||
(`COMPACT_LAYOUT_MAX_WIDTH`, a `ResizeObserver` on the host) drives the
|
||||
template branches CSS cannot express — today the inline volume slider versus
|
||||
its popover. Without `ResizeObserver` (unit tests) the mode stays `wide`.
|
||||
Episode navigation stays in the compact transport: the series hosts rely on
|
||||
those buttons, and the inline series player is often narrower than 720px.
|
||||
|
||||
The actions cluster's width is content-dependent (audio, subtitles, quality,
|
||||
speed, aspect, recording, PiP, and fullscreen are all conditional), so in
|
||||
the compact layout the cluster is end-aligned, capped at the row width, and
|
||||
wraps when the row cannot hold it. Its popover anchors become static at this
|
||||
breakpoint so capability panels position against the unclipped actions
|
||||
cluster and remain accessible above every wrapped row.
|
||||
|
||||
When a volume-capable controller first attaches, an existing `localStorage`
|
||||
volume preference is applied before the first controller snapshot can reconcile
|
||||
@@ -1385,6 +1437,8 @@ libs/ui/playback/src/lib/player-controls/
|
||||
├── player-controls.component.scss
|
||||
├── controls-feedback.ts
|
||||
├── controls-format.utils.ts
|
||||
├── controls-layout.ts
|
||||
├── controls-timeline-hover.ts
|
||||
├── controls-fullscreen.ts
|
||||
├── controls-menu-selection.ts
|
||||
├── controls-menu-state.ts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
formatRemainingTime,
|
||||
formatTime,
|
||||
persistVolume,
|
||||
readStoredVolume,
|
||||
@@ -18,6 +19,20 @@ describe('controls format utilities', () => {
|
||||
expect(formatTime(3661)).toBe('1:01:01');
|
||||
});
|
||||
|
||||
it('formats the remaining time with a minus sign, rounding up', () => {
|
||||
expect(formatRemainingTime(30, 600)).toBe('−9:30');
|
||||
expect(formatRemainingTime(599.2, 600)).toBe('−0:01');
|
||||
expect(formatRemainingTime(700, 600)).toBe('−0:00');
|
||||
expect(formatRemainingTime(-5, 60)).toBe('−1:00');
|
||||
});
|
||||
|
||||
it('has no remaining time without a finite positive duration', () => {
|
||||
expect(formatRemainingTime(30, null)).toBeNull();
|
||||
expect(formatRemainingTime(30, undefined)).toBeNull();
|
||||
expect(formatRemainingTime(30, 0)).toBeNull();
|
||||
expect(formatRemainingTime(30, Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps stored volume reads and persists raw volume values', () => {
|
||||
localStorage.setItem('volume', '2');
|
||||
expect(readStoredVolume()).toBe(1);
|
||||
|
||||
@@ -15,6 +15,29 @@ export function formatTime(value: number | null | undefined): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time left until the end, as `−7:03`. The dock shows it in place of the
|
||||
* total duration: it is the number a viewer actually wants at a glance.
|
||||
* Null when there is no finite duration to count down from.
|
||||
*/
|
||||
export function formatRemainingTime(
|
||||
positionSeconds: number,
|
||||
durationSeconds: number | null | undefined
|
||||
): string | null {
|
||||
if (
|
||||
typeof durationSeconds !== 'number' ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
durationSeconds - Math.max(0, positionSeconds)
|
||||
);
|
||||
return `−${formatTime(Math.ceil(remaining))}`;
|
||||
}
|
||||
|
||||
export function volumeIcon(value: number): string {
|
||||
if (value <= 0) {
|
||||
return 'volume_off';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { COMPACT_LAYOUT_MAX_WIDTH, ControlsLayout } from './controls-layout';
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
describe('ControlsLayout', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
let callbacks: ResizeCallback[];
|
||||
let observed: Element[];
|
||||
let disconnected: number;
|
||||
|
||||
function entry(width: number): ResizeObserverEntry {
|
||||
return {
|
||||
borderBoxSize: [{ inlineSize: width, blockSize: 100 }],
|
||||
contentRect: { width } as DOMRectReadOnly,
|
||||
} as unknown as ResizeObserverEntry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
callbacks = [];
|
||||
observed = [];
|
||||
disconnected = 0;
|
||||
class FakeResizeObserver {
|
||||
constructor(callback: ResizeCallback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
observe(target: Element): void {
|
||||
observed.push(target);
|
||||
}
|
||||
disconnect(): void {
|
||||
disconnected += 1;
|
||||
}
|
||||
unobserve(): void {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
FakeResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
it('starts wide and observes the host', () => {
|
||||
const layout = new ControlsLayout();
|
||||
const host = document.createElement('div');
|
||||
layout.attach(host);
|
||||
|
||||
expect(layout.mode()).toBe('wide');
|
||||
expect(observed).toEqual([host]);
|
||||
});
|
||||
|
||||
it('switches to compact at the breakpoint and back above it', () => {
|
||||
const layout = new ControlsLayout();
|
||||
layout.attach(document.createElement('div'));
|
||||
|
||||
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH)]);
|
||||
expect(layout.mode()).toBe('compact');
|
||||
|
||||
callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH + 1)]);
|
||||
expect(layout.mode()).toBe('wide');
|
||||
});
|
||||
|
||||
it('uses the last entry of a batch and ignores zero widths', () => {
|
||||
const layout = new ControlsLayout();
|
||||
layout.attach(document.createElement('div'));
|
||||
|
||||
callbacks[0]([entry(1200), entry(400)]);
|
||||
expect(layout.mode()).toBe('compact');
|
||||
|
||||
callbacks[0]([entry(0)]);
|
||||
expect(layout.mode()).toBe('compact');
|
||||
});
|
||||
|
||||
it('falls back to the content rect when border box sizes are missing', () => {
|
||||
const layout = new ControlsLayout();
|
||||
layout.attach(document.createElement('div'));
|
||||
|
||||
callbacks[0]([
|
||||
{
|
||||
contentRect: { width: 300 } as DOMRectReadOnly,
|
||||
} as unknown as ResizeObserverEntry,
|
||||
]);
|
||||
expect(layout.mode()).toBe('compact');
|
||||
});
|
||||
|
||||
it('disconnects on dispose and on re-attach', () => {
|
||||
const layout = new ControlsLayout();
|
||||
layout.attach(document.createElement('div'));
|
||||
layout.attach(document.createElement('div'));
|
||||
expect(disconnected).toBe(1);
|
||||
|
||||
layout.dispose();
|
||||
expect(disconnected).toBe(2);
|
||||
});
|
||||
|
||||
it('stays wide without ResizeObserver support', () => {
|
||||
globalThis.ResizeObserver =
|
||||
undefined as unknown as typeof ResizeObserver;
|
||||
const layout = new ControlsLayout();
|
||||
layout.attach(document.createElement('div'));
|
||||
|
||||
expect(layout.mode()).toBe('wide');
|
||||
expect(callbacks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Container width (px) below which the controls switch to the compact
|
||||
* layout: smaller buttons, volume behind a popover, secondary actions
|
||||
* folded away. Mirrors the `@container player-controls (max-width: 719px)`
|
||||
* query in the stylesheet, which owns the purely visual sizing; the signal
|
||||
* exists for the template branches CSS cannot express (which elements are
|
||||
* rendered at all).
|
||||
*/
|
||||
export const COMPACT_LAYOUT_MAX_WIDTH = 719;
|
||||
|
||||
export type ControlsLayoutMode = 'compact' | 'wide';
|
||||
|
||||
/**
|
||||
* Observes the controls host's own width and reports the layout mode. The
|
||||
* host fills the player box (`inset: 0`), so its width is the player's width
|
||||
* — a small inline player inside a wide desktop window is compact, a
|
||||
* fullscreen phone is compact, and a windowed desktop player is wide.
|
||||
*
|
||||
* Without `ResizeObserver` (unit tests, very old runtimes) the mode stays
|
||||
* `wide`, which renders every control and is the safe default.
|
||||
*/
|
||||
export class ControlsLayout {
|
||||
readonly mode = signal<ControlsLayoutMode>('wide');
|
||||
private observer: ResizeObserver | null = null;
|
||||
|
||||
attach(host: HTMLElement): void {
|
||||
this.detach();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this.observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[entries.length - 1];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const width =
|
||||
entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
this.applyWidth(width);
|
||||
});
|
||||
this.observer.observe(host);
|
||||
this.applyWidth(host.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
/** Exposed for hosts and tests that already know the width. */
|
||||
applyWidth(width: number): void {
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
return;
|
||||
}
|
||||
const next: ControlsLayoutMode =
|
||||
width <= COMPACT_LAYOUT_MAX_WIDTH ? 'compact' : 'wide';
|
||||
if (this.mode() !== next) {
|
||||
this.mode.set(next);
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ControlsTimelineHover,
|
||||
projectPointerToSeconds,
|
||||
} from './controls-timeline-hover';
|
||||
|
||||
describe('projectPointerToSeconds', () => {
|
||||
const rect = { left: 100, width: 200 };
|
||||
|
||||
it('maps the pointer across the bar to the duration', () => {
|
||||
expect(projectPointerToSeconds(100, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(200, rect, 600)).toBe(300);
|
||||
expect(projectPointerToSeconds(300, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('clamps positions outside the bar', () => {
|
||||
expect(projectPointerToSeconds(20, rect, 600)).toBe(0);
|
||||
expect(projectPointerToSeconds(999, rect, 600)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns null without a usable bar or duration', () => {
|
||||
expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe(
|
||||
null
|
||||
);
|
||||
expect(projectPointerToSeconds(150, rect, 0)).toBeNull();
|
||||
expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ControlsTimelineHover', () => {
|
||||
function createHover(duration = 600, interactive = true) {
|
||||
const durationSignal = signal(duration);
|
||||
const interactiveSignal = signal(interactive);
|
||||
const hover = new ControlsTimelineHover({
|
||||
duration: durationSignal,
|
||||
interactive: interactiveSignal,
|
||||
});
|
||||
const bar = document.createElement('div');
|
||||
bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect;
|
||||
return { hover, bar, durationSignal, interactiveSignal };
|
||||
}
|
||||
|
||||
function pointer(clientX: number, pointerType = 'mouse'): PointerEvent {
|
||||
return { clientX, pointerType } as PointerEvent;
|
||||
}
|
||||
|
||||
it('reports the hovered time, percent and label', () => {
|
||||
const { hover, bar } = createHover();
|
||||
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
expect(hover.seconds()).toBe(150);
|
||||
expect(hover.percent()).toBe(25);
|
||||
expect(hover.label()).toBe('2:30');
|
||||
});
|
||||
|
||||
it('clears on leave', () => {
|
||||
const { hover, bar } = createHover();
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
hover.clear();
|
||||
|
||||
expect(hover.seconds()).toBeNull();
|
||||
expect(hover.percent()).toBeNull();
|
||||
expect(hover.label()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores touch pointers and non-interactive timelines', () => {
|
||||
const { hover, bar, interactiveSignal } = createHover();
|
||||
|
||||
hover.move(pointer(100, 'touch'), bar);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
|
||||
interactiveSignal.set(false);
|
||||
hover.move(pointer(100), bar);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the bar from the event target when none is passed', () => {
|
||||
const { hover, bar } = createHover();
|
||||
|
||||
hover.move({ ...pointer(200), currentTarget: bar } as PointerEvent);
|
||||
expect(hover.seconds()).toBe(300);
|
||||
|
||||
hover.move({ ...pointer(200), currentTarget: null } as PointerEvent);
|
||||
expect(hover.seconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a stale hover once the duration disappears', () => {
|
||||
const { hover, bar, durationSignal } = createHover();
|
||||
hover.move(pointer(100), bar);
|
||||
|
||||
durationSignal.set(0);
|
||||
|
||||
expect(hover.percent()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
import { formatTime } from './controls-format.utils';
|
||||
|
||||
/**
|
||||
* Projects a pointer's horizontal position over the timeline bar onto a
|
||||
* playback time (seconds), clamped to the bar. Null when the bar has no
|
||||
* width or the duration is unknown.
|
||||
*/
|
||||
export function projectPointerToSeconds(
|
||||
clientX: number,
|
||||
rect: Pick<DOMRect, 'left' | 'width'>,
|
||||
durationSeconds: number
|
||||
): number | null {
|
||||
if (
|
||||
!Number.isFinite(clientX) ||
|
||||
!Number.isFinite(rect.width) ||
|
||||
rect.width <= 0 ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fraction = Math.min(
|
||||
1,
|
||||
Math.max(0, (clientX - rect.left) / rect.width)
|
||||
);
|
||||
return fraction * durationSeconds;
|
||||
}
|
||||
|
||||
export interface ControlsTimelineHoverDeps {
|
||||
duration: Signal<number>;
|
||||
/** Whether the timeline accepts pointer interaction at all. */
|
||||
interactive: Signal<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover state of the timeline: the time under the pointer, its position as
|
||||
* a percentage of the bar (for the marker and label), and the label text.
|
||||
* Touch never hovers, so a coarse pointer leaves this empty.
|
||||
*/
|
||||
export class ControlsTimelineHover {
|
||||
readonly seconds = signal<number | null>(null);
|
||||
|
||||
constructor(private readonly deps: ControlsTimelineHoverDeps) {}
|
||||
|
||||
readonly percent = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
const duration = this.deps.duration();
|
||||
if (seconds === null || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(100, Math.max(0, (seconds / duration) * 100));
|
||||
});
|
||||
|
||||
readonly label = computed(() => {
|
||||
const seconds = this.seconds();
|
||||
return seconds === null ? null : formatTime(seconds);
|
||||
});
|
||||
|
||||
/** Bound to `pointermove` on the bar element itself (`currentTarget`). */
|
||||
move(event: PointerEvent, bar?: HTMLElement): void {
|
||||
const target = bar ?? event.currentTarget;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!this.deps.interactive() ||
|
||||
event.pointerType === 'touch'
|
||||
) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this.seconds.set(
|
||||
projectPointerToSeconds(
|
||||
event.clientX,
|
||||
target.getBoundingClientRect(),
|
||||
this.deps.duration()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.seconds() !== null) {
|
||||
this.seconds.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,14 @@ export class ControlsVolumeInteractions {
|
||||
this.deps.volume.hoverLeave();
|
||||
}
|
||||
|
||||
buttonClick(event?: Event): void {
|
||||
/**
|
||||
* With the slider rendered inline next to the button (wide dock), there
|
||||
* is no popover to open first: the button is plainly a mute toggle for
|
||||
* every pointer type.
|
||||
*/
|
||||
buttonClick(event?: Event, options: { inlineSlider?: boolean } = {}): void {
|
||||
if (
|
||||
!options.inlineSlider &&
|
||||
this.deps.wasTouchInteraction(event) &&
|
||||
!this.deps.menus.volumeOpen()
|
||||
) {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('PlayerControlsComponent capability contract', () => {
|
||||
)?.at(-1) ?? '';
|
||||
|
||||
expect(PLAYER_CONTROLS_STYLE_SOURCE).toContain(
|
||||
'@container player-controls (max-width: 640px)'
|
||||
'@container player-controls (max-width: 719px)'
|
||||
);
|
||||
expect(narrowActionsRule).toContain('flex-wrap: wrap');
|
||||
expect(narrowActionsRule).toContain('overflow: visible');
|
||||
@@ -348,7 +348,7 @@ describe('PlayerControlsComponent capability contract', () => {
|
||||
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
root.querySelector('.player-controls__timeline > input')
|
||||
root.querySelector('.player-controls__slider--timeline')
|
||||
).toBeNull();
|
||||
expect(
|
||||
root.querySelector('.player-controls__live-badge')
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { WritableSignal, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { COMPACT_LAYOUT_MAX_WIDTH } from './controls-layout';
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
} from './player-controls-defaults';
|
||||
import { PlayerControlsComponent } from './player-controls.component';
|
||||
import type {
|
||||
PlayerControlsCapabilities,
|
||||
PlayerControlsCommands,
|
||||
PlayerControlsState,
|
||||
PlayerController,
|
||||
} from './player-controls.model';
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
function createFakeController() {
|
||||
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
});
|
||||
const state: WritableSignal<PlayerControlsState> = signal(
|
||||
createEmptyControlsState()
|
||||
);
|
||||
const commands: jest.Mocked<PlayerControlsCommands> = {
|
||||
togglePlay: jest.fn(),
|
||||
seekTo: jest.fn(),
|
||||
seekBy: jest.fn(),
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
addExternalSubtitleFile: jest.fn(),
|
||||
setSubtitleDelay: jest.fn(),
|
||||
setSubtitleStyle: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
togglePictureInPicture: jest.fn(),
|
||||
};
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Hybrid" dock: scrim backdrop, remaining time, timeline hover label,
|
||||
* the accent play button, and the compact/wide layout branches.
|
||||
*/
|
||||
describe('PlayerControlsComponent dock', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
let resizeCallbacks: ResizeCallback[];
|
||||
let fixture: ComponentFixture<PlayerControlsComponent>;
|
||||
let component: PlayerControlsComponent;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
|
||||
|
||||
const setCapabilities = (overrides: Partial<PlayerControlsCapabilities>) =>
|
||||
fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides });
|
||||
|
||||
const setState = (overrides: Partial<PlayerControlsState>) =>
|
||||
fake.state.set({ ...createEmptyControlsState(), ...overrides });
|
||||
|
||||
const resizeTo = (width: number) => {
|
||||
for (const callback of resizeCallbacks) {
|
||||
callback([
|
||||
{
|
||||
borderBoxSize: [{ inlineSize: width, blockSize: 300 }],
|
||||
contentRect: { width } as DOMRectReadOnly,
|
||||
} as unknown as ResizeObserverEntry,
|
||||
]);
|
||||
}
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resizeCallbacks = [];
|
||||
class FakeResizeObserver {
|
||||
constructor(callback: ResizeCallback) {
|
||||
resizeCallbacks.push(callback);
|
||||
}
|
||||
observe(): void {
|
||||
/* noop */
|
||||
}
|
||||
unobserve(): void {
|
||||
/* noop */
|
||||
}
|
||||
disconnect(): void {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
FakeResizeObserver as unknown as typeof ResizeObserver;
|
||||
localStorage.removeItem('volume');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
PLAY: 'Play',
|
||||
PAUSE: 'Pause',
|
||||
PREVIOUS_EPISODE: 'Previous episode',
|
||||
NEXT_EPISODE: 'Next episode',
|
||||
BACK_10_SECONDS: 'Back 10 seconds',
|
||||
FORWARD_10_SECONDS: 'Forward 10 seconds',
|
||||
MUTE: 'Mute',
|
||||
UNMUTE: 'Unmute',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fake = createFakeController();
|
||||
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controller', fake.controller);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
it('backs the dock with a bottom scrim that fades with the controls', () => {
|
||||
const scrim = query('[data-test-id="player-controls-bottom-scrim"]');
|
||||
expect(scrim).not.toBeNull();
|
||||
expect(scrim?.classList).toContain(
|
||||
'player-controls__bottom-scrim--visible'
|
||||
);
|
||||
expect(scrim?.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
fixture.componentRef.setInput('showControls', false);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
query('[data-test-id="player-controls-bottom-scrim"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the accent play button with Play/Pause names', () => {
|
||||
const play = query('[data-test-id="player-controls-play"]');
|
||||
expect(play?.tagName).toBe('BUTTON');
|
||||
expect(play?.getAttribute('aria-label')).toBe('Play');
|
||||
expect(play?.querySelector('mat-icon')?.textContent).toBe('play_arrow');
|
||||
|
||||
setState({ status: 'playing' });
|
||||
fixture.detectChanges();
|
||||
expect(play?.getAttribute('aria-label')).toBe('Pause');
|
||||
expect(play?.querySelector('mat-icon')?.textContent).toBe('pause');
|
||||
|
||||
play?.click();
|
||||
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('orders the wide transport as previous, back, play, forward, next', () => {
|
||||
setCapabilities({ seek: true, seriesNavigation: true });
|
||||
setState({ canSeek: true, durationSeconds: 100 });
|
||||
fixture.detectChanges();
|
||||
|
||||
const labels = Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.player-controls__transport button'
|
||||
) as NodeListOf<HTMLElement>
|
||||
).map((button) => button.getAttribute('aria-label'));
|
||||
expect(labels).toEqual([
|
||||
'Previous episode',
|
||||
'Back 10 seconds',
|
||||
'Play',
|
||||
'Forward 10 seconds',
|
||||
'Next episode',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('timeline row', () => {
|
||||
it('shows the remaining time instead of the total duration', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 600,
|
||||
positionSeconds: 30,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.player-controls__time--current')?.textContent).toBe(
|
||||
'0:30'
|
||||
);
|
||||
expect(
|
||||
query('[data-test-id="player-controls-remaining-time"]')
|
||||
?.textContent
|
||||
).toBe('−9:30');
|
||||
expect(query('.player-controls__live-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows the scrub preview in the remaining time', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 600,
|
||||
positionSeconds: 30,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const slider = query(
|
||||
'.player-controls__slider--timeline'
|
||||
) as HTMLInputElement;
|
||||
slider.value = '540';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
query('[data-test-id="player-controls-remaining-time"]')
|
||||
?.textContent
|
||||
).toBe('−1:00');
|
||||
});
|
||||
|
||||
it('shows a placeholder for unknown durations and LIVE for live streams', () => {
|
||||
setState({ canSeek: false, isLive: false });
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__time--end')?.textContent).toBe(
|
||||
'--:--'
|
||||
);
|
||||
|
||||
setState({ canSeek: false, isLive: true });
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__time--end')).toBeNull();
|
||||
expect(query('.player-controls__live-badge')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws the progress fill and knob from the timeline progress', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 200,
|
||||
positionSeconds: 50,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.player-controls__timeline-fill')?.style.width).toBe(
|
||||
'25%'
|
||||
);
|
||||
expect(query('.player-controls__timeline-knob')?.style.left).toBe(
|
||||
'25%'
|
||||
);
|
||||
|
||||
setState({
|
||||
canSeek: false,
|
||||
durationSeconds: 200,
|
||||
positionSeconds: 50,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__timeline-knob')).toBeNull();
|
||||
expect(
|
||||
query('.player-controls__timeline-bar')?.classList
|
||||
).toContain('player-controls__timeline-bar--disabled');
|
||||
});
|
||||
|
||||
it('shows the hovered time above the bar and clears it on leave', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({
|
||||
canSeek: true,
|
||||
durationSeconds: 400,
|
||||
positionSeconds: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const bar = query('.player-controls__timeline-bar') as HTMLElement;
|
||||
bar.getBoundingClientRect = () =>
|
||||
({ left: 100, width: 200 }) as DOMRect;
|
||||
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 150, bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const label = query(
|
||||
'[data-test-id="player-controls-timeline-label"]'
|
||||
);
|
||||
expect(label?.textContent?.trim()).toBe('1:40');
|
||||
expect(label?.style.getPropertyValue('--hover-x')).toBe('25%');
|
||||
expect(query('.player-controls__timeline-marker')?.style.left).toBe(
|
||||
'25%'
|
||||
);
|
||||
expect(fake.commands.seekTo).not.toHaveBeenCalled();
|
||||
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointerleave', { bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
query('[data-test-id="player-controls-timeline-label"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not hover-label a non-seekable timeline', () => {
|
||||
setCapabilities({ seek: true });
|
||||
setState({ canSeek: false, durationSeconds: 400 });
|
||||
fixture.detectChanges();
|
||||
|
||||
const bar = query('.player-controls__timeline-bar') as HTMLElement;
|
||||
bar.getBoundingClientRect = () =>
|
||||
({ left: 0, width: 200 }) as DOMRect;
|
||||
bar.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 50, bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
query('[data-test-id="player-controls-timeline-label"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout modes', () => {
|
||||
beforeEach(() => {
|
||||
setCapabilities({ volume: true });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('starts wide with the volume slider inline', () => {
|
||||
expect(component.isCompact()).toBe(false);
|
||||
expect(query('.player-controls__bar')?.classList).not.toContain(
|
||||
'player-controls__bar--compact'
|
||||
);
|
||||
expect(query('.player-controls__slider--inline')).not.toBeNull();
|
||||
expect(query('.player-controls__volume-popover')).toBeNull();
|
||||
});
|
||||
|
||||
it('mutes on a wide volume button click without opening a popover', () => {
|
||||
component.volumeInteractions.hoverEnter();
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__volume-popover')).toBeNull();
|
||||
|
||||
(query('[aria-label="Mute"]') as HTMLButtonElement).dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
|
||||
expect(query('[aria-label="Unmute"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('moves the volume slider behind a popover once compact', () => {
|
||||
resizeTo(COMPACT_LAYOUT_MAX_WIDTH);
|
||||
|
||||
expect(component.isCompact()).toBe(true);
|
||||
expect(query('.player-controls__bar')?.classList).toContain(
|
||||
'player-controls__bar--compact'
|
||||
);
|
||||
expect(query('.player-controls__slider--inline')).toBeNull();
|
||||
expect(query('.player-controls__volume-popover')).toBeNull();
|
||||
|
||||
component.volumeInteractions.hoverEnter();
|
||||
fixture.detectChanges();
|
||||
expect(query('.player-controls__volume-popover')).not.toBeNull();
|
||||
|
||||
resizeTo(COMPACT_LAYOUT_MAX_WIDTH + 200);
|
||||
expect(component.isCompact()).toBe(false);
|
||||
expect(query('.player-controls__slider--inline')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps episode navigation reachable in the compact transport', () => {
|
||||
setCapabilities({ volume: true, seriesNavigation: true });
|
||||
resizeTo(400);
|
||||
|
||||
expect(
|
||||
query('[data-test-id="player-controls-next-episode"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
query('[data-test-id="player-controls-previous-episode"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+10
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user