diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts index 26ac5bf46..0f399a397 100644 --- a/apps/web-e2e/src/player-settings-panel.e2e.ts +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -109,6 +109,8 @@ test('@web @playback settings panel opens from the speed chip and applies in pla page, }) => { test.setTimeout(90_000); + // Chips and the side panel need a player of at least 960px. + await page.setViewportSize({ width: 1600, height: 1000 }); await serveClip(page); await selectHtml5Player(page); await importPlaylist(page); diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index c09a3fad7..3c73022f9 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -343,7 +343,11 @@ app's `--app-selection-color` is a different blue that would fight the video. The track is drawn as a row of segments, one flex item per segment with `flex-grow` equal to its share of the duration and its own accent fill, so a film's chapters or a catch-up recording's programmes read directly off -the bar. The optional `timelineSegments` input +the bar. Each segment is placed absolutely at its time position (`left` = +start percent, `width` = share minus the 3px gap every segment but the +last keeps), so a drawn boundary sits exactly where the linear seek input +and the hover label change segment; a segment shorter than the gap +collapses instead of pushing its neighbours. The optional `timelineSegments` input (`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps to the duration, orders, drops empty and reversed entries, cuts overlaps at @@ -366,8 +370,10 @@ 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 -most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, controls shown, +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. The card is a glass surface that does not fade with the controls; the @@ -393,7 +399,7 @@ and owns open/toggle/close; the `tune` button and the panel render only while at least one group exists, and the availability reconciliation closes the panel the moment the last group disappears. -- **Wide dock**: two **value chips** precede `tune` — subtitles +- **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles (`closed_caption` + the selected track's label, or "Off") and speed (`speed` + `1.25×`). Audio and aspect ratio have no chip: they are panel-only. A chip click opens the panel **focused on its group** @@ -405,7 +411,7 @@ the panel the moment the last group disappears. corner shift left by the panel's width (`--panel-open` modifiers, `right: 370px`), the chips and the picture-in-picture / recording buttons fold away, `tune` fills in the accent color, and fullscreen stays. -- **Compact dock**: no chips; `tune` carries **state dots** (5px, cyan when +- **Compact dock, and wide docks below 960px**: no chips; `tune` carries **state dots** (5px, cyan when subtitles are on or a non-default audio track is selected, violet when speed, aspect or manual quality differ from their default) and the panel opens as a **bottom sheet** (`--sheet` modifier: grip, two-column rows, @@ -1038,8 +1044,19 @@ places that must agree: the `@container player-controls (max-width: 719px)` block in the stylesheet sizes the compact dock (14px gutters, 32px buttons, 36px play circle, 5px track), and `ControlsLayout` (`COMPACT_LAYOUT_MAX_WIDTH`, a `ResizeObserver` on the host) drives the -template branches CSS cannot express — today the inline volume slider versus +template branches CSS cannot express — the inline volume slider versus its popover. Without `ResizeObserver` (unit tests) the mode stays `wide`. + +A second threshold, `ROOMY_LAYOUT_MIN_WIDTH` (960px, `ControlsLayout.roomy`), +gates the wide dock's extras: the subtitle/speed chips and the settings +panel beside the video. Between 720px and 960px the dock stays wide (full +button sizes, inline volume) but folds the chips into `tune` with state +dots and opens settings as the bottom sheet, because the widest action row +(volume, series transport, two chips, tune/record/PiP/fullscreen) and the +dock beside a 370px panel do not fit there. The control row's side columns +are `minmax(min-content, 1fr)`, so if the actions still need more than half +of what the transport leaves, the transport slides off-centre instead of +the actions overlapping it or leaving the player. Episode navigation stays in the compact transport: the series hosts rely on those buttons, and the inline series player is often narrower than 720px. diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts index 7a93b7e5d..e5dfe5700 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts @@ -1,4 +1,8 @@ -import { COMPACT_LAYOUT_MAX_WIDTH, ControlsLayout } from './controls-layout'; +import { + COMPACT_LAYOUT_MAX_WIDTH, + ControlsLayout, + ROOMY_LAYOUT_MIN_WIDTH, +} from './controls-layout'; type ResizeCallback = (entries: ResizeObserverEntry[]) => void; @@ -61,6 +65,23 @@ describe('ControlsLayout', () => { expect(layout.mode()).toBe('wide'); }); + it('reports room for chips and the side panel only from the roomy width', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + expect(layout.roomy()).toBe(true); + + callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH - 1)]); + expect(layout.mode()).toBe('wide'); + expect(layout.roomy()).toBe(false); + + callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH)]); + expect(layout.roomy()).toBe(true); + + callbacks[0]([entry(400)]); + expect(layout.mode()).toBe('compact'); + expect(layout.roomy()).toBe(false); + }); + it('uses the last entry of a batch and ignores zero widths', () => { const layout = new ControlsLayout(); layout.attach(document.createElement('div')); diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.ts index bf18d0d5a..9a8ac6574 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-layout.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.ts @@ -10,6 +10,17 @@ import { signal } from '@angular/core'; */ export const COMPACT_LAYOUT_MAX_WIDTH = 719; +/** + * Container width (px) from which the wide dock has room for its extras: + * the subtitle/speed value chips beside the action buttons, and the + * settings panel beside the video (the panel takes 370px of the dock). + * Between the compact breakpoint and this width the dock stays wide but + * folds the chips into `tune` and opens settings as a bottom sheet. + * Sized from the widest action row: volume + series transport + two chips + * + tune/record/PiP/fullscreen, and dock + panel with only tune/fullscreen. + */ +export const ROOMY_LAYOUT_MIN_WIDTH = 960; + export type ControlsLayoutMode = 'compact' | 'wide'; /** @@ -23,6 +34,8 @@ export type ControlsLayoutMode = 'compact' | 'wide'; */ export class ControlsLayout { readonly mode = signal('wide'); + /** Room for chips and the side panel; see {@link ROOMY_LAYOUT_MIN_WIDTH}. */ + readonly roomy = signal(true); private observer: ResizeObserver | null = null; attach(host: HTMLElement): void { @@ -53,6 +66,10 @@ export class ControlsLayout { if (this.mode() !== next) { this.mode.set(next); } + const roomy = width >= ROOMY_LAYOUT_MIN_WIDTH; + if (this.roomy() !== roomy) { + this.roomy.set(roomy); + } } detach(): void { diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.ts index c9987ccef..a7fb26361 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-settings.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.ts @@ -53,7 +53,7 @@ export class ControlsSettings { () => this.available() && this.deps.menus.settingsOpen() ); - readonly focusGroup = this.deps.menus.settingsFocus; + readonly focusGroup = computed(() => this.deps.menus.settingsFocus()); readonly subtitlesOn = computed( () => this.groups().subtitles && this.deps.state().subtitlesEnabled diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts index 8bec03eda..54baa007d 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts @@ -7,7 +7,12 @@ import { describe('normalizeTimelineSegments', () => { it('renders one untitled segment without input or duration', () => { expect(normalizeTimelineSegments(null, 600)).toEqual([ - { startSeconds: 0, endSeconds: 600, title: null, share: 1 }, + expect.objectContaining({ + startSeconds: 0, + endSeconds: 600, + title: null, + share: 1, + }), ]); expect(normalizeTimelineSegments([], 600)).toHaveLength(1); expect( @@ -15,7 +20,9 @@ describe('normalizeTimelineSegments', () => { [{ startSeconds: 0, endSeconds: 10, title: 'Intro' }], 0 ) - ).toEqual([{ startSeconds: 0, endSeconds: 0, title: null, share: 1 }]); + ).toEqual([ + expect.objectContaining({ startSeconds: 0, title: null, share: 1 }), + ]); expect( normalizeTimelineSegments([], Number.POSITIVE_INFINITY) ).toHaveLength(1); @@ -31,26 +38,52 @@ describe('normalizeTimelineSegments', () => { ); expect(segments).toEqual([ - { startSeconds: 0, endSeconds: 60, title: null, share: 0.1 }, - { + expect.objectContaining({ + startSeconds: 0, + endSeconds: 60, + title: null, + share: 0.1, + }), + expect.objectContaining({ startSeconds: 60, endSeconds: 300, title: 'Chapter 1', share: 0.4, - }, - { + }), + expect.objectContaining({ startSeconds: 300, endSeconds: 450, title: 'Chapter 2', share: 0.25, - }, - { startSeconds: 450, endSeconds: 600, title: null, share: 0.25 }, + }), + expect.objectContaining({ + startSeconds: 450, + endSeconds: 600, + title: null, + share: 0.25, + }), ]); expect( segments.reduce((total, segment) => total + segment.share, 0) ).toBeCloseTo(1); }); + it('places segments at their time positions with a gap after all but the last', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 0, endSeconds: 150, title: 'A' }, + { startSeconds: 150, endSeconds: 600, title: 'B' }, + ], + 600 + ); + + expect(segments.map((s) => s.startPercent)).toEqual([0, 25]); + expect(segments.map((s) => s.width)).toEqual([ + 'max(0px, calc(25% - 3px))', + '75%', + ]); + }); + it('clamps to the duration, cuts overlaps and drops empty segments', () => { const segments = normalizeTimelineSegments( [ diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts index 67eae3d69..b3003d000 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts @@ -5,15 +5,24 @@ export interface TimelineSegmentView { startSeconds: number; endSeconds: number; title: string | null; - /** Share of the whole duration, 0..1; drives the segment's flex-grow. */ + /** Share of the whole duration, 0..1. */ share: number; + /** Left edge on the track, 0..100 — the same mapping the seek input uses. */ + startPercent: number; + /** CSS width: the share, minus the gap every segment but the last keeps. */ + width: string; } +/** Visual gap (px) after every segment except the last. */ +export const TIMELINE_SEGMENT_GAP_PX = 3; + const WHOLE_TIMELINE: TimelineSegmentView = { startSeconds: 0, endSeconds: 0, title: null, share: 1, + startPercent: 0, + width: '100%', }; /** @@ -46,7 +55,10 @@ export function normalizeTimelineSegments( .filter((segment) => segment.endSeconds > segment.startSeconds) .sort((a, b) => a.startSeconds - b.startSeconds); - const cover: Omit[] = []; + const cover: Pick< + TimelineSegmentView, + 'startSeconds' | 'endSeconds' | 'title' + >[] = []; let cursor = 0; for (const segment of ordered) { const startSeconds = Math.max(segment.startSeconds, cursor); @@ -70,10 +82,19 @@ export function normalizeTimelineSegments( title: null, }); } - return cover.map((segment) => ({ - ...segment, - share: (segment.endSeconds - segment.startSeconds) / durationSeconds, - })); + return cover.map((segment, index) => { + const share = + (segment.endSeconds - segment.startSeconds) / durationSeconds; + const last = index === cover.length - 1; + return { + ...segment, + share, + startPercent: (segment.startSeconds / durationSeconds) * 100, + width: last + ? `${share * 100}%` + : `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`, + }; + }); } /** How much of one segment the position has played through, 0..100. */ diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts index 9da587632..33b66dad2 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -117,6 +117,16 @@ describe('ControlsUpNext', () => { expect(upNext.visible()).toBe(false); }); + it('does not count down once the episode has ended', () => { + setState({ + status: 'ended', + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200, + }); + expect(upNext.visible()).toBe(false); + }); + it('yields to hidden controls and to the open settings panel', () => { showControls.set(false); expect(upNext.visible()).toBe(false); diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts index d2dfbc39a..aac4a688a 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -47,6 +47,9 @@ export class ControlsUpNext { remaining === null || remaining > UP_NEXT_THRESHOLD_SECONDS || state.isLive || + // 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() || diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts index 4fd97aa9d..50f514890 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -317,11 +317,13 @@ describe('PlayerControlsComponent dock', () => { expect( segments.map((s) => s.dataset['segmentTitle'] ?? null) ).toEqual(['Chapter 1', 'Chapter 2', null]); - expect(segments.map((s) => s.style.flexGrow)).toEqual([ - '0.25', - '0.5', - '0.25', + // Positioned by time, so boundaries match the linear seek input. + expect(segments.map((s) => s.style.left)).toEqual([ + '0%', + '25%', + '75%', ]); + expect(segments.at(-1)?.style.width).toBe('25%'); expect( segments.map( (s) => diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index c4e552b9e..3036acb54 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -23,7 +23,7 @@ class="player-controls__title" [class.player-controls__title--visible]="controlsAreVisible()" [class.player-controls__title--panel-open]=" - settings.isOpen() && !isCompact() + settings.isOpen() && layout.roomy() " data-test-id="player-controls-media-title" > @@ -43,7 +43,7 @@ class="player-controls__corner" [class.player-controls__corner--visible]="controlsAreVisible()" [class.player-controls__corner--panel-open]=" - settings.isOpen() && !isCompact() + settings.isOpen() && layout.roomy() " (pointerenter)="chrome.onPointerEnter()" (pointerleave)="chrome.onPointerLeave()" @@ -123,10 +123,10 @@ [class.player-controls__bar--visible]="controlsAreVisible()" [class.player-controls__bar--compact]="isCompact()" [class.player-controls__bar--panel-open]=" - settings.isOpen() && !isCompact() + settings.isOpen() && layout.roomy() " [class.player-controls__bar--sheet-open]=" - settings.isOpen() && isCompact() + settings.isOpen() && !layout.roomy() " (pointerenter)="chrome.onPointerEnter()" (pointerleave)="chrome.onPointerLeave()" @@ -160,7 +160,8 @@ [class.player-controls__timeline-segment--titled]=" segment.title !== null " - [style.flex-grow]="segment.share" + [style.left.%]="segment.startPercent" + [style.width]="segment.width" [attr.data-segment-title]="segment.title" >
@if ( - settings.available() && !isCompact() && !settings.isOpen() + settings.available() && layout.roomy() && !settings.isOpen() ) { @if (settings.groups().subtitles) { } - @if (canRecord() && !(settings.isOpen() && !isCompact())) { + @if (canRecord() && !(settings.isOpen() && layout.roomy())) {