From c5eb7b0c96d6beffcc2f8bf5de57312a52ca04cb Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Jan 2026 19:41:03 +0100 Subject: [PATCH] feat(tv): live tv initial commit --- .claude/learned-facts.md | 4 +- CLAUDE.md | 5 +- .../livetv/_layout.tsx | 14 +- .../livetv/programs.tsx | 11 +- .../InfiniteScrollingCollectionList.tv.tsx | 120 +++++---- components/library/TVLibraries.tsx | 4 + components/livetv/TVLiveTVPage.tsx | 254 ++++++++++++++++++ components/livetv/TVLiveTVPlaceholder.tsx | 46 ++++ translations/en.json | 10 +- 9 files changed, 408 insertions(+), 60 deletions(-) create mode 100644 components/livetv/TVLiveTVPage.tsx create mode 100644 components/livetv/TVLiveTVPlaceholder.tsx diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 964bd9e5..e11daa07 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -40,4 +40,6 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_ -- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ \ No newline at end of file +- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ + +- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 10e4f559..9fd32e75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,9 +142,9 @@ import { apiAtom } from "@/providers/JellyfinProvider"; ### TV Component Rendering Pattern -**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports. +**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead. -**Pattern for TV-specific components**: +**Pattern for TV-specific pages and components**: ```typescript // In page file (e.g., app/login.tsx) import { Platform } from "react-native"; @@ -164,6 +164,7 @@ export default LoginPage; - Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`) - Use `Platform.isTV` to conditionally render the appropriate component - TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling +- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly ### TV Option Selector Pattern (Dropdowns/Multi-select) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx index 072b2f93..28cc2f9d 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/_layout.tsx @@ -7,7 +7,8 @@ import type { ParamListBase, TabNavigationState, } from "@react-navigation/native"; -import { Stack, withLayoutContext } from "expo-router"; +import { Slot, Stack, withLayoutContext } from "expo-router"; +import { Platform } from "react-native"; const { Navigator } = createMaterialTopTabNavigator(); @@ -19,6 +20,17 @@ export const Tab = withLayoutContext< >(Navigator); const Layout = () => { + // On TV, skip the Material Top Tab Navigator and render children directly + // The TV version handles its own tab navigation internally + if (Platform.isTV) { + return ( + <> + + + + ); + } + return ( <> diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx index 812d084d..f1471e3a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/livetv/programs.tsx @@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; export default function page() { + if (Platform.isTV) { + return ; + } + + return ; +} + +function MobileLiveTVPrograms() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 548dfed5..53b1e2ed 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -47,53 +47,71 @@ interface Props extends ViewProps { type Typography = ReturnType; -// TV-specific ItemCardText with larger fonts +// TV-specific ItemCardText with appropriately sized fonts const TVItemCardText: React.FC<{ item: BaseItemDto; typography: Typography; -}> = ({ item, typography }) => { + width?: number; +}> = ({ item, typography, width }) => { + const renderSubtitle = () => { + if (item.Type === "Episode") { + return ( + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + {" - "} + {item.SeriesName} + + ); + } + + if (item.Type === "Program") { + // For Live TV programs, show channel name + const channelName = item.ChannelName; + return channelName ? ( + + {channelName} + + ) : null; + } + + // Default: show production year + return item.ProductionYear ? ( + + {item.ProductionYear} + + ) : null; + }; + return ( - - {item.Type === "Episode" ? ( - <> - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - - ) : ( - <> - - {item.Name} - - - {item.ProductionYear} - - - )} + + + {item.Name} + + {renderSubtitle()} ); }; @@ -287,15 +305,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ } as any); }, [router, parentId]); - const getItemLayout = useCallback( - (_data: ArrayLike | null | undefined, index: number) => ({ - length: itemWidth + ITEM_GAP, - offset: (itemWidth + ITEM_GAP) * index, - index, - }), - [itemWidth], - ); - const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; @@ -359,7 +368,11 @@ export const InfiniteScrollingCollectionList: React.FC = ({ > {renderPoster()} - + ); }, @@ -462,7 +475,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ maxToRenderPerBatch={3} windowSize={5} removeClippedSubviews={false} - getItemLayout={getItemLayout} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} contentContainerStyle={{ diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index bc12201b..4c985dc0 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -309,6 +309,10 @@ export const TVLibraries: React.FC = () => { const handleLibraryPress = useCallback( (library: BaseItemDto) => { + if (library.CollectionType === "livetv") { + router.push("/(auth)/(tabs)/(libraries)/livetv/programs"); + return; + } if (library.CollectionType === "music") { router.push({ pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`, diff --git a/components/livetv/TVLiveTVPage.tsx b/components/livetv/TVLiveTVPage.tsx new file mode 100644 index 00000000..b88f32df --- /dev/null +++ b/components/livetv/TVLiveTVPage.tsx @@ -0,0 +1,254 @@ +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +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 { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder"; +import { TVTabButton } from "@/components/tv/TVTabButton"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +const HORIZONTAL_PADDING = 60; +const SECTION_GAP = 24; + +type TabId = + | "programs" + | "guide" + | "channels" + | "recordings" + | "schedule" + | "series"; + +interface Tab { + id: TabId; + labelKey: string; +} + +const TABS: Tab[] = [ + { id: "programs", labelKey: "live_tv.tabs.programs" }, + { id: "guide", labelKey: "live_tv.tabs.guide" }, + { id: "channels", labelKey: "live_tv.tabs.channels" }, + { id: "recordings", labelKey: "live_tv.tabs.recordings" }, + { id: "schedule", labelKey: "live_tv.tabs.schedule" }, + { id: "series", labelKey: "live_tv.tabs.series" }, +]; + +export const TVLiveTVPage: React.FC = () => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const insets = useSafeAreaInsets(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [activeTab, setActiveTab] = useState("programs"); + + // Section configurations for Programs tab + const sections = useMemo(() => { + if (!api || !user?.Id) return []; + + return [ + { + title: t("live_tv.on_now"), + queryKey: ["livetv", "tv", "onNow"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getRecommendedPrograms({ + userId: user.Id, + isAiring: true, + limit: 24, + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.shows"), + queryKey: ["livetv", "tv", "shows"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: false, + isSeries: true, + isSports: false, + isNews: false, + isKids: false, + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.movies"), + queryKey: ["livetv", "tv", "movies"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isMovie: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.sports"), + queryKey: ["livetv", "tv", "sports"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isSports: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.for_kids"), + queryKey: ["livetv", "tv", "kids"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isKids: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + { + title: t("live_tv.news"), + queryKey: ["livetv", "tv", "news"], + queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => { + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user.Id, + hasAired: false, + limit: 24, + isNews: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + const items = res.data.Items || []; + return items.slice(pageParam, pageParam + 10); + }, + }, + ]; + }, [api, user?.Id, t]); + + const handleTabSelect = useCallback((tabId: TabId) => { + setActiveTab(tabId); + }, []); + + const renderProgramsContent = () => ( + + + {sections.map((section) => ( + + ))} + + + ); + + const renderTabContent = () => { + if (activeTab === "programs") { + return renderProgramsContent(); + } + + // Placeholder for other tabs + const tab = TABS.find((t) => t.id === activeTab); + return ; + }; + + return ( + + {/* Header with Title and Tabs */} + + {/* Title */} + + Live TV + + + {/* Tab Bar */} + + {TABS.map((tab, index) => ( + handleTabSelect(tab.id)} + hasTVPreferredFocus={index === 0} + switchOnFocus={true} + /> + ))} + + + + {/* Tab Content */} + {renderTabContent()} + + ); +}; diff --git a/components/livetv/TVLiveTVPlaceholder.tsx b/components/livetv/TVLiveTVPlaceholder.tsx new file mode 100644 index 00000000..2880cbed --- /dev/null +++ b/components/livetv/TVLiveTVPlaceholder.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVLiveTVPlaceholderProps { + tabName: string; +} + +export const TVLiveTVPlaceholder: React.FC = ({ + tabName, +}) => { + const { t } = useTranslation(); + const typography = useScaledTVTypography(); + + return ( + + + {tabName} + + + {t("live_tv.coming_soon")} + + + ); +}; diff --git a/translations/en.json b/translations/en.json index 05663b32..3f88d031 100644 --- a/translations/en.json +++ b/translations/en.json @@ -717,7 +717,15 @@ "movies": "Movies", "sports": "Sports", "for_kids": "For Kids", - "news": "News" + "news": "News", + "tabs": { + "programs": "Programs", + "guide": "Guide", + "channels": "Channels", + "recordings": "Recordings", + "schedule": "Schedule", + "series": "Series" + } }, "jellyseerr": { "confirm": "Confirm",