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",