diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 3c73022f9..a116665ad 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -289,6 +289,10 @@ It owns only transient presentation behavior: - `ControlsSurface` — pointer/click/double-click surface interactions; - `ControlsTimeline` — scrub state and timeline projections; - `ControlsTimelineHover` — the time under the pointer over the timeline; +- `app-player-timeline` — presentation of the timeline row (current time, + segment track, knob, hover label, remaining time / LIVE, recording + status); scrub `input`/`change` events go back to the controls component, + which owns reveal and seeking; - `ControlsLayout` — the compact/wide dock mode from the host's width; - `ControlsSettings` — the settings panel's groups, on/modified state and open/close transitions (`controls-settings-groups.ts` holds the pure @@ -369,13 +373,18 @@ bottom-right corner above the controls: the next episode's still (or its watched, "Up next · in 7 min" and the title. The host supplies the item through the optional `upNext` input (`PlayerUpNextItem { label, title, thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows — -`seriesNavigation` capability, `canNextEpisode`, a finite duration with at +`seriesNavigation` capability, a finite duration with at most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, not `ended` (with autoplay off nothing is scheduled, so no countdown), controls shown, settings panel closed — and how many minutes remain (never below one). A still that fails to load falls back to the label tile. A -click emits `nextEpisodeRequested`, the same output the hosts already -handle, so the switch keeps fullscreen exactly like the transport button. +click emits `nextEpisodeRequested` directly — not through the +transport's `canNextEpisode` guard, which is season-local — so the card +also works at a season's last episode. `PortalInlinePlayerComponent` +routes such a request through the Up Next rail selection +(`upNextEpisodeSelected`) whenever `seriesNavigation.canNext` is false, which +plays the next season's first episode; either path keeps fullscreen exactly +like the transport button. The card is a glass surface that does not fade with the controls; the compact dock uses a smaller variant without the trailing icon. @@ -426,7 +435,12 @@ the panel the moment the last group disappears. the load-file action and the delay / size / color sections that the popover used to hold (same `player-controls-load-subtitle`, `player-controls-subtitle-delay`, `player-controls-subtitle-style` test - ids). A choice applies immediately and **keeps the panel open** — + ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from + the keyboard (the opener is `:focus-visible`) it takes focus, a pointer + open leaves focus alone (a focused control would capture Space from the + shortcuts), and closing with focus inside returns it to `tune`. While + the compact sheet replaces the dock, the dock is `inert`, so hidden + controls leave the tab order. A choice applies immediately and **keeps the panel open** — `ControlsMenuSelection` no longer closes anything — so alternatives can be compared against the running video; Escape, the close button, the `tune` button, a click on the video surface or an outside pointerdown close it. @@ -1558,6 +1572,9 @@ libs/ui/playback/src/lib/player-controls/ ├── controls-settings.ts ├── controls-settings-groups.ts ├── controls-up-next.ts +├── player-timeline.component.ts +├── player-timeline.component.html +├── player-timeline.component.scss ├── player-up-next-card.component.ts ├── player-up-next-card.component.html ├── player-up-next-card.component.scss diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts index 33b66dad2..69ba70311 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -80,17 +80,19 @@ describe('ControlsUpNext', () => { expect(upNext.minutesLeft()).toBe(1); }); - it('needs a next episode, a finite duration and a series-capable engine', () => { + it('needs a supplied episode, a finite duration and a series-capable engine', () => { item.set(null); expect(upNext.visible()).toBe(false); item.set(next); + // The host's item is authoritative: at a season's last episode the + // transport cannot step forward, but the next season's first can. setState({ canNextEpisode: false, durationSeconds: 1200, positionSeconds: 1100, }); - expect(upNext.visible()).toBe(false); + expect(upNext.visible()).toBe(true); setState({ canNextEpisode: true, diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts index aac4a688a..0b13db46f 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -18,9 +18,10 @@ export interface ControlsUpNextDeps { } /** - * When the "Up next" card shows: a series host supplied the next episode, - * the engine can actually switch to it, the episode has a known length, - * and no more than the threshold of it is left. Live streams and open-ended + * When the "Up next" card shows: a series host supplied the next episode + * (possibly the first of the following season, which the transport's + * season-local `canNextEpisode` does not cover), the engine plays series, + * the episode has a known length, and no more than the threshold is left. Live streams and open-ended * VOD never qualify because they have no remaining time to count down. */ export class ControlsUpNext { @@ -50,7 +51,6 @@ export class ControlsUpNext { // An ended episode with autoplay off schedules no switch: a // countdown would promise one. Autoplay replaces the playback. state.status === 'ended' || - !state.canNextEpisode || !this.deps.capabilities().seriesNavigation || !this.deps.showControls() || this.deps.settingsOpen() 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 3b1d4a3ab..d1318b98c 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 @@ -108,7 +108,7 @@ describe('PlayerControlsComponent capability contract', () => { return event.defaultPrevented; } - it('keeps narrow-player popovers outside clipping scroll containers', () => { + it('lets the compact action cluster wrap instead of clipping or scrolling', () => { const narrowActionsRule = PLAYER_CONTROLS_STYLE_SOURCE.match( /\.player-controls__actions\s*\{([\s\S]*?)\}/g @@ -120,9 +120,6 @@ describe('PlayerControlsComponent capability contract', () => { expect(narrowActionsRule).toContain('flex-wrap: wrap'); expect(narrowActionsRule).toContain('overflow: visible'); expect(narrowActionsRule).not.toContain('overflow-x: auto'); - expect(PLAYER_CONTROLS_STYLE_SOURCE).toMatch( - /\.player-controls__actions \.player-controls__popover-anchor\s*\{\s*position:\s*static;/ - ); }); it('routes shortcuts to the player the user interacted with most recently', () => { 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 index 50f514890..57e0313fd 100644 --- 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 @@ -443,6 +443,31 @@ describe('PlayerControlsComponent dock', () => { expect(next).toHaveBeenCalledTimes(1); }); + it('offers the next season across the season boundary', () => { + setState({ + canSeek: true, + canNextEpisode: false, + durationSeconds: 1200, + positionSeconds: 1200 - 5 * 60, + }); + fixture.detectChanges(); + const card = query('[data-test-id="player-controls-up-next"]'); + expect(card).not.toBeNull(); + // The transport's next button is disabled, but the card still + // hands the request to the host, which picks the next season. + expect( + ( + query( + '[data-test-id="player-controls-next-episode"]' + ) as HTMLButtonElement + ).disabled + ).toBe(true); + const next = jest.fn(); + component.nextEpisodeRequested.subscribe(next); + card?.click(); + expect(next).toHaveBeenCalledTimes(1); + }); + it('yields to the settings panel and needs a next episode', () => { nearTheEnd(); fixture.detectChanges(); 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 3036acb54..60acd35e0 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 @@ -125,6 +125,7 @@ [class.player-controls__bar--panel-open]=" settings.isOpen() && layout.roomy() " + [attr.inert]="settings.isOpen() && !layout.roomy() ? '' : null" [class.player-controls__bar--sheet-open]=" settings.isOpen() && !layout.roomy() " @@ -136,123 +137,18 @@ (focusin)="chrome.onFocusIn($event)" (focusout)="chrome.onFocusOut($event)" > -
- {{ formatTime(timelineValue()) }} - @if (capabilities().seek) { -
- - @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) { -
- {{ - isRecording() ? 'fiber_manual_record' : 'check_circle' - }} - {{ recordingStatus }} -
- } -
+
@@ -698,7 +594,7 @@ [item]="item" [minutesLeft]="upNextCard.minutesLeft()" [compact]="isCompact()" - (selected)="requestNextEpisode()" + (selected)="reveal(); nextEpisodeRequested.emit()" (pointerenter)="chrome.onPointerEnter()" (pointerleave)="chrome.onPointerLeave()" (pointerdown)="chrome.onPointerDown()" 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 24476532b..480d874ce 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 @@ -264,251 +264,6 @@ pointer-events: none; } -// Placement of the settings surface. The panel component styles its inside. -.player-controls__settings { - position: absolute; - top: 16px; - right: 16px; - bottom: 16px; - z-index: 4; - width: 330px; - max-width: calc(100cqw - 32px); - animation: player-controls-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -.player-controls__settings--sheet { - top: auto; - right: 8px; - bottom: 0; - left: 8px; - width: auto; - max-width: none; - max-height: min(70%, calc(100cqh - 24px)); - animation: player-controls-sheet-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -// "Up next" card: bottom-right, above the dock. It does not fade with the -// controls — near the end of an episode it is the thing worth seeing. -.player-controls__up-next { - position: absolute; - right: 32px; - bottom: 136px; - z-index: 3; - max-width: calc(100cqw - 64px); - animation: player-controls-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -.player-controls__up-next--compact { - right: 12px; - bottom: 92px; - max-width: calc(100cqw - 24px); -} - -@keyframes player-controls-panel-in { - from { - opacity: 0; - transform: translateX(24px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes player-controls-sheet-in { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -@media (prefers-reduced-motion: reduce) { - .player-controls__settings, - .player-controls__settings--sheet { - animation: none; - } -} - -// --- Timeline row ----------------------------------------------------------- - -.player-controls__timeline { - display: flex; - align-items: center; - gap: 14px; - min-width: 0; -} - -.player-controls__time { - flex: 0 0 auto; - color: var(--pc-text); - font-family: var(--pc-font-mono); - font-size: 14px; - font-weight: 500; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.player-controls__time--end { - color: var(--pc-text-secondary); -} - -.player-controls__timeline-spacer { - flex: 1 1 auto; -} - -// Pointer target of the bar. Taller than the 6px track so it is easy to hit; -// the transparent range input on top owns dragging and the keyboard. -.player-controls__timeline-bar { - position: relative; - flex: 1 1 auto; - min-width: 0; - height: 18px; - cursor: pointer; -} - -.player-controls__timeline-bar--disabled { - cursor: default; -} - -.player-controls__timeline-track { - position: absolute; - top: 50%; - right: 0; - left: 0; - height: 6px; - transform: translateY(-50%); -} - -// Segments sit at their exact time positions (`left` = start, `width` = -// share, minus the 3px gap at each segment's end), so a drawn boundary is -// where the linear seek input and the hover label change segment. A segment -// shorter than the gap simply collapses rather than shifting its neighbours. -.player-controls__timeline-segment { - position: absolute; - top: 0; - bottom: 0; - overflow: hidden; - border-radius: 3px; - background: var(--pc-track); -} - -.player-controls__timeline-fill { - height: 100%; - background: var(--pc-accent-blue); - border-radius: inherit; -} - -.player-controls__timeline-knob { - position: absolute; - top: 50%; - width: 14px; - height: 14px; - margin: -7px 0 0 -7px; - border-radius: 50%; - background: #ffffff; - box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3); - pointer-events: none; - transition: transform 120ms ease-out; -} - -.player-controls__timeline-bar:hover .player-controls__timeline-knob, -.player-controls__timeline-bar:has(.player-controls__slider:focus-visible) - .player-controls__timeline-knob { - transform: scale(1.15); -} - -.player-controls__timeline-marker { - position: absolute; - top: 50%; - width: 2px; - height: 12px; - margin: -6px 0 0 -1px; - border-radius: 1px; - background: #ffffff; - pointer-events: none; -} - -.player-controls__timeline-label { - --hover-x: 0%; - position: absolute; - bottom: calc(100% + 2px); - // Centered on the pointer, but never past the bar's ends. - left: clamp(28px, var(--hover-x), calc(100% - 28px)); - transform: translateX(-50%); - padding: 5px 8px; - color: var(--pc-text); - background: rgba(12, 16, 23, 0.85); - border-radius: 6px; - font-size: 11.5px; - font-weight: 600; - font-variant-numeric: tabular-nums; - white-space: nowrap; - pointer-events: none; -} - -.player-controls__recording-status { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - color: var(--pc-text-dim); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; -} - -.player-controls__recording-status mat-icon { - flex: 0 0 auto; - width: 16px; - height: 16px; - font-size: 16px; - color: #ffffff; -} - -.player-controls__recording-status span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.player-controls__recording-status--active { - color: var(--mat-sys-error, #ff5252); - font-weight: 700; -} - -.player-controls__recording-status--active mat-icon { - color: var(--mat-sys-error, #ff5252); -} - -.player-controls__live-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 4px; - color: #ffffff; - background: var(--mat-sys-error, #e53935); - font-size: 0.66rem; - font-weight: 700; - letter-spacing: 0.08em; -} - -.player-controls__live-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - animation: player-controls-live-pulse 1.6s ease-in-out infinite; -} - -@keyframes player-controls-live-pulse { - 50% { - opacity: 0.35; - } -} - // --- Control row ------------------------------------------------------------ // Each side column keeps at least its own content width: when the action @@ -635,7 +390,6 @@ :host :is(.player-controls__tune--active:hover) { color: #ffffff; background: var(--pc-accent-blue); - --mdc-icon-button-icon-color: #ffffff; --mat-icon-button-icon-color: #ffffff; } @@ -721,12 +475,9 @@ // 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; @@ -738,7 +489,6 @@ :host :is(button[mat-icon-button][disabled]) { // Keep disabled affordances visibly muted. - --mdc-icon-button-icon-color: var(--pc-text-tertiary); --mat-icon-button-icon-color: var(--pc-text-tertiary); color: var(--pc-text-tertiary); } @@ -842,28 +592,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; @@ -980,18 +708,13 @@ font-size: 0.8rem; } -// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse -// slider; the drawn track is unchanged — only the input's touch area and the -// volume thumb grow. +// Touch needs a taller hit strip and thumb than the 18px mouse slider; the +// drawn track is unchanged. The timeline row sizes its own strip. @media (pointer: coarse) { .player-controls__slider { height: 28px; } - .player-controls__timeline-bar { - height: 28px; - } - .player-controls__slider::-webkit-slider-thumb { width: 16px; height: 16px; @@ -1030,25 +753,6 @@ gap: 6px; } - .player-controls__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; } @@ -1082,13 +786,6 @@ overflow: visible; } - // 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: 32px; @@ -1096,11 +793,8 @@ 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 { diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts index 9b5be47cb..73af20310 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -396,6 +396,57 @@ describe('PlayerControlsComponent settings panel', () => { ).not.toBeNull(); }); + it('makes the hidden dock inert and moves keyboard focus into the sheet and back', async () => { + const tune = query( + '[data-test-id="player-controls-settings-button"]' + ) as HTMLButtonElement; + document.body.appendChild(fixture.nativeElement); + tune.focus(); + const matches = tune.matches.bind(tune); + tune.matches = (selector: string) => + selector === ':focus-visible' || matches(selector); + + tune.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const sheet = query( + '[data-test-id="player-controls-settings-panel"]' + ) as HTMLElement; + expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe( + true + ); + expect(document.activeElement).toBe(sheet); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe( + false + ); + expect(document.activeElement).toBe( + query('[data-test-id="player-controls-settings-button"]') + ); + fixture.nativeElement.remove(); + }); + + it('leaves focus alone when a pointer opens the sheet', async () => { + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(document.activeElement).not.toBe( + query('[data-test-id="player-controls-settings-panel"]') + ); + }); + it('opens a bottom sheet that replaces the dock and keeps PiP reachable after close', () => { query('[data-test-id="player-controls-settings-button"]')?.click(); fixture.detectChanges(); 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 56d09e67f..477898bec 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 @@ -32,7 +32,7 @@ import { createControlsViewModel } from './controls-view-model'; import { ControlsVolume } from './controls-volume'; import { ControlsVolumeInteractions } from './controls-volume-interactions'; import { ControlsSubtitleSettings } from './controls-subtitle-settings'; -import { formatRemainingTime, formatTime } from './controls-format.utils'; +import { formatRemainingTime } from './controls-format.utils'; import type { PlayerController, PlayerMediaTitle, @@ -40,6 +40,7 @@ import type { PlayerUpNextItem, } from './player-controls.model'; import { PlayerSettingsPanelComponent } from './player-settings-panel.component'; +import { PlayerTimelineComponent } from './player-timeline.component'; import { PlayerUpNextCardComponent } from './player-up-next-card.component'; @Component({ @@ -52,6 +53,7 @@ import { PlayerUpNextCardComponent } from './player-up-next-card.component'; MatTooltipModule, TranslatePipe, PlayerSettingsPanelComponent, + PlayerTimelineComponent, PlayerUpNextCardComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -162,11 +164,8 @@ export class PlayerControlsComponent implements OnDestroy { private readonly controllerVolume = computed(() => this.state().volume); readonly timeline = new ControlsTimeline(this.state, this.timelineSegments); readonly scrubPosition = this.timeline.scrubPosition; - readonly timelineDuration = this.timeline.duration; - readonly timelineValue = this.timeline.value; - readonly timelineProgress = this.timeline.progress; readonly timelineHover = new ControlsTimelineHover({ - duration: this.timelineDuration, + duration: this.timeline.duration, interactive: computed( () => this.capabilities().seek && this.state().canSeek ), @@ -174,7 +173,7 @@ export class PlayerControlsComponent implements OnDestroy { }); /** `−7:03` while a finite duration is known; the dock prefers it to the total. */ readonly remainingTimeText = computed(() => - formatRemainingTime(this.timelineValue(), this.timelineDuration()) + formatRemainingTime(this.timeline.value(), this.timeline.duration()) ); readonly displayVolume = this.volume.value; @@ -331,7 +330,6 @@ export class PlayerControlsComponent implements OnDestroy { this.surface.dispose(); this.streamStats.dispose(); } - formatTime = formatTime; togglePlay(): void { this.reveal(); if (!this.canTogglePlay()) { diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss index 55e3bf2ba..071d856d8 100644 --- a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss @@ -1,8 +1,16 @@ // The settings panel / bottom sheet. Reads the `--pc-*` palette that the -// controls host declares; the host also positions this element, so nothing -// here knows whether it sits beside the video or rises from its bottom edge -// beyond the `--sheet` modifier's density changes. +// controls host declares and places itself inside that host (the +// `player-controls` container): beside the video as a panel, or rising from +// the bottom edge as a sheet. :host { + position: absolute; + top: 16px; + right: 16px; + bottom: 16px; + z-index: 4; + width: 330px; + max-width: calc(100cqw - 32px); + animation: player-settings-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); display: flex; flex-direction: column; gap: 18px; @@ -18,7 +26,25 @@ pointer-events: auto; } +:host(:focus) { + outline: none; +} + +:host(:focus-visible) { + box-shadow: + 0 0 0 2px var(--pc-text, #e7ecf3), + 0 12px 32px rgba(0, 0, 0, 0.45); +} + :host(.player-settings--sheet) { + top: auto; + right: 8px; + bottom: 0; + left: 8px; + width: auto; + max-width: none; + max-height: min(70%, calc(100cqh - 24px)); + animation-name: player-settings-sheet-in; gap: 9px; padding: 10px 14px 12px; border-bottom: 0; @@ -59,14 +85,10 @@ height: 32px; padding: 4px; color: var(--pc-text, #e7ecf3); - --mdc-icon-button-icon-color: var(--pc-text, #e7ecf3); --mat-icon-button-icon-color: var(--pc-text, #e7ecf3); --mat-icon-button-state-layer-size: 32px; - --mdc-icon-button-state-layer-size: 32px; --mat-icon-button-container-shape: 10px; - --mdc-icon-button-container-shape: 10px; --mat-icon-button-icon-size: 20px; - --mdc-icon-button-icon-size: 20px; --mat-icon-button-hover-state-layer-opacity: 0; border-radius: 10px; } @@ -85,7 +107,6 @@ :host :is(button[mat-icon-button][disabled]), :host :is(button[mat-icon-button][disabled]) mat-icon { color: var(--pc-text-tertiary, #6b7384); - --mdc-icon-button-icon-color: var(--pc-text-tertiary, #6b7384); --mat-icon-button-icon-color: var(--pc-text-tertiary, #6b7384); } @@ -94,7 +115,6 @@ height: 26px; padding: 3px; --mat-icon-button-state-layer-size: 26px; - --mdc-icon-button-state-layer-size: 26px; } // Single scroll owner: on a short player the whole content scrolls, never @@ -400,3 +420,29 @@ margin-top: 2px; padding-top: 6px; } + +@keyframes player-settings-panel-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes player-settings-sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + :host { + animation: none; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts index ef7e72a94..deb45bdda 100644 --- a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts @@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, ElementRef, + OnDestroy, + afterNextRender, computed, effect, inject, @@ -42,11 +44,14 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet'; host: { class: 'player-settings', role: 'dialog', + tabindex: '-1', '[class.player-settings--sheet]': 'mode() === "sheet"', '[attr.aria-label]': 'title()', + '(focusin)': 'focusInside = true', + '(focusout)': 'onFocusOut($event)', }, }) -export class PlayerSettingsPanelComponent { +export class PlayerSettingsPanelComponent implements OnDestroy { private readonly host: HTMLElement = inject>(ElementRef).nativeElement; readonly controller = input.required(); @@ -68,7 +73,27 @@ export class PlayerSettingsPanelComponent { readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS; readonly subtitleDelayLabel = subtitleDelayLabel; + /** The control that opened the panel, and whether the keyboard did. */ + private readonly opener = document.activeElement; + private readonly openedByKeyboard = + this.opener instanceof HTMLElement && + this.opener !== document.body && + !!this.opener.closest('.player-controls-host') && + matchesFocusVisible(this.opener); + focusInside = false; + /** Recorded after render: on destroy the panel is already detached. */ + private controlsHost: Element | null = null; + constructor() { + // Keyboard users land inside the dialog; a pointer open leaves focus + // alone (a focused control would capture Space from the shortcuts). + afterNextRender(() => { + this.controlsHost = this.host.closest('.player-controls-host'); + if (this.openedByKeyboard) { + this.host.focus({ preventScroll: true }); + this.focusInside = this.host.contains(document.activeElement); + } + }); // A chip click opens the panel on its group: bring that group into // view so a long audio list cannot push the speed row off-screen. effect(() => { @@ -80,6 +105,30 @@ export class PlayerSettingsPanelComponent { }); } + onFocusOut(event: FocusEvent): void { + const next = event.relatedTarget; + this.focusInside = next instanceof Node && this.host.contains(next); + } + + /** + * Closing with focus inside (Escape, the close button by keyboard) + * returns it to the `tune` button rather than dropping it on the page. + * The chip that may have opened the panel is re-rendered on close, so + * `tune` is the stable target. Tracked by flag, not `activeElement`: + * the view's DOM is already detached when this hook runs. + */ + ngOnDestroy(): void { + if (!this.focusInside) { + return; + } + const controls = this.controlsHost; + queueMicrotask(() => + controls + ?.querySelector('.player-controls__tune') + ?.focus({ preventScroll: true }) + ); + } + isFocused(group: SettingsGroup): boolean { return this.focusGroup() === group; } @@ -101,3 +150,13 @@ export class PlayerSettingsPanelComponent { } } } + +function matchesFocusVisible(element: Element | null): boolean { + try { + return ( + element instanceof HTMLElement && element.matches(':focus-visible') + ); + } catch { + return false; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html new file mode 100644 index 000000000..b28e252c4 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html @@ -0,0 +1,98 @@ +{{ + formatTime(timeline().value()) +}} +@if (seekable()) { +
+ + @if (canSeek()) { + + } + @if (hover().percent() !== null) { + + + } + +
+} @else { +
+} +@if (canSeek() && remaining(); as remaining) { + {{ remaining }} +} @else if (isLive()) { + + + LIVE + +} @else { + --:-- +} +@if (recordingStatus(); as status) { +
+ {{ + recording() ? 'fiber_manual_record' : 'check_circle' + }} + {{ status }} +
+} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss new file mode 100644 index 000000000..bd6275d44 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -0,0 +1,233 @@ +// The dock's timeline row. Reads the `--pc-*` palette the controls host +// declares; the host is the `player-controls` size container queried below. +:host { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.player-controls__time { + flex: 0 0 auto; + color: var(--pc-text); + font-family: var(--pc-font-mono); + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.player-controls__time--end { + color: var(--pc-text-secondary); +} + +.player-controls__timeline-spacer { + flex: 1 1 auto; +} + +// Pointer target of the bar. Taller than the 6px track so it is easy to hit; +// the transparent range input on top owns dragging and the keyboard. +.player-controls__timeline-bar { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 18px; + cursor: pointer; +} + +.player-controls__timeline-bar--disabled { + cursor: default; +} + +.player-controls__timeline-track { + position: absolute; + top: 50%; + right: 0; + left: 0; + height: 6px; + transform: translateY(-50%); +} + +// Segments sit at their exact time positions (`left` = start, `width` = +// share, minus the 3px gap at each segment's end), so a drawn boundary is +// where the linear seek input and the hover label change segment. A segment +// shorter than the gap simply collapses rather than shifting its neighbours. +.player-controls__timeline-segment { + position: absolute; + top: 0; + bottom: 0; + overflow: hidden; + border-radius: 3px; + background: var(--pc-track); +} + +.player-controls__timeline-fill { + height: 100%; + background: var(--pc-accent-blue); + border-radius: inherit; +} + +.player-controls__timeline-knob { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3); + pointer-events: none; + transition: transform 120ms ease-out; +} + +.player-controls__timeline-bar:hover .player-controls__timeline-knob, +.player-controls__timeline-bar:has(.player-controls__slider:focus-visible) + .player-controls__timeline-knob { + transform: scale(1.15); +} + +.player-controls__timeline-marker { + position: absolute; + top: 50%; + width: 2px; + height: 12px; + margin: -6px 0 0 -1px; + border-radius: 1px; + background: #ffffff; + pointer-events: none; +} + +.player-controls__timeline-label { + --hover-x: 0%; + position: absolute; + bottom: calc(100% + 2px); + // Centered on the pointer, but never past the bar's ends. + left: clamp(28px, var(--hover-x), calc(100% - 28px)); + transform: translateX(-50%); + padding: 5px 8px; + color: var(--pc-text); + background: rgba(12, 16, 23, 0.85); + border-radius: 6px; + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +.player-controls__recording-status { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--pc-text-dim); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.player-controls__recording-status mat-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 16px; + color: #ffffff; +} + +.player-controls__recording-status span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__recording-status--active { + color: var(--mat-sys-error, #ff5252); + font-weight: 700; +} + +.player-controls__recording-status--active mat-icon { + color: var(--mat-sys-error, #ff5252); +} + +.player-controls__live-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 4px; + color: #ffffff; + background: var(--mat-sys-error, #e53935); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.player-controls__live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: player-controls-live-pulse 1.6s ease-in-out infinite; +} + +@keyframes player-controls-live-pulse { + 50% { + opacity: 0.35; + } +} + +// The range input is the interaction and accessibility layer over the drawn +// track: invisible and full-size. Its focus ring is drawn on the track +// instead, because an outline on a transparent element is invisible. +.player-controls__slider--timeline { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + appearance: none; + -webkit-appearance: none; + cursor: pointer; +} + +.player-controls__slider--timeline:disabled { + cursor: default; +} + +.player-controls__timeline-bar:has(.player-controls__slider:focus-visible) + .player-controls__timeline-track { + outline: 2px solid var(--pc-text); + outline-offset: 5px; + border-radius: 4px; +} + +// Touch scrubbing needs a taller hit strip; the 6px track is unchanged. +@media (pointer: coarse) { + .player-controls__timeline-bar { + height: 28px; + } +} + +// Keep in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts. +@container player-controls (max-width: 719px) { + :host { + gap: 10px; + } + + .player-controls__time { + font-size: 13px; + } + + .player-controls__timeline-track { + height: 5px; + } + + .player-controls__timeline-knob { + width: 11px; + height: 11px; + margin: -5.5px 0 0 -5.5px; + box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts new file mode 100644 index 000000000..383014364 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts @@ -0,0 +1,44 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { formatTime } from './controls-format.utils'; +import type { ControlsTimeline } from './controls-timeline'; +import type { ControlsTimelineHover } from './controls-timeline-hover'; + +/** + * The dock's timeline row: current time, the drawn segment track with its + * knob and hover label, the remaining time (or LIVE / `--:--`), and the + * recording status. Presentation only — scrub state lives in + * {@link ControlsTimeline}, and the range input's `input`/`change` events + * go back to the controls component, which owns reveal and seeking. + */ +@Component({ + selector: 'app-player-timeline', + templateUrl: './player-timeline.component.html', + styleUrl: './player-timeline.component.scss', + imports: [MatIconModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'player-controls__timeline' }, +}) +export class PlayerTimelineComponent { + readonly timeline = input.required(); + readonly hover = input.required(); + /** The engine can seek at all; without it the track is not drawn. */ + readonly seekable = input(false); + /** Seeking is possible right now. */ + readonly canSeek = input(false); + readonly isLive = input(false); + /** `−7:03`, or null without a finite duration. */ + readonly remaining = input(null); + readonly recordingStatus = input(null); + readonly recording = input(false); + readonly scrubInput = output(); + readonly scrubCommit = output(); + + readonly formatTime = formatTime; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss index 23121748c..1aaede1e7 100644 --- a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss @@ -1,8 +1,38 @@ -// Glass card; the controls host positions it (bottom-right, above the dock) -// and reads the `--pc-*` palette it declares. +// Glass card, bottom-right above the dock, inside the controls host (the +// `player-controls` container) whose `--pc-*` palette it reads. It does not +// fade with the controls: near the end of an episode it is worth seeing. :host { + position: absolute; + right: 32px; + bottom: 136px; + z-index: 3; display: block; + max-width: calc(100cqw - 64px); pointer-events: auto; + animation: player-up-next-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +:host(.player-up-next--compact) { + right: 12px; + bottom: 92px; + max-width: calc(100cqw - 24px); +} + +@keyframes player-up-next-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + :host { + animation: none; + } } .player-up-next__button { diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 6183ee287..695d138e4 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -149,6 +149,36 @@ describe('PortalInlinePlayerComponent up next rail', () => { expect(component.playerUpNext()).toBeNull(); }); + it('plays the next season through the rail path once the season is over', async () => { + await setup(true); + fixture.componentRef.setInput('playback', seriesPlayback); + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + const selected = jest.fn(); + const next = jest.fn(); + component.upNextEpisodeSelected.subscribe(selected); + component.nextEpisodeRequested.subscribe(next); + + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: false, + }); + fixture.detectChanges(); + component.onNextEpisodeRequested(); + expect(selected).toHaveBeenCalledWith(upNextItems[1]); + expect(next).not.toHaveBeenCalled(); + + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: true, + autoplayEnabled: false, + }); + fixture.detectChanges(); + component.onNextEpisodeRequested(); + expect(next).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledTimes(1); + }); + /** Renders, then feeds a stage size as the ResizeObserver would. */ function renderWithStage(width: number, height: number): void { fixture.detectChanges(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index b92088563..c8e4bc432 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -224,12 +224,19 @@ export class PortalInlinePlayerComponent { readonly upNextRailItems = computed( () => this.upNextEpisodes() ?? [] ); - /** The episode after the playing one, for the controls' "Up next" card. */ - readonly playerUpNext = computed(() => { + /** The rail entry after the playing one, across season boundaries. */ + private readonly upNextRailNext = computed(() => { const items = this.upNextEpisodes() ?? []; const playing = items.findIndex((item) => item.isPlaying); const next = playing >= 0 ? items[playing + 1] : undefined; - if (!next || this.playback()?.contentInfo?.contentType !== 'episode') { + return next && this.playback()?.contentInfo?.contentType === 'episode' + ? next + : null; + }); + /** The episode after the playing one, for the controls' "Up next" card. */ + readonly playerUpNext = computed(() => { + const next = this.upNextRailNext(); + if (!next) { return null; } return { @@ -338,6 +345,14 @@ export class PortalInlinePlayerComponent { } onNextEpisodeRequested(): void { + // Season-local navigation ends at a season's last episode; the Up + // next card can still offer the next season's first, which only + // the rail selection path knows how to play. + const next = this.upNextRailNext(); + if (this.seriesNavigation()?.canNext === false && next) { + this.upNextEpisodeSelected.emit(next); + return; + } this.nextEpisodeRequested.emit(); }