From b604e25e6d42cb3413e0fb1d0292c5d82583fffd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 02:38:30 +0000 Subject: [PATCH 01/10] perf(web-ui): lazily render EPG day sections and precompute row labels Render each day of the programme guide only once it approaches the scroll viewport (shared IntersectionObserver, fixed-height placeholders), always rendering the section holding the playing programme so auto-scroll still works. Row time labels are formatted once per EPG update with a cached Intl.DateTimeFormat instead of toLocaleTimeString per render, and the on-air clock ticks on minute boundaries instead of every second. Co-authored-by: Stackie Jia --- web-ui/src/components/player/epg-view.tsx | 325 +++++++++++++++------- 1 file changed, 230 insertions(+), 95 deletions(-) diff --git a/web-ui/src/components/player/epg-view.tsx b/web-ui/src/components/player/epg-view.tsx index 5d03de21..263b49ea 100644 --- a/web-ui/src/components/player/epg-view.tsx +++ b/web-ui/src/components/player/epg-view.tsx @@ -3,8 +3,8 @@ import { Circle, History } from "lucide-react"; import { memo, type RefObject, + startTransition, useCallback, - useDeferredValue, useEffect, useLayoutEffect, useMemo, @@ -35,30 +35,58 @@ interface EPGViewProps { export const nextScrollBehaviorRef: RefObject<"smooth" | "instant" | "skip"> = { current: "instant" }; +/** + * Sections this far outside the scroll viewport are hydrated ahead of time so + * scrolling into them never shows an empty placeholder. + */ +const SECTION_HYDRATION_MARGIN = "800px 0px"; + +let timeFormatter: Intl.DateTimeFormat | null = null; +function formatProgramTime(date: Date): string { + timeFormatter ??= new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }); + return timeFormatter.format(date); +} + +interface EPGProgramRow { + program: EPGProgram; + startLabel: string; + durationMinutes: number; +} + +interface EPGDateSectionData { + dateKey: string; + date: Date; + rows: EPGProgramRow[]; +} + +type SectionVisibilityHandler = () => void; + interface EPGProgramItemProps { currentProgramRef: RefObject; + durationMinutes: number; handleProgramClick: (programStart: Date, programEnd: Date) => void; isPast: boolean; locale: Locale; onAir: boolean; playing: boolean; program: EPGProgram; + startLabel: string; supportsCatchup: boolean; } const EPGProgramItem = memo(function EPGProgramItem({ currentProgramRef, + durationMinutes, handleProgramClick, isPast, locale, onAir, playing, program, + startLabel, supportsCatchup, }: EPGProgramItemProps) { const t = usePlayerTranslation(locale); - const formatTime = (date: Date) => date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - const durationMinutes = Math.round((program.end.getTime() - program.start.getTime()) / 60000); return (