Skip to content

Commit f42d89f

Browse files
authored
feat(web-ui): add an EPG timeline band to the web player (#771)
Implements the EPG timeline requested in #770. **What it adds** — a pannable programme ruler above the existing progress bar: - tiered ticks (hour / half / quarter) labelled with the whole hours *and* every programme boundary, deduplicated, collision-avoided and pinned inside the ruler - blocks with past / live / future states, plus a catch-up marker for replays the source can serve - a draggable playhead with a live time + programme-name bubble; release to seek, never into the future - hover details (name + start/end) with a dwell delay so crossing blocks does not flicker - click a finished programme to replay it (confirmation first), the live one to return to the live edge, a future one to be told it has not aired - an always-visible day label in front of the pan buttons (today 9/17 - tomorrow 9/18 style) - a marquee for names wider than their block **Design notes / deviations from the issue** - *Window instead of a virtual list*: a bounded window (±90 min around playback, snapped to the tick grid) pans by buttons, wheel or drag. One channel 48 h is only ~50-100 blocks, so nothing needs virtualising; the fine grid is a CSS gradient and only the programmes intersecting the window are in the DOM. - *Palette*: the blue gradient family of the progress fill, and the live badge rose for the now line, instead of the issue red/orange/grey, so it stays consistent with the existing player and the simple theme. - *Tooltip content*: name + start/end only. desc/category would mean widening the XMLTV parser and EPGProgram; left out to keep this PR front-end only. - *Replay confirmation*: the M3U carries no catchup-days, so the client cannot know the upstream catch-up window; clicking a finished programme asks before trying. - *Measured placement*: label positions, collision decisions and marquee shifts are measured from the DOM and replayed through a ResizeObserver (sidebar toggle, fullscreen, PiP, rotation) and document.fonts.ready, because none of those re-render the band. **Verification** - `pnpm web-ui:test` — 98 tests pass, including 44 new ones for the timeline geometry / axis labels / tap decision table and 6 render-level checks - `pnpm type-check:tsc`, `biome check`, `vite build` clean - interaction paths and ruler geometry measured in a real (headless) browser, before and after a width change No docs change: `docs/guide/web-player.md` already covers EPG + time-shift. Happy to add a section if wanted.
1 parent 96ba191 commit f42d89f

11 files changed

Lines changed: 2775 additions & 0 deletions

‎web-ui/src/components/player/playback-time-context.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ export function usePlaybackTime(): number {
2525
clock?.getSnapshot ?? zero,
2626
);
2727
}
28+
29+
/**
30+
* The clock itself, for consumers that must not re-render at 1 Hz: the EPG timeline reads it
31+
* imperatively and only commits state when the value it derives (the snapped window anchor)
32+
* actually changes.
33+
*/
34+
export function usePlaybackClock(): PlaybackClock | null {
35+
return useContext(PlaybackClockContext);
36+
}

‎web-ui/src/components/player/player-controls.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { isNearLiveWallClock, type LiveSessionAnchor, mseToWallClock } from "../
2222
import type { Channel, EPGProgram } from "../../types/player";
2323
import { PLAYER_CONTROL_BUTTON_CLASS, PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames";
2424
import { usePlaybackTime } from "./playback-time-context";
25+
import { PlayerEpgTimeline } from "./player-epg-timeline";
2526
import { PlayerMediaBadges } from "./player-media-badges";
2627
import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers";
2728

@@ -30,6 +31,8 @@ interface PlayerControlsProps {
3031
channel: Channel;
3132
// EPG program information
3233
currentProgram: EPGProgram | null;
34+
// Every programme known for the current channel, for the EPG timeline band
35+
epgPrograms?: readonly EPGProgram[];
3336
// Whether we're in live mode or catchup mode
3437
isLive: boolean;
3538
// Callback when user seeks to a new position
@@ -72,6 +75,9 @@ const COMPACT_BUTTON_CLASS = "[@container_video_(max-height:_320px)]:p-1 md:[@co
7275
const COMPACT_ICON_CLASS =
7376
"[@container_video_(max-height:_320px)]:h-4 [@container_video_(max-height:_320px)]:w-4 md:[@container_video_(max-height:_320px)]:h-4 md:[@container_video_(max-height:_320px)]:w-4";
7477

78+
/** Shared identity, so a channel without guide data does not re-render the timeline band. */
79+
const NO_EPG_PROGRAMS: readonly EPGProgram[] = [];
80+
7581
function formatTime(date: Date, withSeconds = false) {
7682
return date.toLocaleTimeString([], {
7783
hour: "2-digit",
@@ -379,6 +385,7 @@ const PlayerTimeDisplay = memo(function PlayerTimeDisplay({
379385
function PlayerControlsComponent({
380386
channel,
381387
currentProgram,
388+
epgPrograms = NO_EPG_PROGRAMS,
382389
isLive,
383390
onSeek,
384391
onScrubbingChange,
@@ -419,6 +426,18 @@ function PlayerControlsComponent({
419426
"[@container_video_(max-height:_320px)]:gap-0.5 [@container_video_(max-height:_320px)]:pt-2 [@container_video_(max-height:_320px)]:pb-0.5 md:[@container_video_(max-height:_320px)]:gap-0.5 md:[@container_video_(max-height:_320px)]:pt-2 md:[@container_video_(max-height:_320px)]:pb-0.5 [@container_video_(max-height:_220px)]:pt-1 md:[@container_video_(max-height:_220px)]:pt-1",
420427
)}
421428
>
429+
{epgPrograms.length > 0 && (
430+
<PlayerEpgTimeline
431+
programs={epgPrograms}
432+
locale={locale}
433+
liveSessionAnchor={liveSessionAnchor}
434+
onScrubbingChange={onScrubbingChange}
435+
onSeek={onSeek}
436+
seekStartTime={seekStartTime}
437+
supportsCatchup={isCatchupSupported}
438+
/>
439+
)}
440+
422441
{hasTimeline && (
423442
<PlayerTimeline
424443
channel={channel}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { createElement } from "react";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vitest";
4+
import type { EPGProgram } from "../../types/player";
5+
import { PlayerEpgTimeline } from "./player-epg-timeline";
6+
7+
/**
8+
* Render-level checks for the timeline band. The band is measured and dragged in the browser,
9+
* but what it puts on screen — which programmes survive the window, and how each is classified —
10+
* is decided during render and can be asserted from the server renderer without a DOM.
11+
*
12+
* Pointer and keyboard paths are covered by the pure decision table in `lib/epg-timeline.test.ts`
13+
* (tap targets, pan clamping, wheel travel); this file only checks what render decides.
14+
*/
15+
16+
const MINUTE = 60_000;
17+
const noop = () => {};
18+
19+
function program(startOffsetMs: number, endOffsetMs: number, title: string): EPGProgram {
20+
const now = Date.now();
21+
const start = now + startOffsetMs;
22+
const end = now + endOffsetMs;
23+
return { id: title, title, start: new Date(start), end: new Date(end) };
24+
}
25+
26+
/**
27+
* The band's layout effects have no meaning on the server, so React warns about them. Only that
28+
* one warning is dropped — anything else still reaches the test output and fails loudly.
29+
*/
30+
function render(programs: readonly EPGProgram[], supportsCatchup = true): string {
31+
const originalError = console.error;
32+
const forwarded: unknown[][] = [];
33+
console.error = (...args: unknown[]) => {
34+
const first = String(args[0] ?? "");
35+
if (first.includes("useLayoutEffect")) return;
36+
forwarded.push(args);
37+
originalError(...args);
38+
};
39+
try {
40+
const html = renderToStaticMarkup(
41+
createElement(PlayerEpgTimeline, {
42+
programs,
43+
locale: "en",
44+
liveSessionAnchor: null,
45+
onScrubbingChange: noop,
46+
onSeek: noop,
47+
seekStartTime: new Date(),
48+
supportsCatchup,
49+
}),
50+
);
51+
expect(forwarded, "unexpected console.error output during render").toEqual([]);
52+
return html;
53+
} finally {
54+
console.error = originalError;
55+
}
56+
}
57+
58+
/** The class list of every rendered programme block, keyed by its `data-epg-block-id`. */
59+
function blockClasses(html: string): Map<string, string[]> {
60+
const blocks = new Map<string, string[]>();
61+
for (const match of html.matchAll(/<button[^>]*data-epg-block-id="([^"]+)"[^>]*class="([^"]*)"/g)) {
62+
blocks.set(match[1], match[2].split(/\s+/).filter(Boolean));
63+
}
64+
return blocks;
65+
}
66+
67+
const past = program(-80 * MINUTE, -50 * MINUTE, "past-show");
68+
const live = program(-20 * MINUTE, 20 * MINUTE, "live-show");
69+
const future = program(30 * MINUTE, 60 * MINUTE, "future-show");
70+
const outside = program(-10 * 60 * MINUTE, -9 * 60 * MINUTE, "far-away-show");
71+
const programmes = [outside, past, live, future];
72+
73+
describe("PlayerEpgTimeline", () => {
74+
it("renders only the programmes inside the window", () => {
75+
const html = render(programmes);
76+
expect(html).toContain("past-show");
77+
expect(html).toContain("live-show");
78+
expect(html).toContain("future-show");
79+
expect(html).not.toContain("far-away-show");
80+
});
81+
82+
it("classifies past, live and future blocks, whatever order the classes come in", () => {
83+
const blocks = blockClasses(render(programmes));
84+
expect(blocks.get("past-show")).toEqual(expect.arrayContaining(["is-past", "is-catchup", "is-playable"]));
85+
expect(blocks.get("live-show")).toEqual(expect.arrayContaining(["is-live", "is-playable"]));
86+
expect(blocks.get("future-show")).toEqual(expect.arrayContaining(["is-future"]));
87+
expect(blocks.get("future-show")).not.toContain("is-playable");
88+
// Only the programme that is on air gets the highlighted live treatment.
89+
expect(render(programmes).match(/is-live/g)).toHaveLength(1);
90+
});
91+
92+
it("drops catch-up affordances when the source cannot replay", () => {
93+
const blocks = blockClasses(render(programmes, false));
94+
expect(blocks.get("past-show")).not.toContain("is-catchup");
95+
expect(blocks.get("past-show")).not.toContain("is-playable");
96+
// The live block stays playable: returning to the live edge never needs catch-up.
97+
expect(blocks.get("live-show")).toEqual(expect.arrayContaining(["is-live", "is-playable"]));
98+
});
99+
100+
it("marks a programme that cannot be played as a disabled button", () => {
101+
const html = render(programmes);
102+
const futureBlock = html.match(/<button[^>]*data-epg-block-id="future-show"[^>]*>/)?.[0] ?? "";
103+
expect(futureBlock).toContain("disabled");
104+
expect(futureBlock).toContain('tabindex="-1"');
105+
const pastBlock = html.match(/<button[^>]*data-epg-block-id="past-show"[^>]*>/)?.[0] ?? "";
106+
expect(pastBlock).not.toContain("disabled");
107+
});
108+
109+
it("draws a tick per quarter hour and exposes the band as a slider", () => {
110+
const html = render(programmes);
111+
expect(html.match(/player-epg-timeline__tick /g)?.length ?? 0).toBeGreaterThanOrEqual(12);
112+
expect(html).toContain("is-hour");
113+
expect(html).toContain("is-half");
114+
expect(html).toContain("is-minor");
115+
expect(html).toContain('role="slider"');
116+
expect(html).toContain('aria-label="Program timeline"');
117+
// The ruler reports a position even before the 1 Hz playhead takes over the attribute.
118+
expect(html).toMatch(/aria-valuenow="\d+"/);
119+
expect(html).toMatch(/aria-valuetext="[^"]+"/);
120+
});
121+
122+
it("renders the hour grid, pan controls and current-time clock", () => {
123+
const html = render(programmes);
124+
expect(html).toContain("player-epg-timeline__grid");
125+
expect(html).toContain("--epg-hour-width");
126+
expect(html).toContain('title="Earlier 30 minutes"');
127+
expect(html).toContain('title="Later 30 minutes"');
128+
expect(html).toContain("player-epg-timeline__clock");
129+
});
130+
131+
it("renders nothing but the empty band without guide data", () => {
132+
const html = render([]);
133+
expect(html).toContain("player-epg-timeline__track");
134+
expect(html).not.toContain("data-epg-block-id");
135+
});
136+
});

0 commit comments

Comments
 (0)