From 246e0af0f6136b9b34413551ad036cbfc80ad577 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:53:13 +0100 Subject: [PATCH] feat(tv): improve live tv guide scrolling and time range --- components/livetv/TVGuideChannelRow.tsx | 146 ++++++++ components/livetv/TVGuideTimeHeader.tsx | 64 ++++ components/livetv/TVLiveTVGuide.tsx | 433 ++++++++++++++++++++++++ 3 files changed, 643 insertions(+) create mode 100644 components/livetv/TVGuideChannelRow.tsx create mode 100644 components/livetv/TVGuideTimeHeader.tsx create mode 100644 components/livetv/TVLiveTVGuide.tsx diff --git a/components/livetv/TVGuideChannelRow.tsx b/components/livetv/TVGuideChannelRow.tsx new file mode 100644 index 00000000..73ba7491 --- /dev/null +++ b/components/livetv/TVGuideChannelRow.tsx @@ -0,0 +1,146 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React, { useMemo } from "react"; +import { StyleSheet, View } from "react-native"; +import { TVGuideProgramCell } from "./TVGuideProgramCell"; + +interface TVGuideChannelRowProps { + programs: BaseItemDto[]; + baseTime: Date; + pixelsPerHour: number; + minProgramWidth: number; + hoursToShow: number; + onProgramPress: (program: BaseItemDto) => void; + disabled?: boolean; + firstProgramRefSetter?: (ref: View | null) => void; +} + +export const TVGuideChannelRow: React.FC = ({ + programs, + baseTime, + pixelsPerHour, + minProgramWidth, + hoursToShow, + onProgramPress, + disabled = false, + firstProgramRefSetter, +}) => { + const isCurrentlyAiring = (program: BaseItemDto): boolean => { + if (!program.StartDate || !program.EndDate) return false; + const now = new Date(); + const start = new Date(program.StartDate); + const end = new Date(program.EndDate); + return now >= start && now <= end; + }; + + const getTimeOffset = (startDate: string): number => { + const start = new Date(startDate); + const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000; + return Math.max(0, (diffMinutes / 60) * pixelsPerHour); + }; + + // Filter programs for this channel and within the time window + const filteredPrograms = useMemo(() => { + const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000); + + return programs + .filter((p) => { + if (!p.StartDate || !p.EndDate) return false; + const start = new Date(p.StartDate); + const end = new Date(p.EndDate); + // Program overlaps with our time window + return end > baseTime && start < endTime; + }) + .sort((a, b) => { + const dateA = new Date(a.StartDate || 0); + const dateB = new Date(b.StartDate || 0); + return dateA.getTime() - dateB.getTime(); + }); + }, [programs, baseTime, hoursToShow]); + + // Calculate program cells with positions (absolute positioning) + const programCells = useMemo(() => { + return filteredPrograms.map((program) => { + if (!program.StartDate || !program.EndDate) { + return { program, width: minProgramWidth, left: 0 }; + } + + // Clamp the start time to baseTime if program started earlier + const programStart = new Date(program.StartDate); + const effectiveStart = programStart < baseTime ? baseTime : programStart; + + // Clamp the end time to the window end + const windowEnd = new Date( + baseTime.getTime() + hoursToShow * 60 * 60 * 1000, + ); + const programEnd = new Date(program.EndDate); + const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd; + + const durationMinutes = + (effectiveEnd.getTime() - effectiveStart.getTime()) / 60000; + const width = Math.max( + (durationMinutes / 60) * pixelsPerHour - 4, + minProgramWidth, + ); // -4 for gap + + const left = getTimeOffset(effectiveStart.toISOString()); + + return { + program, + width, + left, + }; + }); + }, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]); + + const totalWidth = hoursToShow * pixelsPerHour; + + return ( + + {programCells.map(({ program, width, left }, index) => ( + + onProgramPress(program)} + disabled={disabled} + refSetter={index === 0 ? firstProgramRefSetter : undefined} + /> + + ))} + + {/* Empty state */} + {programCells.length === 0 && ( + + {/* Empty row indicator */} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 80, + position: "relative", + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + backgroundColor: "rgba(20, 20, 20, 1)", + }, + programCellWrapper: { + position: "absolute", + top: 4, + bottom: 4, + }, + noPrograms: { + position: "absolute", + left: 4, + top: 4, + bottom: 4, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderRadius: 8, + }, +}); diff --git a/components/livetv/TVGuideTimeHeader.tsx b/components/livetv/TVGuideTimeHeader.tsx new file mode 100644 index 00000000..a3ca8348 --- /dev/null +++ b/components/livetv/TVGuideTimeHeader.tsx @@ -0,0 +1,64 @@ +import { BlurView } from "expo-blur"; +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVGuideTimeHeaderProps { + baseTime: Date; + hoursToShow: number; + pixelsPerHour: number; +} + +export const TVGuideTimeHeader: React.FC = ({ + baseTime, + hoursToShow, + pixelsPerHour, +}) => { + const typography = useScaledTVTypography(); + + const hours: Date[] = []; + for (let i = 0; i < hoursToShow; i++) { + const hour = new Date(baseTime); + hour.setMinutes(0, 0, 0); + hour.setHours(baseTime.getHours() + i); + hours.push(hour); + } + + const formatHour = (date: Date) => { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + {hours.map((hour, index) => ( + + + {formatHour(hour)} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + height: 44, + }, + hourCell: { + justifyContent: "center", + paddingLeft: 12, + borderLeftWidth: 1, + borderLeftColor: "rgba(255, 255, 255, 0.1)", + }, + hourText: { + color: "rgba(255, 255, 255, 0.6)", + fontWeight: "500", + }, +}); diff --git a/components/livetv/TVLiveTVGuide.tsx b/components/livetv/TVLiveTVGuide.tsx new file mode 100644 index 00000000..7c1f12f6 --- /dev/null +++ b/components/livetv/TVLiveTVGuide.tsx @@ -0,0 +1,433 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { TVGuideChannelRow } from "./TVGuideChannelRow"; +import { TVGuidePageNavigation } from "./TVGuidePageNavigation"; +import { TVGuideTimeHeader } from "./TVGuideTimeHeader"; + +// Design constants +const CHANNEL_COLUMN_WIDTH = 240; +const PIXELS_PER_HOUR = 250; +const ROW_HEIGHT = 80; +const TIME_HEADER_HEIGHT = 44; +const CHANNELS_PER_PAGE = 20; +const MIN_PROGRAM_WIDTH = 80; +const HORIZONTAL_PADDING = 60; + +// Channel label component +const ChannelLabel: React.FC<{ + channel: BaseItemDto; + typography: ReturnType; +}> = ({ channel, typography }) => ( + + + {channel.ChannelNumber} + + + {channel.Name} + + +); + +export const TVLiveTVGuide: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [currentPage, setCurrentPage] = useState(1); + + // Scroll refs for synchronization + const channelListRef = useRef(null); + const mainVerticalRef = useRef(null); + + // Focus guide refs for bidirectional navigation + const [firstProgramRef, setFirstProgramRef] = useState(null); + const [prevButtonRef, setPrevButtonRef] = useState(null); + + // Base time - start of current hour, end time - end of day + const [{ baseTime, endOfDay, hoursToShow }] = useState(() => { + const now = new Date(); + now.setMinutes(0, 0, 0); + + const endOfDayTime = new Date(now); + endOfDayTime.setHours(23, 59, 59, 999); + + const hoursUntilEndOfDay = Math.ceil( + (endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000), + ); + + return { + baseTime: now, + endOfDay: endOfDayTime, + hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour + }; + }); + + // Current time indicator position (relative to program grid start) + const [currentTimeOffset, setCurrentTimeOffset] = useState(0); + + // Update current time indicator every minute + useEffect(() => { + const updateCurrentTime = () => { + const now = new Date(); + const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000; + const offset = (diffMinutes / 60) * PIXELS_PER_HOUR; + setCurrentTimeOffset(offset); + }; + + updateCurrentTime(); + const interval = setInterval(updateCurrentTime, 60000); + return () => clearInterval(interval); + }, [baseTime]); + + // Sync vertical scroll between channel list and main grid + const handleVerticalScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetY = event.nativeEvent.contentOffset.y; + channelListRef.current?.scrollTo({ y: offsetY, animated: false }); + }, + [], + ); + + // Fetch channels + const { data: channelsData, isLoading: isLoadingChannels } = useQuery({ + queryKey: ["livetv", "tv-guide", "channels"], + queryFn: async () => { + if (!api || !user?.Id) return null; + const res = await getLiveTvApi(api).getLiveTvChannels({ + enableFavoriteSorting: true, + userId: user.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + enabled: !!api && !!user?.Id, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const totalChannels = channelsData?.TotalRecordCount ?? 0; + const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE); + const allChannels = channelsData?.Items ?? []; + + // Get channels for current page + const paginatedChannels = useMemo(() => { + const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE; + return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE); + }, [allChannels, currentPage]); + + const channelIds = useMemo( + () => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[], + [paginatedChannels], + ); + + // Fetch programs for visible channels + const { data: programsData, isLoading: isLoadingPrograms } = useQuery({ + queryKey: [ + "livetv", + "tv-guide", + "programs", + channelIds, + baseTime.toISOString(), + endOfDay.toISOString(), + ], + queryFn: async () => { + if (!api || channelIds.length === 0) return null; + const res = await getLiveTvApi(api).getPrograms({ + getProgramsDto: { + MaxStartDate: endOfDay.toISOString(), + MinEndDate: baseTime.toISOString(), + ChannelIds: channelIds, + ImageTypeLimit: 1, + EnableImages: false, + SortBy: ["StartDate"], + EnableTotalRecordCount: false, + EnableUserData: false, + }, + }); + return res.data; + }, + enabled: channelIds.length > 0, + staleTime: 2 * 60 * 1000, // 2 minutes + }); + + const programs = programsData?.Items ?? []; + + // Group programs by channel + const programsByChannel = useMemo(() => { + const grouped: Record = {}; + for (const program of programs) { + const channelId = program.ChannelId; + if (channelId) { + if (!grouped[channelId]) { + grouped[channelId] = []; + } + grouped[channelId].push(program); + } + } + return grouped; + }, [programs]); + + const handleProgramPress = useCallback( + (program: BaseItemDto) => { + // Navigate to play the program/channel + const queryParams = new URLSearchParams({ + itemId: program.Id ?? "", + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + }, + [router], + ); + + const handlePreviousPage = useCallback(() => { + if (currentPage > 1) { + setCurrentPage((p) => p - 1); + } + }, [currentPage]); + + const handleNextPage = useCallback(() => { + if (currentPage < totalPages) { + setCurrentPage((p) => p + 1); + } + }, [currentPage, totalPages]); + + const isLoading = isLoadingChannels; + const totalWidth = hoursToShow * PIXELS_PER_HOUR; + + if (isLoading) { + return ( + + + + ); + } + + if (paginatedChannels.length === 0) { + return ( + + + {t("live_tv.no_programs")} + + + ); + } + + return ( + + {/* Page Navigation */} + {totalPages > 1 && ( + + + + )} + + {/* Bidirectional focus guides */} + {firstProgramRef && ( + + )} + {prevButtonRef && ( + + )} + + {/* Main grid container */} + + {/* Fixed channel column */} + + {/* Spacer for time header */} + + + {/* Channel labels - synced with main scroll */} + + {paginatedChannels.map((channel, index) => ( + + ))} + + + + {/* Scrollable programs area */} + + + {/* Time header */} + + + {/* Programs grid - vertical scroll */} + + {paginatedChannels.map((channel, index) => { + const channelPrograms = channel.Id + ? (programsByChannel[channel.Id] ?? []) + : []; + return ( + + ); + })} + + + {/* Current time indicator */} + {currentTimeOffset > 0 && currentTimeOffset < totalWidth && ( + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, + gridWrapper: { + flex: 1, + flexDirection: "row", + }, + channelColumn: { + backgroundColor: "rgba(40, 40, 40, 1)", + borderRightWidth: 1, + borderRightColor: "rgba(255, 255, 255, 0.2)", + }, + channelLabel: { + height: ROW_HEIGHT, + justifyContent: "center", + paddingHorizontal: 12, + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.2)", + }, + channelNumber: { + color: "rgba(255, 255, 255, 0.5)", + fontWeight: "400", + marginBottom: 2, + }, + channelName: { + color: "#FFFFFF", + fontWeight: "600", + }, + horizontalScroll: { + flex: 1, + }, + currentTimeIndicator: { + position: "absolute", + width: 2, + backgroundColor: "#EF4444", + zIndex: 10, + pointerEvents: "none", + }, +});