diff --git a/web-ui/src/components/player/channel-list.tsx b/web-ui/src/components/player/channel-list.tsx index e445dc21..3bacfb67 100644 --- a/web-ui/src/components/player/channel-list.tsx +++ b/web-ui/src/components/player/channel-list.tsx @@ -1,18 +1,8 @@ import { clsx } from "clsx"; import { Search } from "lucide-react"; -import { - memo, - type RefObject, - startTransition, - useCallback, - useDeferredValue, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, type RefObject, useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePlayerTranslation } from "../../hooks/use-player-translation"; +import { useWallClockMinute } from "../../hooks/use-wall-clock-minute"; import { type EPGData, getCurrentProgram, getEPGChannelId } from "../../lib/epg-parser"; import type { Locale } from "../../lib/locale"; import type { Channel } from "../../types/player"; @@ -114,14 +104,9 @@ function ChannelListComponent({ const deferredSelectedGroup = useDeferredValue(selectedGroup); const currentChannelRef = useRef(null); - // Re-compute current programs every minute (low-priority update) - const [now, setNow] = useState(() => new Date()); - useEffect(() => { - const timer = setInterval(() => { - startTransition(() => setNow(new Date())); - }, 60_000); - return () => clearInterval(timer); - }, []); + // "Now playing" titles follow the page-wide minute clock; deferring it (like epgData) keeps + // the recompute and the row updates in a non-urgent, interruptible render. + const nowMs = useDeferredValue(useWallClockMinute()); // Defer epgData so initial load / large EPG updates don't block interactions const deferredEpgData = useDeferredValue(epgData); @@ -130,6 +115,7 @@ function ChannelListComponent({ const currentProgramMap = useMemo(() => { const map: Record = {}; if (!channels || !deferredEpgData) return map; + const now = new Date(nowMs); for (const ch of channels) { const epgId = getEPGChannelId(ch, deferredEpgData); if (!epgId) continue; @@ -139,7 +125,7 @@ function ChannelListComponent({ } } return map; - }, [channels, deferredEpgData, now]); + }, [channels, deferredEpgData, nowMs]); const filteredChannels = useMemo( () => filterChannels(channels, deferredSearchQuery, deferredSelectedGroup), diff --git a/web-ui/src/components/player/epg-view.tsx b/web-ui/src/components/player/epg-view.tsx index 5d03de21..4bfe3802 100644 --- a/web-ui/src/components/player/epg-view.tsx +++ b/web-ui/src/components/player/epg-view.tsx @@ -1,17 +1,8 @@ import { clsx } from "clsx"; import { Circle, History } from "lucide-react"; -import { - memo, - type RefObject, - useCallback, - useDeferredValue, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, type RefObject, useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef } from "react"; import { usePlayerTranslation } from "../../hooks/use-player-translation"; +import { useWallClockMinute } from "../../hooks/use-wall-clock-minute"; import type { EPGData } from "../../lib/epg-parser"; import type { Locale } from "../../lib/locale"; import type { EPGProgram } from "../../types/player"; @@ -35,30 +26,50 @@ interface EPGViewProps { export const nextScrollBehaviorRef: RefObject<"smooth" | "instant" | "skip"> = { current: "instant" }; +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[]; +} + 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 (