mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-30 12:26:29 +01:00
feat(tv): live tv initial commit
This commit is contained in:
@@ -41,3 +41,5 @@ 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)_
|
- **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)_
|
- **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)_
|
||||||
@@ -142,9 +142,9 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
### TV Component Rendering Pattern
|
### 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
|
```typescript
|
||||||
// In page file (e.g., app/login.tsx)
|
// In page file (e.g., app/login.tsx)
|
||||||
import { Platform } from "react-native";
|
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`)
|
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
|
||||||
- Use `Platform.isTV` to conditionally render the appropriate component
|
- Use `Platform.isTV` to conditionally render the appropriate component
|
||||||
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
|
- 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)
|
### TV Option Selector Pattern (Dropdowns/Multi-select)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} 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();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
const Layout = () => {
|
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 (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<Slot />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return <TVLiveTVPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileLiveTVPrograms />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileLiveTVPrograms() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -47,53 +47,71 @@ interface Props extends ViewProps {
|
|||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
// TV-specific ItemCardText with larger fonts
|
// TV-specific ItemCardText with appropriately sized fonts
|
||||||
const TVItemCardText: React.FC<{
|
const TVItemCardText: React.FC<{
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
typography: Typography;
|
typography: Typography;
|
||||||
}> = ({ item, typography }) => {
|
width?: number;
|
||||||
|
}> = ({ item, typography, width }) => {
|
||||||
|
const renderSubtitle = () => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
// For Live TV programs, show channel name
|
||||||
|
const channelName = item.ChannelName;
|
||||||
|
return channelName ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channelName}
|
||||||
|
</Text>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: show production year
|
||||||
|
return item.ProductionYear ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column", width }}>
|
||||||
{item.Type === "Episode" ? (
|
<Text
|
||||||
<>
|
numberOfLines={1}
|
||||||
<Text
|
style={{ fontSize: typography.body, color: "#FFFFFF" }}
|
||||||
numberOfLines={1}
|
>
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
{item.Name}
|
||||||
>
|
</Text>
|
||||||
{item.Name}
|
{renderSubtitle()}
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{
|
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
|
||||||
{" - "}
|
|
||||||
{item.SeriesName}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -287,15 +305,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
|
|
||||||
const getItemLayout = useCallback(
|
|
||||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
|
||||||
length: itemWidth + ITEM_GAP,
|
|
||||||
offset: (itemWidth + ITEM_GAP) * index,
|
|
||||||
index,
|
|
||||||
}),
|
|
||||||
[itemWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
const isFirstItem = isFirstSection && index === 0;
|
const isFirstItem = isFirstSection && index === 0;
|
||||||
@@ -359,7 +368,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{renderPoster()}
|
{renderPoster()}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} typography={typography} />
|
<TVItemCardText
|
||||||
|
item={item}
|
||||||
|
typography={typography}
|
||||||
|
width={itemWidth}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -462,7 +475,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
maxToRenderPerBatch={3}
|
maxToRenderPerBatch={3}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
|||||||
@@ -309,6 +309,10 @@ export const TVLibraries: React.FC = () => {
|
|||||||
|
|
||||||
const handleLibraryPress = useCallback(
|
const handleLibraryPress = useCallback(
|
||||||
(library: BaseItemDto) => {
|
(library: BaseItemDto) => {
|
||||||
|
if (library.CollectionType === "livetv") {
|
||||||
|
router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (library.CollectionType === "music") {
|
if (library.CollectionType === "music") {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
||||||
|
|||||||
254
components/livetv/TVLiveTVPage.tsx
Normal file
254
components/livetv/TVLiveTVPage.tsx
Normal file
@@ -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<TabId>("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 = () => (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
key={section.queryKey.join("-")}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation='horizontal'
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={10}
|
||||||
|
enabled={true}
|
||||||
|
isFirstSection={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
if (activeTab === "programs") {
|
||||||
|
return renderProgramsContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for other tabs
|
||||||
|
const tab = TABS.find((t) => t.id === activeTab);
|
||||||
|
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Header with Title and Tabs */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 60,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Live TV
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABS.map((tab, index) => (
|
||||||
|
<TVTabButton
|
||||||
|
key={tab.id}
|
||||||
|
label={t(tab.labelKey)}
|
||||||
|
active={activeTab === tab.id}
|
||||||
|
onSelect={() => handleTabSelect(tab.id)}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
switchOnFocus={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<View style={{ flex: 1 }}>{renderTabContent()}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
@@ -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<TVLiveTVPlaceholderProps> = ({
|
||||||
|
tabName,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("live_tv.coming_soon")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -717,7 +717,15 @@
|
|||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
"for_kids": "For Kids",
|
"for_kids": "For Kids",
|
||||||
"news": "News"
|
"news": "News",
|
||||||
|
"tabs": {
|
||||||
|
"programs": "Programs",
|
||||||
|
"guide": "Guide",
|
||||||
|
"channels": "Channels",
|
||||||
|
"recordings": "Recordings",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"series": "Series"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
Reference in New Issue
Block a user