mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-29 06:28:24 +00:00
feat(tv): live tv initial commit
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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`,
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user