From a0dd752d8f9f7d1f4a90fa9f0c3fed1ab836df6a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:42:10 +0100 Subject: [PATCH] feat(tv): add channels tab with direct channel playback and live tv controls --- components/livetv/TVChannelCard.tsx | 183 ++++++++++++++++++ components/livetv/TVChannelsGrid.tsx | 136 +++++++++++++ components/livetv/TVLiveTVPage.tsx | 14 +- .../video-player/controls/Controls.tv.tsx | 140 +++++++++++--- translations/en.json | 4 + 5 files changed, 450 insertions(+), 27 deletions(-) create mode 100644 components/livetv/TVChannelCard.tsx create mode 100644 components/livetv/TVChannelsGrid.tsx diff --git a/components/livetv/TVChannelCard.tsx b/components/livetv/TVChannelCard.tsx new file mode 100644 index 00000000..7ac47b71 --- /dev/null +++ b/components/livetv/TVChannelCard.tsx @@ -0,0 +1,183 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Animated, Image, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +interface TVChannelCardProps { + channel: BaseItemDto; + api: Api | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; +} + +const CARD_WIDTH = 200; +const CARD_HEIGHT = 160; + +export const TVChannelCard: React.FC = ({ + channel, + api, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const typography = useScaledTVTypography(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.05, + duration: 120, + }); + + const imageUrl = getPrimaryImageUrl({ + api, + item: channel, + quality: 80, + width: 200, + }); + + return ( + + + {/* Channel logo or number */} + + {imageUrl ? ( + + ) : ( + + + {channel.ChannelNumber || "?"} + + + )} + + + {/* Channel name */} + + {channel.Name} + + + {/* Channel number (if name is shown) */} + {channel.ChannelNumber && ( + + Ch. {channel.ChannelNumber} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + }, + container: { + flex: 1, + borderRadius: 12, + borderWidth: 1, + padding: 12, + alignItems: "center", + justifyContent: "center", + }, + focusedShadow: { + shadowColor: "#FFFFFF", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 12, + }, + logoContainer: { + width: 80, + height: 60, + marginBottom: 8, + justifyContent: "center", + alignItems: "center", + }, + logo: { + width: "100%", + height: "100%", + }, + numberFallback: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + }, + numberText: { + fontWeight: "bold", + }, + channelName: { + fontWeight: "600", + textAlign: "center", + marginBottom: 4, + }, + channelNumber: { + fontWeight: "400", + }, +}); + +export { CARD_WIDTH, CARD_HEIGHT }; diff --git a/components/livetv/TVChannelsGrid.tsx b/components/livetv/TVChannelsGrid.tsx new file mode 100644 index 00000000..f93beb35 --- /dev/null +++ b/components/livetv/TVChannelsGrid.tsx @@ -0,0 +1,136 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, ScrollView, StyleSheet, 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 { TVChannelCard } from "./TVChannelCard"; + +const HORIZONTAL_PADDING = 60; +const GRID_GAP = 16; + +export const TVChannelsGrid: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + // Fetch all channels + const { data: channelsData, isLoading } = useQuery({ + queryKey: ["livetv", "channels-grid", "all"], + 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 channels = channelsData?.Items ?? []; + + const handleChannelPress = useCallback( + (channelId: string | undefined) => { + if (channelId) { + // Navigate directly to the player to start the channel + const queryParams = new URLSearchParams({ + itemId: channelId, + audioIndex: "", + subtitleIndex: "", + mediaSourceId: "", + bitrateValue: "", + }); + router.push(`/player/direct-player?${queryParams.toString()}`); + } + }, + [router], + ); + + if (isLoading) { + return ( + + + + ); + } + + if (channels.length === 0) { + return ( + + + {t("live_tv.no_channels")} + + + ); + } + + return ( + + + {channels.map((channel, index) => ( + handleChannelPress(channel.Id)} + // No hasTVPreferredFocus - tab buttons handle initial focus + /> + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingTop: 24, + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-start", + gap: GRID_GAP, + overflow: "visible", + paddingVertical: 10, // Extra padding for focus scale animation + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + emptyText: { + color: "rgba(255, 255, 255, 0.6)", + }, +}); diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx index b88f32df..fffa7738 100644 --- a/components/livetv/TVLiveTVPage.tsx +++ b/components/livetv/TVLiveTVPage.tsx @@ -6,6 +6,8 @@ import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; +import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid"; +import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide"; import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder"; import { TVTabButton } from "@/components/tv/TVTabButton"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -200,6 +202,14 @@ export const TVLiveTVPage: React.FC = () => { return renderProgramsContent(); } + if (activeTab === "guide") { + return ; + } + + if (activeTab === "channels") { + return ; + } + // Placeholder for other tabs const tab = TABS.find((t) => t.id === activeTab); return ; @@ -234,13 +244,13 @@ export const TVLiveTVPage: React.FC = () => { gap: 8, }} > - {TABS.map((tab, index) => ( + {TABS.map((tab) => ( handleTabSelect(tab.id)} - hasTVPreferredFocus={index === 0} + hasTVPreferredFocus={activeTab === tab.id} switchOnFocus={true} /> ))} diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index dad6c082..18369ab4 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -375,6 +375,12 @@ export const Controls: FC = ({ isSeeking, }); + // Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels) + const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel"; + + // For live TV, determine if we're at the live edge (within 5 seconds of max) + const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms + const getFinishTime = () => { const now = new Date(); const finishTime = new Date(now.getTime() + remainingTime); @@ -540,6 +546,13 @@ export const Controls: FC = ({ ); const handleSeekForwardButton = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; seek(newPosition); @@ -556,7 +569,14 @@ export const Controls: FC = ({ }, 2000); controlsInteractionRef.current(); - }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); const handleSeekBackwardButton = useCallback(() => { const newPosition = Math.max(min.value, progress.value - 30 * 1000); @@ -579,6 +599,13 @@ export const Controls: FC = ({ // Progress bar D-pad seeking (10s increments for finer control) const handleProgressSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + controlsInteractionRef.current(); + return; + } + const newPosition = Math.min(max.value, progress.value + 10 * 1000); progress.value = newPosition; seek(newPosition); @@ -595,7 +622,14 @@ export const Controls: FC = ({ }, 2000); controlsInteractionRef.current(); - }, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]); + }, [ + progress, + max, + seek, + calculateTrickplayUrl, + updateSeekBubbleTime, + isLiveTV, + ]); const handleProgressSeekLeft = useCallback(() => { const newPosition = Math.max(min.value, progress.value - 10 * 1000); @@ -618,6 +652,12 @@ export const Controls: FC = ({ // Minimal seek mode handlers (only show progress bar, not full controls) const handleMinimalSeekRight = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't seek further + return; + } + const newPosition = Math.min(max.value, progress.value + 10 * 1000); progress.value = newPosition; seek(newPosition); @@ -642,6 +682,7 @@ export const Controls: FC = ({ calculateTrickplayUrl, updateSeekBubbleTime, showMinimalSeek, + isLiveTV, ]); const handleMinimalSeekLeft = useCallback(() => { @@ -691,11 +732,23 @@ export const Controls: FC = ({ }, [startMinimalSeekHideTimeout]); const startContinuousSeekForward = useCallback(() => { + // For live TV, check if we're already at the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + // Already at live edge, don't start continuous seeking + return; + } + seekAccelerationRef.current = 1; handleSeekForwardButton(); continuousSeekRef.current = setInterval(() => { + // For live TV, stop continuous seeking when we hit the live edge + if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) { + stopContinuousSeeking(); + return; + } + const seekAmount = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * seekAccelerationRef.current * @@ -718,6 +771,8 @@ export const Controls: FC = ({ seek, calculateTrickplayUrl, updateSeekBubbleTime, + isLiveTV, + stopContinuousSeeking, ]); const startContinuousSeekBackward = useCallback(() => { @@ -977,16 +1032,18 @@ export const Controls: FC = ({ {formatTimeString(currentTime, "ms")} - - - -{formatTimeString(remainingTime, "ms")} - - - {t("player.ends_at")} {getFinishTime()} - - + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} @@ -1012,9 +1069,25 @@ export const Controls: FC = ({ style={[styles.subtitleText, { fontSize: typography.body }]} >{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} - - {item?.Name} - + + + {item?.Name} + + {isLiveTV && ( + + + {t("player.live")} + + + )} + {item?.Type === "Movie" && ( = ({ {formatTimeString(currentTime, "ms")} - - - -{formatTimeString(remainingTime, "ms")} - - - {t("player.ends_at")} {getFinishTime()} - - + {!isLiveTV && ( + + + -{formatTimeString(remainingTime, "ms")} + + + {t("player.ends_at")} {getFinishTime()} + + + )} @@ -1160,6 +1235,11 @@ const styles = StyleSheet.create({ metadataContainer: { marginBottom: 16, }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, subtitleText: { color: "rgba(255,255,255,0.6)", }, @@ -1167,6 +1247,16 @@ const styles = StyleSheet.create({ color: "#fff", fontWeight: "bold", }, + liveBadge: { + backgroundColor: "#EF4444", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 6, + }, + liveBadgeText: { + color: "#FFF", + fontWeight: "bold", + }, controlButtonsRow: { flexDirection: "row", alignItems: "center", diff --git a/translations/en.json b/translations/en.json index 3f88d031..b3c6963f 100644 --- a/translations/en.json +++ b/translations/en.json @@ -624,6 +624,7 @@ "no_links": "No Links" }, "player": { + "live": "LIVE", "error": "Error", "failed_to_get_stream_url": "Failed to get the stream URL", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", @@ -718,6 +719,9 @@ "sports": "Sports", "for_kids": "For Kids", "news": "News", + "page_of": "Page {{current}} of {{total}}", + "no_programs": "No programs available", + "no_channels": "No channels available", "tabs": { "programs": "Programs", "guide": "Guide",