feat(tv): live tv initial commit

This commit is contained in:
Fredrik Burmester
2026-01-26 19:41:03 +01:00
parent 55c74ab383
commit c5eb7b0c96
9 changed files with 408 additions and 60 deletions

View File

@@ -47,53 +47,71 @@ interface Props extends ViewProps {
type Typography = ReturnType<typeof useScaledTVTypography>;
// 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 (
<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 (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</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 style={{ marginTop: 12, flexDirection: "column", width }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.body, color: "#FFFFFF" }}
>
{item.Name}
</Text>
{renderSubtitle()}
</View>
);
};
@@ -287,15 +305,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
} as any);
}, [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(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
@@ -359,7 +368,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
<TVItemCardText
item={item}
typography={typography}
width={itemWidth}
/>
</View>
);
},
@@ -462,7 +475,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentContainerStyle={{

View File

@@ -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`,

View 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>
);
};

View 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>
);
};