feat(playback): draw the player timeline as chapter or programme segments

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 21:31:25 +02:00
1 parent 93c8c4b148
commit e2a846b430
11 files changed
+421 -16

No files matched your search

@@ -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.
@@ -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
@@ -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<number>;
/** Whether the timeline accepts pointer interaction at all. */
interactive: Signal<boolean>;
/** Drawn segments, for the title in the label; none means time only. */
segments?: Signal<readonly TimelineSegmentView[]>;
}
/**
@@ -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`). */
@@ -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();
});
});
@@ -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<TimelineSegmentView, 'share'>[] = [];
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<TimelineSegmentView, 'startSeconds' | 'endSeconds'>,
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;
}
@@ -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<number | null>(null);
constructor(private readonly state: Signal<PlayerControlsState>) {}
constructor(
private readonly state: Signal<PlayerControlsState>,
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<TimelineSegmentView[]>(() =>
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));
}
@@ -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<HTMLElement>
);
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 });
@@ -154,12 +154,23 @@
class="player-controls__timeline-track"
aria-hidden="true"
>
<div class="player-controls__timeline-segment">
@for (segment of timeline.segments(); track $index) {
<div
class="player-controls__timeline-fill"
[style.width.%]="timelineProgress()"
></div>
</div>
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"
>
<div
class="player-controls__timeline-fill"
[style.width.%]="
timeline.fillPercent(segment)
"
></div>
</div>
}
</div>
@if (state().canSeek) {
<div
@@ -366,9 +366,13 @@
transform: translateY(-50%);
}
// One flex item per segment; `flex-grow` carries the segment's share of the
// duration, so widths are proportional to time. A tiny segment still gets a
// visible sliver rather than vanishing between the 3px gaps.
.player-controls__timeline-segment {
position: relative;
flex: 1 1 auto;
flex: 1 1 0;
min-width: 4px;
overflow: hidden;
border-radius: 3px;
background: var(--pc-track);
@@ -35,6 +35,7 @@ import { formatRemainingTime, formatTime } from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
PlayerTimelineSegment,
} from './player-controls.model';
import { PlayerSettingsPanelComponent } from './player-settings-panel.component';
@@ -72,6 +73,10 @@ export class PlayerControlsComponent implements OnDestroy {
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Chapters / programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
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(() =>
@@ -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<T> {
value: T;
label: string;