feat(tv): add channels tab with direct channel playback and live tv controls

This commit is contained in:
Fredrik Burmester
2026-01-26 19:42:10 +01:00
parent c5eb7b0c96
commit a0dd752d8f
5 changed files with 450 additions and 27 deletions

View File

@@ -0,0 +1,183 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface TVChannelCardProps {
channel: BaseItemDto;
api: Api | null;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
const CARD_WIDTH = 200;
const CARD_HEIGHT = 160;
export const TVChannelCard: React.FC<TVChannelCardProps> = ({
channel,
api,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const imageUrl = getPrimaryImageUrl({
api,
item: channel,
quality: 80,
width: 200,
});
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
style={styles.pressable}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
animatedStyle,
focused && styles.focusedShadow,
]}
>
{/* Channel logo or number */}
<View style={styles.logoContainer}>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={styles.logo}
resizeMode='contain'
/>
) : (
<View
style={[
styles.numberFallback,
{
backgroundColor: focused
? "#E5E5E5"
: "rgba(255, 255, 255, 0.15)",
},
]}
>
<Text
style={[
styles.numberText,
{
fontSize: typography.title,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.ChannelNumber || "?"}
</Text>
</View>
)}
</View>
{/* Channel name */}
<Text
numberOfLines={2}
style={[
styles.channelName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.Name}
</Text>
{/* Channel number (if name is shown) */}
{channel.ChannelNumber && (
<Text
numberOfLines={1}
style={[
styles.channelNumber,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
Ch. {channel.ChannelNumber}
</Text>
)}
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
},
container: {
flex: 1,
borderRadius: 12,
borderWidth: 1,
padding: 12,
alignItems: "center",
justifyContent: "center",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
logoContainer: {
width: 80,
height: 60,
marginBottom: 8,
justifyContent: "center",
alignItems: "center",
},
logo: {
width: "100%",
height: "100%",
},
numberFallback: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
},
numberText: {
fontWeight: "bold",
},
channelName: {
fontWeight: "600",
textAlign: "center",
marginBottom: 4,
},
channelNumber: {
fontWeight: "400",
},
});
export { CARD_WIDTH, CARD_HEIGHT };

View File

@@ -0,0 +1,136 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { TVChannelCard } from "./TVChannelCard";
const HORIZONTAL_PADDING = 60;
const GRID_GAP = 16;
export const TVChannelsGrid: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Fetch all channels
const { data: channelsData, isLoading } = useQuery({
queryKey: ["livetv", "channels-grid", "all"],
queryFn: async () => {
if (!api || !user?.Id) return null;
const res = await getLiveTvApi(api).getLiveTvChannels({
enableFavoriteSorting: true,
userId: user.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
enabled: !!api && !!user?.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const channels = channelsData?.Items ?? [];
const handleChannelPress = useCallback(
(channelId: string | undefined) => {
if (channelId) {
// Navigate directly to the player to start the channel
const queryParams = new URLSearchParams({
itemId: channelId,
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
}
},
[router],
);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (channels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_channels")}
</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
{
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.grid}>
{channels.map((channel, index) => (
<TVChannelCard
key={channel.Id ?? index}
channel={channel}
api={api}
onPress={() => handleChannelPress(channel.Id)}
// No hasTVPreferredFocus - tab buttons handle initial focus
/>
))}
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingTop: 24,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: GRID_GAP,
overflow: "visible",
paddingVertical: 10, // Extra padding for focus scale animation
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -6,6 +6,8 @@ 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 { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
import { TVTabButton } from "@/components/tv/TVTabButton";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -200,6 +202,14 @@ export const TVLiveTVPage: React.FC = () => {
return renderProgramsContent();
}
if (activeTab === "guide") {
return <TVLiveTVGuide />;
}
if (activeTab === "channels") {
return <TVChannelsGrid />;
}
// Placeholder for other tabs
const tab = TABS.find((t) => t.id === activeTab);
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
@@ -234,13 +244,13 @@ export const TVLiveTVPage: React.FC = () => {
gap: 8,
}}
>
{TABS.map((tab, index) => (
{TABS.map((tab) => (
<TVTabButton
key={tab.id}
label={t(tab.labelKey)}
active={activeTab === tab.id}
onSelect={() => handleTabSelect(tab.id)}
hasTVPreferredFocus={index === 0}
hasTVPreferredFocus={activeTab === tab.id}
switchOnFocus={true}
/>
))}

View File

@@ -375,6 +375,12 @@ export const Controls: FC<Props> = ({
isSeeking,
});
// Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels)
const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel";
// For live TV, determine if we're at the live edge (within 5 seconds of max)
const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms
const getFinishTime = () => {
const now = new Date();
const finishTime = new Date(now.getTime() + remainingTime);
@@ -540,6 +546,13 @@ export const Controls: FC<Props> = ({
);
const handleSeekForwardButton = useCallback(() => {
// For live TV, check if we're already at the live edge
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
// Already at live edge, don't seek further
controlsInteractionRef.current();
return;
}
const newPosition = Math.min(max.value, progress.value + 30 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -556,7 +569,14 @@ export const Controls: FC<Props> = ({
}, 2000);
controlsInteractionRef.current();
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
}, [
progress,
max,
seek,
calculateTrickplayUrl,
updateSeekBubbleTime,
isLiveTV,
]);
const handleSeekBackwardButton = useCallback(() => {
const newPosition = Math.max(min.value, progress.value - 30 * 1000);
@@ -579,6 +599,13 @@ export const Controls: FC<Props> = ({
// Progress bar D-pad seeking (10s increments for finer control)
const handleProgressSeekRight = useCallback(() => {
// For live TV, check if we're already at the live edge
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
// Already at live edge, don't seek further
controlsInteractionRef.current();
return;
}
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -595,7 +622,14 @@ export const Controls: FC<Props> = ({
}, 2000);
controlsInteractionRef.current();
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
}, [
progress,
max,
seek,
calculateTrickplayUrl,
updateSeekBubbleTime,
isLiveTV,
]);
const handleProgressSeekLeft = useCallback(() => {
const newPosition = Math.max(min.value, progress.value - 10 * 1000);
@@ -618,6 +652,12 @@ export const Controls: FC<Props> = ({
// Minimal seek mode handlers (only show progress bar, not full controls)
const handleMinimalSeekRight = useCallback(() => {
// For live TV, check if we're already at the live edge
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
// Already at live edge, don't seek further
return;
}
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
progress.value = newPosition;
seek(newPosition);
@@ -642,6 +682,7 @@ export const Controls: FC<Props> = ({
calculateTrickplayUrl,
updateSeekBubbleTime,
showMinimalSeek,
isLiveTV,
]);
const handleMinimalSeekLeft = useCallback(() => {
@@ -691,11 +732,23 @@ export const Controls: FC<Props> = ({
}, [startMinimalSeekHideTimeout]);
const startContinuousSeekForward = useCallback(() => {
// For live TV, check if we're already at the live edge
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
// Already at live edge, don't start continuous seeking
return;
}
seekAccelerationRef.current = 1;
handleSeekForwardButton();
continuousSeekRef.current = setInterval(() => {
// For live TV, stop continuous seeking when we hit the live edge
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
stopContinuousSeeking();
return;
}
const seekAmount =
CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK *
seekAccelerationRef.current *
@@ -718,6 +771,8 @@ export const Controls: FC<Props> = ({
seek,
calculateTrickplayUrl,
updateSeekBubbleTime,
isLiveTV,
stopContinuousSeeking,
]);
const startContinuousSeekBackward = useCallback(() => {
@@ -977,16 +1032,18 @@ export const Controls: FC<Props> = ({
<Text style={[styles.timeText, { fontSize: typography.body }]}>
{formatTimeString(currentTime, "ms")}
</Text>
<View style={styles.timeRight}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
{!isLiveTV && (
<View style={styles.timeRight}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
@@ -1012,9 +1069,25 @@ export const Controls: FC<Props> = ({
style={[styles.subtitleText, { fontSize: typography.body }]}
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
)}
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
{item?.Name}
</Text>
<View style={styles.titleRow}>
<Text
style={[styles.titleText, { fontSize: typography.heading }]}
>
{item?.Name}
</Text>
{isLiveTV && (
<View style={styles.liveBadge}>
<Text
style={[
styles.liveBadgeText,
{ fontSize: typography.callout },
]}
>
{t("player.live")}
</Text>
</View>
)}
</View>
{item?.Type === "Movie" && (
<Text
style={[styles.subtitleText, { fontSize: typography.body }]}
@@ -1122,16 +1195,18 @@ export const Controls: FC<Props> = ({
<Text style={[styles.timeText, { fontSize: typography.body }]}>
{formatTimeString(currentTime, "ms")}
</Text>
<View style={styles.timeRight}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
{!isLiveTV && (
<View style={styles.timeRight}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
@@ -1160,6 +1235,11 @@ const styles = StyleSheet.create({
metadataContainer: {
marginBottom: 16,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
subtitleText: {
color: "rgba(255,255,255,0.6)",
},
@@ -1167,6 +1247,16 @@ const styles = StyleSheet.create({
color: "#fff",
fontWeight: "bold",
},
liveBadge: {
backgroundColor: "#EF4444",
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 6,
},
liveBadgeText: {
color: "#FFF",
fontWeight: "bold",
},
controlButtonsRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -624,6 +624,7 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -718,6 +719,9 @@
"sports": "Sports",
"for_kids": "For Kids",
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",