fix(playback): keep player controls in the style budget, fix cross-season Up next and dialog focus

CI: the production build failed the 15 kB component style budget
(player-controls.component.scss grew to 16.5 kB minified).
- Extract the timeline row into app-player-timeline with its own
  stylesheet; scrub events still go back to the controls component.
- The settings panel and Up next card place themselves from their own
  :host styles.
- Drop dead --mdc-icon-button-* tokens (Material 22 reads --mat-* only)
  and the popover-anchor rule the action cluster no longer needs.
Largest stylesheet is now 11.7 kB, below the 12.5 kB warning.

Review:
- Up next at a season's last episode: the card follows the host's item
  instead of the season-local canNextEpisode, emits nextEpisodeRequested
  directly, and PortalInlinePlayerComponent routes it through the Up Next
  rail selection when seriesNavigation.canNext is false.
- Settings dialog focus: opened from the keyboard it takes focus, closing
  with focus inside returns it to `tune`, and the dock is inert while the
  compact sheet replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-26 23:59:10 +02:00
1 parent ea25c739f5
commit fb62b14feb
17 files changed
+697 -462

No files matched your search

+21 -4
View File
@@ -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
@@ -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,
@@ -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()
@@ -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', () => {
@@ -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();
@@ -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)"
>
<div class="player-controls__timeline">
<span
class="player-controls__time player-controls__time--current"
>{{ formatTime(timelineValue()) }}</span
>
@if (capabilities().seek) {
<div
class="player-controls__timeline-bar"
[class.player-controls__timeline-bar--disabled]="
!state().canSeek
"
(pointermove)="timelineHover.move($event)"
(pointerleave)="timelineHover.clear()"
>
<div
class="player-controls__timeline-track"
aria-hidden="true"
>
@for (segment of timeline.segments(); track $index) {
<div
class="player-controls__timeline-segment"
[class.player-controls__timeline-segment--titled]="
segment.title !== null
"
[style.left.%]="segment.startPercent"
[style.width]="segment.width"
[attr.data-segment-title]="segment.title"
>
<div
class="player-controls__timeline-fill"
[style.width.%]="
timeline.fillPercent(segment)
"
></div>
</div>
}
</div>
@if (state().canSeek) {
<div
class="player-controls__timeline-knob"
[style.left.%]="timelineProgress()"
aria-hidden="true"
></div>
}
@if (timelineHover.percent() !== null) {
<div
class="player-controls__timeline-marker"
[style.left.%]="timelineHover.percent()"
aria-hidden="true"
></div>
<div
class="player-controls__timeline-label"
[style.--hover-x.%]="timelineHover.percent()"
data-test-id="player-controls-timeline-label"
aria-hidden="true"
>
{{ timelineHover.label() }}
</div>
}
<input
class="player-controls__slider player-controls__slider--timeline"
[style.--slider-progress.%]="timelineProgress()"
type="range"
min="0"
[max]="timelineDuration()"
[value]="timelineValue()"
[disabled]="!state().canSeek"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
</div>
} @else {
<div class="player-controls__timeline-spacer"></div>
}
@if (state().canSeek && remainingTimeText(); as remaining) {
<span
class="player-controls__time player-controls__time--end"
data-test-id="player-controls-remaining-time"
>{{ remaining }}</span
>
} @else if (state().isLive) {
<span
class="player-controls__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate
"
>
<span
class="player-controls__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span class="player-controls__time player-controls__time--end"
>--:--</span
>
}
@if (recordingStatusText(); as recordingStatus) {
<div
class="player-controls__recording-status"
[class.player-controls__recording-status--active]="
isRecording()
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
>
<mat-icon>{{
isRecording() ? 'fiber_manual_record' : 'check_circle'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<app-player-timeline
[timeline]="timeline"
[hover]="timelineHover"
[seekable]="capabilities().seek"
[canSeek]="state().canSeek"
[isLive]="state().isLive"
[remaining]="remainingTimeText()"
[recordingStatus]="recordingStatusText()"
[recording]="isRecording()"
(scrubInput)="onTimelineInput($event)"
(scrubCommit)="onTimelineCommit($event)"
/>
<div class="player-controls__row">
<div class="player-controls__left">
@@ -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()"
@@ -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 {
@@ -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();
@@ -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()) {
@@ -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;
}
}
@@ -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<HTMLElement>>(ElementRef).nativeElement;
readonly controller = input.required<PlayerController>();
@@ -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<HTMLElement>('.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;
}
}
@@ -0,0 +1,98 @@
<span class="player-controls__time player-controls__time--current">{{
formatTime(timeline().value())
}}</span>
@if (seekable()) {
<div
class="player-controls__timeline-bar"
[class.player-controls__timeline-bar--disabled]="!canSeek()"
(pointermove)="hover().move($event)"
(pointerleave)="hover().clear()"
>
<div class="player-controls__timeline-track" aria-hidden="true">
@for (segment of timeline().segments(); track $index) {
<div
class="player-controls__timeline-segment"
[class.player-controls__timeline-segment--titled]="
segment.title !== null
"
[style.left.%]="segment.startPercent"
[style.width]="segment.width"
[attr.data-segment-title]="segment.title"
>
<div
class="player-controls__timeline-fill"
[style.width.%]="timeline().fillPercent(segment)"
></div>
</div>
}
</div>
@if (canSeek()) {
<div
class="player-controls__timeline-knob"
[style.left.%]="timeline().progress()"
aria-hidden="true"
></div>
}
@if (hover().percent() !== null) {
<div
class="player-controls__timeline-marker"
[style.left.%]="hover().percent()"
aria-hidden="true"
></div>
<div
class="player-controls__timeline-label"
[style.--hover-x.%]="hover().percent()"
data-test-id="player-controls-timeline-label"
aria-hidden="true"
>
{{ hover().label() }}
</div>
}
<input
class="player-controls__slider player-controls__slider--timeline"
[style.--slider-progress.%]="timeline().progress()"
type="range"
min="0"
[max]="timeline().duration()"
[value]="timeline().value()"
[disabled]="!canSeek()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_POSITION' | translate
"
[attr.aria-valuetext]="formatTime(timeline().value())"
(input)="scrubInput.emit($event)"
(change)="scrubCommit.emit($event)"
/>
</div>
} @else {
<div class="player-controls__timeline-spacer"></div>
}
@if (canSeek() && remaining(); as remaining) {
<span
class="player-controls__time player-controls__time--end"
data-test-id="player-controls-remaining-time"
>{{ remaining }}</span
>
} @else if (isLive()) {
<span
class="player-controls__live-badge"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate"
>
<span class="player-controls__live-dot" aria-hidden="true"></span>
LIVE
</span>
} @else {
<span class="player-controls__time player-controls__time--end">--:--</span>
}
@if (recordingStatus(); as status) {
<div
class="player-controls__recording-status"
[class.player-controls__recording-status--active]="recording()"
[attr.aria-live]="recording() ? 'off' : 'polite'"
>
<mat-icon>{{
recording() ? 'fiber_manual_record' : 'check_circle'
}}</mat-icon>
<span>{{ status }}</span>
</div>
}
@@ -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);
}
}
@@ -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<ControlsTimeline>();
readonly hover = input.required<ControlsTimelineHover>();
/** 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<string | null>(null);
readonly recordingStatus = input<string | null>(null);
readonly recording = input(false);
readonly scrubInput = output<Event>();
readonly scrubCommit = output<Event>();
readonly formatTime = formatTime;
}
@@ -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 {
@@ -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();
@@ -224,12 +224,19 @@ export class PortalInlinePlayerComponent {
readonly upNextRailItems = computed<UpNextRailItem[]>(
() => this.upNextEpisodes() ?? []
);
/** The episode after the playing one, for the controls' "Up next" card. */
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
/** The rail entry after the playing one, across season boundaries. */
private readonly upNextRailNext = computed<UpNextRailItem | null>(() => {
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<PlayerUpNextItem | null>(() => {
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();
}