mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user