diff --git a/web-ui/src/components/player/playback-time-context.tsx b/web-ui/src/components/player/playback-time-context.tsx index 409fe78f..fcbc2c25 100644 --- a/web-ui/src/components/player/playback-time-context.tsx +++ b/web-ui/src/components/player/playback-time-context.tsx @@ -25,3 +25,12 @@ export function usePlaybackTime(): number { clock?.getSnapshot ?? zero, ); } + +/** + * The clock itself, for consumers that must not re-render at 1 Hz: the EPG timeline reads it + * imperatively and only commits state when the value it derives (the snapped window anchor) + * actually changes. + */ +export function usePlaybackClock(): PlaybackClock | null { + return useContext(PlaybackClockContext); +} diff --git a/web-ui/src/components/player/player-controls.tsx b/web-ui/src/components/player/player-controls.tsx index 25a18eef..3f37fef8 100644 --- a/web-ui/src/components/player/player-controls.tsx +++ b/web-ui/src/components/player/player-controls.tsx @@ -22,6 +22,7 @@ import { isNearLiveWallClock, type LiveSessionAnchor, mseToWallClock } from "../ import type { Channel, EPGProgram } from "../../types/player"; import { PLAYER_CONTROL_BUTTON_CLASS, PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames"; import { usePlaybackTime } from "./playback-time-context"; +import { PlayerEpgTimeline } from "./player-epg-timeline"; import { PlayerMediaBadges } from "./player-media-badges"; import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers"; @@ -30,6 +31,8 @@ interface PlayerControlsProps { channel: Channel; // EPG program information currentProgram: EPGProgram | null; + // Every programme known for the current channel, for the EPG timeline band + epgPrograms?: readonly EPGProgram[]; // Whether we're in live mode or catchup mode isLive: boolean; // 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 const COMPACT_ICON_CLASS = "[@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"; +/** Shared identity, so a channel without guide data does not re-render the timeline band. */ +const NO_EPG_PROGRAMS: readonly EPGProgram[] = []; + function formatTime(date: Date, withSeconds = false) { return date.toLocaleTimeString([], { hour: "2-digit", @@ -379,6 +385,7 @@ const PlayerTimeDisplay = memo(function PlayerTimeDisplay({ function PlayerControlsComponent({ channel, currentProgram, + epgPrograms = NO_EPG_PROGRAMS, isLive, onSeek, onScrubbingChange, @@ -419,6 +426,18 @@ function PlayerControlsComponent({ "[@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", )} > + {epgPrograms.length > 0 && ( + + )} + {hasTimeline && ( {}; + +function program(startOffsetMs: number, endOffsetMs: number, title: string): EPGProgram { + const now = Date.now(); + const start = now + startOffsetMs; + const end = now + endOffsetMs; + return { id: title, title, start: new Date(start), end: new Date(end) }; +} + +/** + * The band's layout effects have no meaning on the server, so React warns about them. Only that + * one warning is dropped — anything else still reaches the test output and fails loudly. + */ +function render(programs: readonly EPGProgram[], supportsCatchup = true): string { + const originalError = console.error; + const forwarded: unknown[][] = []; + console.error = (...args: unknown[]) => { + const first = String(args[0] ?? ""); + if (first.includes("useLayoutEffect")) return; + forwarded.push(args); + originalError(...args); + }; + try { + const html = renderToStaticMarkup( + createElement(PlayerEpgTimeline, { + programs, + locale: "en", + liveSessionAnchor: null, + onScrubbingChange: noop, + onSeek: noop, + seekStartTime: new Date(), + supportsCatchup, + }), + ); + expect(forwarded, "unexpected console.error output during render").toEqual([]); + return html; + } finally { + console.error = originalError; + } +} + +/** The class list of every rendered programme block, keyed by its `data-epg-block-id`. */ +function blockClasses(html: string): Map { + const blocks = new Map(); + for (const match of html.matchAll(/]*data-epg-block-id="([^"]+)"[^>]*class="([^"]*)"/g)) { + blocks.set(match[1], match[2].split(/\s+/).filter(Boolean)); + } + return blocks; +} + +const past = program(-80 * MINUTE, -50 * MINUTE, "past-show"); +const live = program(-20 * MINUTE, 20 * MINUTE, "live-show"); +const future = program(30 * MINUTE, 60 * MINUTE, "future-show"); +const outside = program(-10 * 60 * MINUTE, -9 * 60 * MINUTE, "far-away-show"); +const programmes = [outside, past, live, future]; + +describe("PlayerEpgTimeline", () => { + it("renders only the programmes inside the window", () => { + const html = render(programmes); + expect(html).toContain("past-show"); + expect(html).toContain("live-show"); + expect(html).toContain("future-show"); + expect(html).not.toContain("far-away-show"); + }); + + it("classifies past, live and future blocks, whatever order the classes come in", () => { + const blocks = blockClasses(render(programmes)); + expect(blocks.get("past-show")).toEqual(expect.arrayContaining(["is-past", "is-catchup", "is-playable"])); + expect(blocks.get("live-show")).toEqual(expect.arrayContaining(["is-live", "is-playable"])); + expect(blocks.get("future-show")).toEqual(expect.arrayContaining(["is-future"])); + expect(blocks.get("future-show")).not.toContain("is-playable"); + // Only the programme that is on air gets the highlighted live treatment. + expect(render(programmes).match(/is-live/g)).toHaveLength(1); + }); + + it("drops catch-up affordances when the source cannot replay", () => { + const blocks = blockClasses(render(programmes, false)); + expect(blocks.get("past-show")).not.toContain("is-catchup"); + expect(blocks.get("past-show")).not.toContain("is-playable"); + // The live block stays playable: returning to the live edge never needs catch-up. + expect(blocks.get("live-show")).toEqual(expect.arrayContaining(["is-live", "is-playable"])); + }); + + it("marks a programme that cannot be played as a disabled button", () => { + const html = render(programmes); + const futureBlock = html.match(/]*data-epg-block-id="future-show"[^>]*>/)?.[0] ?? ""; + expect(futureBlock).toContain("disabled"); + expect(futureBlock).toContain('tabindex="-1"'); + const pastBlock = html.match(/]*data-epg-block-id="past-show"[^>]*>/)?.[0] ?? ""; + expect(pastBlock).not.toContain("disabled"); + }); + + it("draws a tick per quarter hour and exposes the band as a slider", () => { + const html = render(programmes); + expect(html.match(/player-epg-timeline__tick /g)?.length ?? 0).toBeGreaterThanOrEqual(12); + expect(html).toContain("is-hour"); + expect(html).toContain("is-half"); + expect(html).toContain("is-minor"); + expect(html).toContain('role="slider"'); + expect(html).toContain('aria-label="Program timeline"'); + // The ruler reports a position even before the 1 Hz playhead takes over the attribute. + expect(html).toMatch(/aria-valuenow="\d+"/); + expect(html).toMatch(/aria-valuetext="[^"]+"/); + }); + + it("renders the hour grid, pan controls and current-time clock", () => { + const html = render(programmes); + expect(html).toContain("player-epg-timeline__grid"); + expect(html).toContain("--epg-hour-width"); + expect(html).toContain('title="Earlier 30 minutes"'); + expect(html).toContain('title="Later 30 minutes"'); + expect(html).toContain("player-epg-timeline__clock"); + }); + + it("renders nothing but the empty band without guide data", () => { + const html = render([]); + expect(html).toContain("player-epg-timeline__track"); + expect(html).not.toContain("data-epg-block-id"); + }); +}); diff --git a/web-ui/src/components/player/player-epg-timeline.tsx b/web-ui/src/components/player/player-epg-timeline.tsx new file mode 100644 index 00000000..017e7991 --- /dev/null +++ b/web-ui/src/components/player/player-epg-timeline.tsx @@ -0,0 +1,1011 @@ +import { clsx } from "clsx"; +import { ChevronLeft, ChevronRight, History } from "lucide-react"; +import { + memo, + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { usePlayerTranslation } from "../../hooks/use-player-translation"; +import { useWallClockMinute } from "../../hooks/use-wall-clock-minute"; +import { useWallClockSecond } from "../../hooks/use-wall-clock-second"; +import type { TranslationKey } from "../../i18n/player"; +import { + chooseEpgTickMinutes, + clampEpgPan, + clampPercent, + createEpgAxisLabels, + createEpgTicks, + createEpgTimelineBlocks, + createEpgTimelineWindow, + EPG_PAN_MINUTES, + EPG_WHEEL_STEP_PX, + EPG_WINDOW_MINUTES_AFTER, + EPG_WINDOW_MINUTES_BEFORE, + type EpgAxisLabel, + type EpgTimelineWindow, + findEpgProgramAt, + getEpgAnchorBounds, + getLocalDayOffset, + getLocalDayStart, + isTimeInWindow, + normalizeWheelDelta, + percentToTime, + resolveEpgTapAction, + snapToTick, + timeToPercent, +} from "../../lib/epg-timeline"; +import type { Locale } from "../../lib/locale"; +import { isNearLiveWallClock, type LiveSessionAnchor, mseToWallClock } from "../../playback-engine/timeline/wall-clock"; +import type { EPGProgram } from "../../types/player"; +import { PLAYER_OVERLAY_SURFACE_CLASS } from "./classnames"; +import { usePlaybackClock, usePlaybackTime } from "./playback-time-context"; +import { PlayerSelectedGlassLayers } from "./player-selected-glass-layers"; + +/** + * EPG timeline band for the bottom of the player, above the progress bar. + * + * A bounded, pannable window (±90 minutes around playback) rather than a scroll container: the + * guide can be panned across days without the DOM ever holding more than the programmes that + * intersect the window, and the fine grid is a CSS gradient instead of one node per tick. The + * band owns three pointer interactions — scrub the playhead, click a programme to replay it, and + * hover for details — and pans through the ◀ ▶ buttons, the mouse wheel, or ← / → while the ruler + * has focus. Dragging inside the band always scrubs; it never pans. + */ + +interface PlayerEpgTimelineProps { + /** Every programme known for the current channel, sorted by start time. */ + programs: readonly EPGProgram[]; + locale: Locale; + liveSessionAnchor: LiveSessionAnchor | null; + /** Keeps the auto-hiding controls on screen while the band is dragged or a prompt is open. */ + onScrubbingChange: (isScrubbing: boolean) => void; + onSeek: (seekTime: Date) => void; + seekStartTime: Date; + supportsCatchup: boolean; +} + +type EpgTimelineAction = + | { kind: "confirm-catchup"; program: EPGProgram; anchorPercent: number } + | { kind: "notice"; message: string; anchorPercent: number }; + +interface DragState { + pointerId: number; + startX: number; + moved: boolean; +} + +const WINDOW_MINUTES_BEFORE = EPG_WINDOW_MINUTES_BEFORE; +const WINDOW_MINUTES_AFTER = EPG_WINDOW_MINUTES_AFTER; +const WINDOW_SPAN_MINUTES = WINDOW_MINUTES_BEFORE + WINDOW_MINUTES_AFTER; +/** Fixed at build time: the window is always the same width, so the ruler never re-zooms. */ +const TICK_MINUTES = chooseEpgTickMinutes(WINDOW_SPAN_MINUTES); + +/** Pointer travel that turns a click on the band into a scrub. */ +const DRAG_THRESHOLD_PX = 4; +const TOOLTIP_SHOW_DELAY_MS = 300; +const TOOLTIP_HIDE_DELAY_MS = 200; +const NOTICE_DURATION_MS = 2600; +const TOOLTIP_EDGE_PADDING_PX = 12; +/** Breathing room kept between two ruler labels before one of them is dropped. */ +const AXIS_LABEL_GAP_PX = 6; +/** A playhead further ahead of the wall clock than this is a stale clock, not a seek target. */ +const MAX_PLAUSIBLE_LEAD_MS = 60_000; +/** A wheel pause longer than this starts a fresh gesture instead of continuing the last one. */ +const WHEEL_GESTURE_GAP_MS = 250; + +let clockFormatter: Intl.DateTimeFormat | null = null; +let preciseClockFormatter: Intl.DateTimeFormat | null = null; + +function two(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +/** Locale-aware clock, matching the progress bar's own tooltip formatting. */ +function formatClock(timeMs: number): string { + clockFormatter ??= new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }); + return clockFormatter.format(timeMs); +} + +function formatClockSeconds(timeMs: number): string { + preciseClockFormatter ??= new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + return preciseClockFormatter.format(timeMs); +} + +/** 24-hour compact form for the ruler and the blocks, so a dense ruler stays unambiguous. */ +function formatRulerClock(timeMs: number): string { + const date = new Date(timeMs); + return `${two(date.getHours())}:${two(date.getMinutes())}`; +} + +/** Ruler labels: whole hours name the scale, programme boundaries name the guide. */ +function formatAxisLabel(label: EpgAxisLabel): string { + return label.kind === "hour" ? `${two(label.hour)}:00` : `${two(label.hour)}:${two(label.minute)}`; +} + +function formatMonthDay(timeMs: number): string { + const date = new Date(timeMs); + return `${date.getMonth() + 1}/${date.getDate()}`; +} + +/** + * A day, named relatively while it is close enough to be worth naming. The date always follows, + * so a relative label is never the only thing telling the user which day is on screen. + */ +function formatDayLabel(dayMs: number, nowMs: number, t: (key: TranslationKey) => string): string { + const monthDay = formatMonthDay(dayMs); + switch (getLocalDayOffset(dayMs, nowMs)) { + case 0: + return `${t("today")} ${monthDay}`; + case -1: + return `${t("yesterday")} ${monthDay}`; + case -2: + return `${t("dayBeforeYesterday")} ${monthDay}`; + case 1: + return `${t("tomorrow")} ${monthDay}`; + default: + return monthDay; + } +} + +/** The window's day, plus the next day when the window runs past midnight. */ +function formatWindowDayLabel( + startMs: number, + endMs: number, + nowMs: number, + t: (key: TranslationKey) => string, +): string { + const startLabel = formatDayLabel(getLocalDayStart(startMs), nowMs, t); + const endDayMs = getLocalDayStart(endMs); + if (endDayMs === getLocalDayStart(startMs)) return startLabel; + return `${startLabel} – ${formatDayLabel(endDayMs, nowMs, t)}`; +} + +function formatRange(startMs: number, endMs: number): string { + return `${formatClock(startMs)} ~ ${formatClock(endMs)}`; +} + +/** + * The live edge, drawn separately from the playhead: during catch-up the two diverge and the + * gap between them is the part of the guide that can still be watched. + */ +const EpgNowMarker = memo(function EpgNowMarker({ timelineWindow }: { timelineWindow: EpgTimelineWindow }) { + const nowMs = useWallClockSecond(); + if (!isTimeInWindow(timelineWindow, nowMs)) return null; + return ( +