+ );
+});
+
+function PlayerEpgTimelineComponent({
+ programs,
+ locale,
+ liveSessionAnchor,
+ onScrubbingChange,
+ onSeek,
+ seekStartTime,
+ supportsCatchup,
+}: PlayerEpgTimelineProps) {
+ const t = usePlayerTranslation(locale);
+ const clock = usePlaybackClock();
+ // Programme states only change on minute boundaries, so the list itself never needs the 1 Hz clock.
+ const nowMinuteMs = useWallClockMinute();
+
+ const rootRef = useRef(null);
+ const axisRef = useRef(null);
+ const trackRef = useRef(null);
+ const previewRef = useRef(null);
+ const previewBubbleRef = useRef(null);
+ const previewTimeRef = useRef(null);
+ const previewTitleRef = useRef(null);
+ const tooltipRef = useRef(null);
+ const popoverRef = useRef(null);
+ const frameRef = useRef(0);
+ const pointerXRef = useRef(null);
+ const dragRef = useRef(null);
+
+ const [anchorMs, setAnchorMs] = useState(() => snapToTick(Date.now(), TICK_MINUTES));
+ /** Non-null once the user pans: the window stops following playback until they return to it. */
+ const [manualAnchorMs, setManualAnchorMs] = useState(null);
+ const [scrubbing, setScrubbing] = useState(false);
+ const [hoveredProgramId, setHoveredProgramId] = useState(null);
+ const [tooltipVisible, setTooltipVisible] = useState(false);
+ const [action, setAction] = useState(null);
+
+ /** Where the window sits: the manual pan when the user has one, otherwise the followed anchor. */
+ const windowAnchorMs = manualAnchorMs ?? anchorMs;
+ const timelineWindow = useMemo(
+ () => createEpgTimelineWindow(windowAnchorMs, TICK_MINUTES, WINDOW_MINUTES_BEFORE, WINDOW_MINUTES_AFTER),
+ [windowAnchorMs],
+ );
+ const ticks = useMemo(() => createEpgTicks(timelineWindow, TICK_MINUTES), [timelineWindow]);
+ const blocks = useMemo(
+ () => createEpgTimelineBlocks(programs, timelineWindow, nowMinuteMs, supportsCatchup),
+ [programs, timelineWindow, nowMinuteMs, supportsCatchup],
+ );
+ const axisLabels = useMemo(() => createEpgAxisLabels(timelineWindow, ticks, blocks), [timelineWindow, ticks, blocks]);
+ const windowDayLabel = formatWindowDayLabel(timelineWindow.startMs, timelineWindow.endMs, nowMinuteMs, t);
+ const anchorBounds = useMemo(
+ () => getEpgAnchorBounds(programs, WINDOW_MINUTES_BEFORE, WINDOW_MINUTES_AFTER),
+ [programs],
+ );
+ const hoveredProgram = useMemo(
+ () => (hoveredProgramId ? (programs.find((program) => program.id === hoveredProgramId) ?? null) : null),
+ [hoveredProgramId, programs],
+ );
+
+ // Read by the (possibly stale) rAF callback and by pointer handlers, which only ever see the
+ // props of the render that created them.
+ const latestRef = useRef({ timelineWindow, programs, liveSessionAnchor, seekStartTime, t });
+ latestRef.current = { timelineWindow, programs, liveSessionAnchor, seekStartTime, t };
+
+ // Follow playback while the user has not panned. The anchor is snapped to a tick, so this
+ // commits state — and re-renders the band — once per tick rather than once per second.
+ useEffect(() => {
+ if (manualAnchorMs !== null) return;
+ const sync = () => {
+ const playbackMs = mseToWallClock(clock?.get() ?? 0, seekStartTime).getTime();
+ const next = snapToTick(Number.isFinite(playbackMs) ? playbackMs : Date.now(), TICK_MINUTES);
+ setAnchorMs((previous) => (previous === next ? previous : next));
+ };
+ sync();
+ return clock?.subscribe(sync);
+ }, [clock, seekStartTime, manualAnchorMs]);
+
+ // A seek re-centres the guide on where playback actually restarted.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the new start time is the trigger; the effect body itself only clears the manual pan.
+ useEffect(() => {
+ setManualAnchorMs(null);
+ }, [seekStartTime]);
+
+ const busy = scrubbing || action !== null;
+ useEffect(() => {
+ if (!busy) return;
+ onScrubbingChange(true);
+ return () => onScrubbingChange(false);
+ }, [busy, onScrubbingChange]);
+
+ const pan = useCallback(
+ (deltaMinutes: number) => {
+ // Pans from where the window is now, which is the followed anchor until the first pan.
+ setManualAnchorMs(clampToRange(windowAnchorMs + deltaMinutes * 60_000, anchorBounds));
+ },
+ [windowAnchorMs, anchorBounds],
+ );
+
+ const followPlayback = useCallback(() => {
+ setManualAnchorMs(null);
+ }, []);
+
+ // The wheel pans the ruler and must not reach the page or the player's own gestures.
+ useEffect(() => {
+ const root = rootRef.current;
+ if (!root) return;
+ const handleWheel = (event: WheelEvent) => {
+ if (event.deltaY === 0) return;
+ event.preventDefault();
+ event.stopPropagation();
+ pan(event.deltaY > 0 ? EPG_PAN_MINUTES : -EPG_PAN_MINUTES);
+ };
+ root.addEventListener("wheel", handleWheel, { passive: false });
+ return () => root.removeEventListener("wheel", handleWheel);
+ }, [pan]);
+
+ // Prompts and notices close on an outside press or Escape.
+ useEffect(() => {
+ if (!action) return;
+ const handlePointerDown = (event: PointerEvent) => {
+ if (popoverRef.current?.contains(event.target as Node)) return;
+ setAction(null);
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setAction(null);
+ };
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [action]);
+
+ // Plain information notices retire on their own; confirmations wait for an answer.
+ useEffect(() => {
+ if (action?.kind !== "notice") return;
+ const timer = window.setTimeout(() => setAction(null), NOTICE_DURATION_MS);
+ return () => window.clearTimeout(timer);
+ }, [action]);
+
+ // A window that moves under an open prompt would leave it pointing at the wrong programme.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the window's identity is the trigger
+ useEffect(() => {
+ setAction(null);
+ }, [timelineWindow.startMs]);
+
+ // Hover details: shown after a short dwell, hidden after a short grace period so crossing the
+ // gap between two blocks does not make the tooltip flicker.
+ useEffect(() => {
+ if (scrubbing || hoveredProgramId === null) {
+ const timer = window.setTimeout(() => setTooltipVisible(false), TOOLTIP_HIDE_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }
+ const timer = window.setTimeout(() => setTooltipVisible(true), TOOLTIP_SHOW_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [hoveredProgramId, scrubbing]);
+
+ /** Centres a floating element over the pointer, kept clear of the band's edges. */
+ const positionFloating = useCallback((element: HTMLElement | null) => {
+ const root = rootRef.current;
+ const pointerX = pointerXRef.current;
+ if (!element || !root || pointerX === null) return;
+ const rootRect = root.getBoundingClientRect();
+ const half = element.offsetWidth / 2;
+ const minX = half + TOOLTIP_EDGE_PADDING_PX;
+ const maxX = rootRect.width - half - TOOLTIP_EDGE_PADDING_PX;
+ const x = maxX < minX ? rootRect.width / 2 : Math.min(Math.max(pointerX - rootRect.left, minX), maxX);
+ element.style.left = `${x}px`;
+ }, []);
+
+ const positionTooltip = useCallback(() => {
+ positionFloating(tooltipRef.current);
+ }, [positionFloating]);
+
+ // Everything that follows the pointer is written straight to the DOM: a scrub must not
+ // re-render the band (and its programme blocks) at pointer frequency.
+ const paintPreview = useCallback(() => {
+ frameRef.current = 0;
+ const preview = previewRef.current;
+ const track = trackRef.current;
+ const pointerX = pointerXRef.current;
+ if (!preview || !track || pointerX === null) return;
+
+ const rect = track.getBoundingClientRect();
+ if (rect.width === 0) return;
+
+ const latest = latestRef.current;
+ const percent = clampPercent(((pointerX - rect.left) / rect.width) * 100);
+ const timeMs = percentToTime(latest.timelineWindow, percent);
+ const goesLive =
+ timeMs >= Date.now() || isNearLiveWallClock(new Date(timeMs), latest.liveSessionAnchor, latest.seekStartTime);
+
+ preview.style.left = `${percent}%`;
+ positionFloating(previewBubbleRef.current);
+ if (previewTimeRef.current) {
+ previewTimeRef.current.textContent = goesLive ? latest.t("goLive") : formatClockSeconds(timeMs);
+ }
+ if (previewTitleRef.current) {
+ previewTitleRef.current.textContent =
+ findEpgProgramAt(latest.programs, timeMs)?.title || latest.t("excellentProgram");
+ }
+ }, [positionFloating]);
+
+ const schedulePreview = useCallback(() => {
+ if (frameRef.current) return;
+ frameRef.current = window.requestAnimationFrame(paintPreview);
+ }, [paintPreview]);
+
+ useEffect(
+ () => () => {
+ if (frameRef.current) window.cancelAnimationFrame(frameRef.current);
+ },
+ [],
+ );
+
+ // The tooltip is centred on the pointer, so it is re-measured after every render: the band
+ // re-renders on the minute clock and on hover changes, and a stale centre would let a long
+ // title push past the edge of the video. The work is a single layout read.
+ useLayoutEffect(() => {
+ positionTooltip();
+ });
+
+ /**
+ * Marquee only for titles that actually overflow their block, measured in one read pass so a
+ * long guide costs a single layout, not one per block. The measured shift also drives the
+ * always-on scroll, so a name too long for its block stays readable without a pointer.
+ *
+ * Reads the DOM only, so it can also be replayed from the resize observer: the overflow of a
+ * name depends on how wide its block currently is.
+ */
+ const measureMarqueeTitles = useCallback(() => {
+ const track = trackRef.current;
+ if (!track) return;
+ const titles = Array.from(track.querySelectorAll("[data-epg-marquee]"));
+ const overflows = titles.map((title) => {
+ const clip = title.parentElement;
+ // The title is an inline-block sized to its text, so offsetWidth is the full label in any
+ // hover state; the clip is the room the block actually offers it.
+ return clip ? Math.max(0, title.offsetWidth - clip.clientWidth) : 0;
+ });
+ titles.forEach((title, index) => {
+ const overflow = overflows[index];
+ const block = title.closest(".player-epg-timeline__block");
+ if (overflow > 0) {
+ title.setAttribute("data-overflow", "true");
+ title.style.setProperty("--epg-marquee-shift", `-${overflow}px`);
+ // Marked on the block as well: the clip has to drop its ellipsis while the text scrolls,
+ // and a block-level hook avoids relying on :has() support.
+ block?.setAttribute("data-title-overflow", "true");
+ } else {
+ title.removeAttribute("data-overflow");
+ title.style.removeProperty("--epg-marquee-shift");
+ block?.removeAttribute("data-title-overflow");
+ }
+ });
+ }, []);
+
+ /**
+ * Axis labels are placed from their measured width: numbers are pinned to the ruler's edges
+ * instead of hanging off it, and a label that would collide with one already placed is dropped.
+ * Labels that fit stay in document order, so the ruler reads left to right.
+ *
+ * Reads the DOM only, so the resize observer can replay it — the placement is in pixels while
+ * the scale is in percentages, and a stale pass would leave the numbers off their own ticks.
+ */
+ const placeAxisLabels = useCallback(() => {
+ const axis = axisRef.current;
+ if (!axis) return;
+ const nodes = Array.from(axis.querySelectorAll("[data-epg-axis-label]"));
+ const axisWidth = axis.clientWidth;
+ if (axisWidth === 0) return;
+
+ // Writes first (clear the previous verdict), then reads, then writes again: one extra layout
+ // per window change buys exact widths instead of guessed ones.
+ for (const node of nodes) node.style.visibility = "";
+
+ const measured = nodes.map((node) => {
+ const width = node.offsetWidth;
+ const percent = Number(node.dataset.percent ?? "0");
+ return {
+ node,
+ width,
+ center: (percent / 100) * axisWidth,
+ // Whole hours name the scale, so they win the space when a boundary label would collide.
+ priority: node.dataset.kind === "hour" ? 0 : 1,
+ };
+ });
+ measured.sort((a, b) => a.priority - b.priority || a.center - b.center);
+
+ const placed: Array<{ left: number; right: number }> = [];
+ for (const { node, width, center } of measured) {
+ const half = width / 2;
+ // Pinned inside the ruler: a label never hangs off either end, whatever the window shows.
+ const pinnedCenter = Math.min(Math.max(center, half), Math.max(half, axisWidth - half));
+ const left = pinnedCenter - half;
+ const right = pinnedCenter + half;
+ const collides = placed.some(
+ (box) => left < box.right + AXIS_LABEL_GAP_PX && box.left - AXIS_LABEL_GAP_PX < right,
+ );
+ if (collides) {
+ node.style.visibility = "hidden";
+ continue;
+ }
+ placed.push({ left, right });
+ node.style.left = `${pinnedCenter}px`;
+ }
+ }, []);
+
+ // Both passes read the DOM and write only to absolutely positioned children, so they can be
+ // replayed whenever the band's box changes size — nothing they write can resize it again.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the rendered blocks and the language are the trigger; the body reads neither.
+ useLayoutEffect(() => {
+ measureMarqueeTitles();
+ }, [blocks, locale, measureMarqueeTitles]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: which labels are rendered is the trigger; the body reads neither.
+ useLayoutEffect(() => {
+ placeAxisLabels();
+ }, [axisLabels, placeAxisLabels]);
+
+ // The ruler's geometry is a percentage of the band, but its labels are pinned in pixels, so a
+ // pure resize — the sidebar opening or closing, fullscreen, picture-in-picture, a window drag,
+ // a rotated phone — has to replay both passes. The band does not even re-render for most of
+ // those: its props are unchanged, so nothing else would.
+ useEffect(() => {
+ const root = rootRef.current;
+ if (!root || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(() => {
+ placeAxisLabels();
+ measureMarqueeTitles();
+ });
+ observer.observe(root);
+ return () => observer.disconnect();
+ }, [placeAxisLabels, measureMarqueeTitles]);
+
+ // A late webfont changes how wide every label and title is without changing the band's box, so
+ // the resize observer would never hear about it. Re-measure once the fonts settle.
+ useEffect(() => {
+ let cancelled = false;
+ void document.fonts?.ready.then(() => {
+ if (cancelled) return;
+ placeAxisLabels();
+ measureMarqueeTitles();
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [placeAxisLabels, measureMarqueeTitles]);
+
+ const resolvePointerTime = useCallback((clientX: number): number | null => {
+ const track = trackRef.current;
+ if (!track) return null;
+ const rect = track.getBoundingClientRect();
+ if (rect.width === 0) return null;
+ return percentToTime(latestRef.current.timelineWindow, clampPercent(((clientX - rect.left) / rect.width) * 100));
+ }, []);
+
+ /** Never seek into the future: the last reachable moment is now, which reads as "go live". */
+ const seekTo = useCallback(
+ (timeMs: number) => {
+ onSeek(new Date(Math.min(timeMs, Date.now())));
+ },
+ [onSeek],
+ );
+
+ const showNotice = useCallback((message: string, anchorPercent: number) => {
+ setAction({ kind: "notice", message, anchorPercent });
+ }, []);
+
+ const handleTrackPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!event.isPrimary || dragRef.current) return;
+ if (event.pointerType === "mouse" && event.button !== 0) return;
+ const rect = trackRef.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) return;
+ // The band is the only place a horizontal drag may work: the surface gesture layer is a
+ // sibling underneath, so capturing here keeps zapping and swipe-seek out of the ruler.
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ pointerXRef.current = event.clientX;
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, moved: false };
+ setAction(null);
+ // A channel with no catch-up source has nothing to scrub to, but a click still deserves
+ // its answer, so the drag bookkeeping runs either way and only the preview is skipped.
+ if (supportsCatchup) {
+ setScrubbing(true);
+ schedulePreview();
+ }
+ },
+ [schedulePreview, supportsCatchup],
+ );
+
+ const handleTrackPointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ pointerXRef.current = event.clientX;
+ const drag = dragRef.current;
+ if (drag && drag.pointerId === event.pointerId) {
+ if (!drag.moved && Math.abs(event.clientX - drag.startX) > DRAG_THRESHOLD_PX) drag.moved = true;
+ if (supportsCatchup) {
+ event.preventDefault();
+ schedulePreview();
+ }
+ return;
+ }
+ if (tooltipVisible) positionTooltip();
+ },
+ [positionTooltip, schedulePreview, supportsCatchup, tooltipVisible],
+ );
+
+ const endDrag = useCallback((pointerId: number) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== pointerId) return null;
+ dragRef.current = null;
+ setScrubbing(false);
+ if (previewRef.current) previewRef.current.style.left = "";
+ return drag;
+ }, []);
+
+ const handleTrackPointerUp = useCallback(
+ (event: ReactPointerEvent) => {
+ const drag = endDrag(event.pointerId);
+ if (!drag) return;
+ event.preventDefault();
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+
+ const timeMs = resolvePointerTime(event.clientX);
+ if (timeMs === null) return;
+
+ if (drag.moved) {
+ if (supportsCatchup) seekTo(timeMs);
+ return;
+ }
+
+ // A press without travel is a click: the pure decision table turns the moment under the
+ // pointer into one of seek / go live / ask about catch-up / explain why nothing happens.
+ const anchorPercent = clampPercent(timeToPercent(timelineWindow, timeMs));
+ const tapAction = resolveEpgTapAction(programs, timeMs, Date.now(), supportsCatchup);
+ switch (tapAction.kind) {
+ case "seek":
+ seekTo(tapAction.timeMs);
+ return;
+ case "go-live":
+ onSeek(new Date());
+ return;
+ case "notice":
+ showNotice(
+ tapAction.reason === "not-aired" ? t("epgNotAiredYet") : t("epgCatchupUnsupported"),
+ anchorPercent,
+ );
+ return;
+ case "confirm-catchup":
+ setAction({ kind: "confirm-catchup", program: tapAction.program, anchorPercent });
+ }
+ },
+ [endDrag, onSeek, programs, resolvePointerTime, seekTo, showNotice, supportsCatchup, t, timelineWindow],
+ );
+
+ const handleTrackPointerCancel = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!endDrag(event.pointerId)) return;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ },
+ [endDrag],
+ );
+
+ const handleTrackPointerOver = useCallback((event: ReactPointerEvent) => {
+ const block = (event.target as HTMLElement).closest("[data-epg-block-id]");
+ const id = block?.dataset.epgBlockId ?? null;
+ setHoveredProgramId((previous) => (previous === id ? previous : id));
+ }, []);
+
+ const handleTrackPointerLeave = useCallback(() => {
+ setHoveredProgramId(null);
+ }, []);
+
+ const confirmCatchup = useCallback(() => {
+ if (action?.kind !== "confirm-catchup") return;
+ setAction(null);
+ seekTo(action.program.start.getTime());
+ }, [action, seekTo]);
+
+ const dismissAction = useCallback(() => setAction(null), []);
+
+ return (
+
+
+ {/* Always on, including in the compact layout where the rest of the toolbar folds away:
+ without it, a window that crosses midnight has nothing naming the day. */}
+ {windowDayLabel}
+
+
+ {formatRange(timelineWindow.startMs, timelineWindow.endMs)}
+
+
+
+
+
+
+ {manualAnchorMs !== null && (
+
+ )}
+
+
+ {/* The scale is drawn by the ticks; the labels come from the hour ticks and the programme
+ boundaries, and are positioned from their measured width. */}
+
+
+ {/* The scrub preview and the prompts sit outside the track: the track clips its blocks,
+ and both must be able to float above the band. */}
+
+
+
+
+
+
+
+
+
+ {action && (
+