From e2a846b430203c21f32fc2ceab2ac4ccdd13cb38 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 21:31:25 +0200 Subject: [PATCH] feat(playback): draw the player timeline as chapter or programme segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the "Hybrid" player controls redesign: an optional `timelineSegments` input (PlayerTimelineSegment { startSeconds, endSeconds, title }) on app-player-controls. Segments are clamped, ordered, overlap-cut and gap-filled into a cover of the duration, drawn as proportional flex items with their own accent fill, and named in the hover label (`Chapter 2 · 12:40`). No host supplies segments yet, so the bar still renders as one segment; EPG catch-up programmes and mpv chapters are separate follow-ups. Co-Authored-By: Claude Fable 5.1 --- .changes/playback-timeline-segments-model.md | 8 ++ docs/architecture/player-controls-contract.md | 19 +++ .../controls-timeline-hover.ts | 24 +++- .../controls-timeline-segments.spec.ts | 117 ++++++++++++++++++ .../controls-timeline-segments.ts | 107 ++++++++++++++++ .../lib/player-controls/controls-timeline.ts | 30 ++++- .../player-controls.component.dock.spec.ts | 78 ++++++++++++ .../player-controls.component.html | 21 +++- .../player-controls.component.scss | 6 +- .../player-controls.component.ts | 8 +- .../player-controls/player-controls.model.ts | 19 ++- 11 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 .changes/playback-timeline-segments-model.md create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts diff --git a/.changes/playback-timeline-segments-model.md b/.changes/playback-timeline-segments-model.md new file mode 100644 index 000000000..cbcd8eff1 --- /dev/null +++ b/.changes/playback-timeline-segments-model.md @@ -0,0 +1,8 @@ +--- +type: internal +area: playback +--- + +The shared player timeline can draw chapter or programme segments with +per-segment progress and a titled hover label; no player supplies segments +yet, so the bar still renders as one segment. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index c88934915..58d6ee192 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -336,6 +336,24 @@ app's `--app-selection-color` is a different blue that would fight the video. Material's `--mat-icon-button-*` tokens; their hover is a flat `rgba(255,255,255,.1)` layer. +### Timeline segments + +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 +(`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies +them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps +to the duration, orders, drops empty and reversed entries, cuts overlaps at +the previous end and fills every gap with an untitled segment so the row +always covers `[0, duration]`. Without segments — every host today — the +row is one untitled segment, which is the plain bar. `ControlsTimeline` +owns the normalized list and the per-segment fill for the current scrub or +playback value; the hover label becomes `Chapter 2 · 12:40` over a titled +segment. Producers (EPG programmes for catch-up and timeshift playback, +mpv's chapter list) are separate follow-ups; the rendering, model and rules +are in place for them. + ### Settings panel Every track, quality, speed and aspect choice lives behind one **`tune`** @@ -1495,6 +1513,7 @@ libs/ui/playback/src/lib/player-controls/ ├── controls-format.utils.ts ├── controls-layout.ts ├── controls-timeline-hover.ts +├── controls-timeline-segments.ts ├── controls-settings.ts ├── controls-settings-groups.ts ├── player-settings-panel.component.ts diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts index ff53ce757..6d2fa321c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts @@ -1,5 +1,9 @@ import { Signal, computed, signal } from '@angular/core'; import { formatTime } from './controls-format.utils'; +import { + findTimelineSegment, + type TimelineSegmentView, +} from './controls-timeline-segments'; /** * Projects a pointer's horizontal position over the timeline bar onto a @@ -31,6 +35,8 @@ export interface ControlsTimelineHoverDeps { duration: Signal; /** Whether the timeline accepts pointer interaction at all. */ interactive: Signal; + /** Drawn segments, for the title in the label; none means time only. */ + segments?: Signal; } /** @@ -52,9 +58,25 @@ export class ControlsTimelineHover { return Math.min(100, Math.max(0, (seconds / duration) * 100)); }); + /** The titled segment under the pointer, if any. */ + readonly segmentTitle = computed(() => { + const seconds = this.seconds(); + const segments = this.deps.segments?.(); + if (seconds === null || !segments) { + return null; + } + return findTimelineSegment(segments, seconds)?.title ?? null; + }); + + /** `Chapter 2 · 12:40` over a titled segment, else the time alone. */ readonly label = computed(() => { const seconds = this.seconds(); - return seconds === null ? null : formatTime(seconds); + if (seconds === null) { + return null; + } + const time = formatTime(seconds); + const title = this.segmentTitle(); + return title ? `${title} \u00b7 ${time}` : time; }); /** Bound to `pointermove` on the bar element itself (`currentTarget`). */ 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 new file mode 100644 index 000000000..8bec03eda --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts @@ -0,0 +1,117 @@ +import { + findTimelineSegment, + normalizeTimelineSegments, + segmentFillPercent, +} from './controls-timeline-segments'; + +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(normalizeTimelineSegments([], 600)).toHaveLength(1); + expect( + normalizeTimelineSegments( + [{ startSeconds: 0, endSeconds: 10, title: 'Intro' }], + 0 + ) + ).toEqual([{ startSeconds: 0, endSeconds: 0, title: null, share: 1 }]); + expect( + normalizeTimelineSegments([], Number.POSITIVE_INFINITY) + ).toHaveLength(1); + }); + + it('sorts, fills the gaps and shares the duration', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 300, endSeconds: 450, title: 'Chapter 2' }, + { startSeconds: 60, endSeconds: 300, title: 'Chapter 1' }, + ], + 600 + ); + + expect(segments).toEqual([ + { startSeconds: 0, endSeconds: 60, title: null, share: 0.1 }, + { + startSeconds: 60, + endSeconds: 300, + title: 'Chapter 1', + share: 0.4, + }, + { + startSeconds: 300, + endSeconds: 450, + title: 'Chapter 2', + share: 0.25, + }, + { startSeconds: 450, endSeconds: 600, title: null, share: 0.25 }, + ]); + expect( + segments.reduce((total, segment) => total + segment.share, 0) + ).toBeCloseTo(1); + }); + + it('clamps to the duration, cuts overlaps and drops empty segments', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: -20, endSeconds: 100, title: ' A ' }, + { startSeconds: 50, endSeconds: 200, title: 'B' }, + { startSeconds: 200, endSeconds: 200, title: 'empty' }, + { startSeconds: 400, endSeconds: 300, title: 'reversed' }, + { startSeconds: 150, endSeconds: 180, title: 'swallowed' }, + { startSeconds: 500, endSeconds: 900, title: '' }, + ], + 600 + ); + + expect( + segments.map((s) => [s.startSeconds, s.endSeconds, s.title]) + ).toEqual([ + [0, 100, 'A'], + [100, 200, 'B'], + [200, 500, null], + [500, 600, null], + ]); + }); +}); + +describe('segmentFillPercent', () => { + const segment = { startSeconds: 100, endSeconds: 300 }; + + it('measures the played share of a segment and clamps outside it', () => { + expect(segmentFillPercent(segment, 50)).toBe(0); + expect(segmentFillPercent(segment, 150)).toBe(25); + expect(segmentFillPercent(segment, 300)).toBe(100); + expect(segmentFillPercent(segment, 999)).toBe(100); + }); + + it('is empty for degenerate segments and positions', () => { + expect(segmentFillPercent({ startSeconds: 5, endSeconds: 5 }, 5)).toBe( + 0 + ); + expect(segmentFillPercent(segment, Number.NaN)).toBe(0); + }); +}); + +describe('findTimelineSegment', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 0, endSeconds: 100, title: 'First' }, + { startSeconds: 100, endSeconds: 200, title: 'Second' }, + ], + 200 + ); + + it('returns the segment containing the time, the last one at the end', () => { + expect(findTimelineSegment(segments, 0)?.title).toBe('First'); + expect(findTimelineSegment(segments, 99.9)?.title).toBe('First'); + expect(findTimelineSegment(segments, 100)?.title).toBe('Second'); + expect(findTimelineSegment(segments, 200)?.title).toBe('Second'); + }); + + it('returns null for nothing to match', () => { + expect(findTimelineSegment([], 10)).toBeNull(); + expect(findTimelineSegment(segments, Number.NaN)).toBeNull(); + expect(findTimelineSegment(segments, -1)).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000..67eae3d69 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts @@ -0,0 +1,107 @@ +import type { PlayerTimelineSegment } from './player-controls.model'; + +/** A normalized segment as the track renders it. */ +export interface TimelineSegmentView { + startSeconds: number; + endSeconds: number; + title: string | null; + /** Share of the whole duration, 0..1; drives the segment's flex-grow. */ + share: number; +} + +const WHOLE_TIMELINE: TimelineSegmentView = { + startSeconds: 0, + endSeconds: 0, + title: null, + share: 1, +}; + +/** + * Turns host-supplied segments (chapters, programmes) into a gapless, + * non-overlapping cover of `[0, duration]`, in playback order. Segments are + * clamped to the duration, empty and reversed ones dropped, overlaps cut at + * the previous segment's end, and every uncovered stretch — before the + * first, between two, after the last — becomes an untitled segment so the + * track always adds up to the full duration. Without a usable duration or + * without segments the whole timeline is one untitled segment, which is + * exactly the pre-segment rendering. + */ +export function normalizeTimelineSegments( + segments: readonly PlayerTimelineSegment[] | null | undefined, + durationSeconds: number +): TimelineSegmentView[] { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return [WHOLE_TIMELINE]; + } + const clamp = (value: number) => + Number.isFinite(value) + ? Math.min(durationSeconds, Math.max(0, value)) + : 0; + const ordered = (segments ?? []) + .map((segment) => ({ + startSeconds: clamp(segment.startSeconds), + endSeconds: clamp(segment.endSeconds), + title: segment.title?.trim() || null, + })) + .filter((segment) => segment.endSeconds > segment.startSeconds) + .sort((a, b) => a.startSeconds - b.startSeconds); + + const cover: Omit[] = []; + let cursor = 0; + for (const segment of ordered) { + const startSeconds = Math.max(segment.startSeconds, cursor); + if (startSeconds >= segment.endSeconds) { + continue; + } + if (startSeconds > cursor) { + cover.push({ + startSeconds: cursor, + endSeconds: startSeconds, + title: null, + }); + } + cover.push({ ...segment, startSeconds }); + cursor = segment.endSeconds; + } + if (cursor < durationSeconds) { + cover.push({ + startSeconds: cursor, + endSeconds: durationSeconds, + title: null, + }); + } + return cover.map((segment) => ({ + ...segment, + share: (segment.endSeconds - segment.startSeconds) / durationSeconds, + })); +} + +/** How much of one segment the position has played through, 0..100. */ +export function segmentFillPercent( + segment: Pick, + positionSeconds: number +): number { + const length = segment.endSeconds - segment.startSeconds; + if (length <= 0 || !Number.isFinite(positionSeconds)) { + return 0; + } + const fraction = (positionSeconds - segment.startSeconds) / length; + return Math.min(100, Math.max(0, fraction * 100)); +} + +/** The segment containing `seconds`; the last one at the very end. */ +export function findTimelineSegment( + segments: readonly TimelineSegmentView[], + seconds: number +): TimelineSegmentView | null { + if (!Number.isFinite(seconds)) { + return null; + } + for (const segment of segments) { + if (seconds >= segment.startSeconds && seconds < segment.endSeconds) { + return segment; + } + } + const last = segments[segments.length - 1]; + return last && seconds >= last.endSeconds ? last : null; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts index b6ac54130..985a62af5 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts @@ -1,5 +1,13 @@ import { Signal, computed, signal } from '@angular/core'; -import type { PlayerControlsState } from './player-controls.model'; +import { + normalizeTimelineSegments, + segmentFillPercent, + type TimelineSegmentView, +} from './controls-timeline-segments'; +import type { + PlayerControlsState, + PlayerTimelineSegment, +} from './player-controls.model'; /** * Owns the scrub state and timeline projections for the controls bar: the @@ -9,7 +17,12 @@ import type { PlayerControlsState } from './player-controls.model'; export class ControlsTimeline { readonly scrubPosition = signal(null); - constructor(private readonly state: Signal) {} + constructor( + private readonly state: Signal, + private readonly hostSegments: Signal< + readonly PlayerTimelineSegment[] | null + > = signal(null) + ) {} readonly duration = computed(() => { const duration = this.state().durationSeconds; @@ -32,6 +45,19 @@ export class ControlsTimeline { : 0; }); + /** The drawn track: host segments over the duration, else one segment. */ + readonly segments = computed(() => + normalizeTimelineSegments(this.hostSegments(), this.duration()) + ); + + /** Played share of one segment for the current (scrub or playback) value. */ + fillPercent(segment: TimelineSegmentView): number { + if (!this.state().canSeek) { + return 0; + } + return segmentFillPercent(segment, this.value()); + } + readEventValue(event: Event): number | null { return this.normalize(Number((event.target as HTMLInputElement).value)); } 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 fa2287d70..b3b20e9f0 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 @@ -296,6 +296,84 @@ describe('PlayerControlsComponent dock', () => { ).toBeNull(); }); + it('draws host segments proportionally with per-segment fills', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 150, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 100, endSeconds: 300, title: 'Chapter 2' }, + { startSeconds: 0, endSeconds: 100, title: 'Chapter 1' }, + ]); + fixture.detectChanges(); + + const segments = Array.from( + fixture.nativeElement.querySelectorAll( + '.player-controls__timeline-segment' + ) as NodeListOf + ); + 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', + ]); + expect( + segments.map( + (s) => + ( + s.querySelector( + '.player-controls__timeline-fill' + ) as HTMLElement + ).style.width + ) + ).toEqual(['100%', '25%', '0%']); + // The knob still reads the overall progress. + expect(query('.player-controls__timeline-knob')?.style.left).toBe( + '37.5%' + ); + }); + + it('names the hovered segment in the timeline label', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 0, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 0, endSeconds: 200, title: 'Intro' }, + ]); + fixture.detectChanges(); + + const bar = query('.player-controls__timeline-bar') as HTMLElement; + bar.getBoundingClientRect = () => + ({ left: 0, width: 400 }) as DOMRect; + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 100, bubbles: true }) + ); + fixture.detectChanges(); + expect( + query( + '[data-test-id="player-controls-timeline-label"]' + )?.textContent?.trim() + ).toBe('Intro \u00b7 1:40'); + + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 300, bubbles: true }) + ); + fixture.detectChanges(); + expect( + query( + '[data-test-id="player-controls-timeline-label"]' + )?.textContent?.trim() + ).toBe('5:00'); + }); + it('does not hover-label a non-seekable timeline', () => { setCapabilities({ seek: true }); setState({ canSeek: false, durationSeconds: 400 }); 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 11864254c..b994fc0a8 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 @@ -154,12 +154,23 @@ class="player-controls__timeline-track" aria-hidden="true" > -
+ @for (segment of timeline.segments(); track $index) {
-
+ class="player-controls__timeline-segment" + [class.player-controls__timeline-segment--titled]=" + segment.title !== null + " + [style.flex-grow]="segment.share" + [attr.data-segment-title]="segment.title" + > +
+ + } @if (state().canSeek) {
(null); + /** Chapters / programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); readonly menus = new ControlsMenuState(); @@ -142,7 +147,7 @@ export class PlayerControlsComponent implements OnDestroy { reveal: (options) => this.reveal(options), }); private readonly controllerVolume = computed(() => this.state().volume); - private readonly timeline = new ControlsTimeline(this.state); + readonly timeline = new ControlsTimeline(this.state, this.timelineSegments); readonly scrubPosition = this.timeline.scrubPosition; readonly timelineDuration = this.timeline.duration; readonly timelineValue = this.timeline.value; @@ -152,6 +157,7 @@ export class PlayerControlsComponent implements OnDestroy { interactive: computed( () => this.capabilities().seek && this.state().canSeek ), + segments: this.timeline.segments, }); /** `−7:03` while a finite duration is known; the dock prefers it to the total. */ readonly remainingTimeText = computed(() => diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 218824d2f..7b21a4039 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -3,12 +3,7 @@ import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces'; import type { PlayerStreamStatsSource } from './player-stream-stats.model'; export type PlayerStatus = - | 'idle' - | 'loading' - | 'playing' - | 'paused' - | 'ended' - | 'error'; + 'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error'; /** Which controls an engine supports. A control is only rendered when its flag is true. */ export interface PlayerControlsCapabilities { @@ -62,6 +57,18 @@ export interface PlayerMediaTitle { secondary?: string | null; } +/** + * One stretch of the timeline the track draws as its own segment — a chapter + * of a film, a programme of a catch-up recording. Hosts pass display-ready + * titles; the controls clamp, order and gap-fill the list themselves. + */ +export interface PlayerTimelineSegment { + startSeconds: number; + endSeconds: number; + /** Shown in the hover label as `Title · 12:40`; null for a plain gap. */ + title: string | null; +} + export interface PlayerPreset { value: T; label: string;