mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
ea25c739f5
commit
fb62b14feb
17 files changed
+697
-462
No files matched your search
@@ -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 {
|
||||
|
||||
+51
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user