Merge branch 'develop' into sync-subtitle/audio-data

This commit is contained in:
Alex Kim
2026-05-31 14:36:11 +10:00
401 changed files with 43001 additions and 6855 deletions

View File

@@ -1,5 +1,7 @@
import { BlurView } from "expo-blur";
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -14,6 +16,8 @@ export const Badge: React.FC<Props> = ({
variant = "purple",
...props
}) => {
const typography = useScaledTVTypography();
const content = (
<View style={styles.content}>
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
@@ -28,7 +32,7 @@ export const Badge: React.FC<Props> = ({
</View>
);
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View {...props} style={[styles.container, props.style]}>
<GlassEffectView style={{ borderRadius: 100 }}>
@@ -38,21 +42,70 @@ export const Badge: React.FC<Props> = ({
);
}
// On TV, use BlurView for consistent styling
if (Platform.isTV) {
return (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
alignSelf: "flex-start",
flexShrink: 1,
flexGrow: 0,
}}
>
<View
style={[
{
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.3)",
},
props.style,
]}
>
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
<Text
style={{
fontSize: typography.callout,
color: "#E5E7EB",
}}
>
{text}
</Text>
</View>
</BlurView>
);
}
return (
<View
{...props}
className={`
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
${variant === "purple" && "bg-purple-600"}
${variant === "gray" && "bg-neutral-800"}
`}
style={[
{
borderRadius: 4,
padding: 4,
paddingHorizontal: 6,
flexShrink: 1,
flexGrow: 0,
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
backgroundColor: variant === "purple" ? "#9333ea" : "#262626",
},
props.style,
]}
>
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
{iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
style={{
fontSize: 12,
color: "#fff",
}}
>
{text}
</Text>

View File

@@ -15,6 +15,7 @@ import {
View,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { scaleSize } from "@/utils/scaleSize";
import { Loader } from "./Loader";
const getColorClasses = (
@@ -122,7 +123,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
@@ -132,19 +133,29 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(10) : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>
<View
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${className}`}
style={{
borderRadius: scaleSize(16),
paddingVertical: scaleSize(14),
alignItems: "center",
justifyContent: "center",
}}
className={`${colorClasses} ${className}`}
>
<Text className={`${textColorClass} text-xl font-bold`}>
<Text
style={{
fontSize: scaleSize(20),
fontWeight: "bold",
}}
className={textColorClass}
>
{children}
</Text>
</View>

View File

@@ -9,6 +9,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
@@ -73,12 +74,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
SelectedOptions | undefined
>(undefined);
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(items[0], settings);
} = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
@@ -195,9 +200,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
const downloadDetailsPromises = items.map(async (item) => {
// Ensure the snapshot we store offline carries the Chapters array.
// Page-level fetches sometimes use a fields filter that omits it; the
// offline player would then render no chapter ticks / list.
let itemForDownload = item;
if (!itemForDownload.Chapters && itemForDownload.Id) {
try {
const enriched = await getUserLibraryApi(api).getItem({
itemId: itemForDownload.Id,
userId: user.Id!,
});
if (enriched.data) {
itemForDownload = enriched.data;
}
} catch (e) {
console.warn(
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
e,
);
}
}
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!)
? getDefaultPlaySettings(itemForDownload, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
@@ -206,7 +232,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const downloadDetails = await getDownloadUrl({
api,
item,
item: itemForDownload,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
@@ -218,7 +244,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return {
url: downloadDetails?.url,
item,
item: itemForDownload,
mediaSource: downloadDetails?.mediaSource,
};
});

View File

@@ -1,4 +1,5 @@
// GenreTags.tsx
import { BlurView } from "expo-blur";
import type React from "react";
import {
Platform,
@@ -9,6 +10,7 @@ import {
type ViewProps,
} from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface TagProps {
@@ -23,7 +25,10 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
if (Platform.OS === "ios") {
// Hook must be called at the top level, before any conditional returns
const typography = useScaledTVTypography();
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View>
<GlassEffectView style={styles.glass}>
@@ -40,6 +45,32 @@ export const Tag: React.FC<
);
}
// TV-specific styling with blur background
if (Platform.isTV) {
return (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
}}
>
<View
style={{
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
{text}
</Text>
</View>
</BlurView>
);
}
return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}>
@@ -66,7 +97,8 @@ export const Tags: React.FC<
return (
<View
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
className={`flex flex-row flex-wrap ${props.className}`}
style={{ gap: Platform.isTV ? 12 : 4 }}
{...props}
>
{tags.map((tag, idx) => (

View File

@@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
@@ -37,6 +36,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
const ItemContentTV = Platform.isTV
? require("./ItemContent.tv").ItemContentTV
: null;
export type SelectedOptions = {
bitrate: Bitrate;
@@ -46,242 +48,267 @@ export type SelectedOptions = {
};
interface ItemContentProps {
item: BaseItemDto;
item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
const downloadedItem =
isOffline && item.Id ? getDownloadedItemById(item.Id) : null;
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
// Mobile-specific implementation
const ItemContentMobile: React.FC<ItemContentProps> = ({
item,
itemWithSources,
}) => {
const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
const downloadedItem =
isOffline && item?.Id ? getDownloadedItemById(item.Id) : null;
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const itemColors = useImageColorsReturn({ item });
const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
// Use itemWithSources for play settings since it has MediaSources data
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
// Use itemWithSources for play settings since it has MediaSources data
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const onLogoLoad = React.useCallback(() => {
setLoadingLogo(false);
}, []);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
// When offline, use the indices stored in userData (the last-used tracks for this file)
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
const offlineUserData = downloadedItem?.userData;
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
// When offline, use the indices stored in userData (the last-used tracks for this file)
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
const offlineUserData = downloadedItem?.userData;
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.subtitleStreamIndex
: (defaultSubtitleIndex ?? -1),
audioIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.audioStreamIndex
: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
downloadedItem?.userData?.audioStreamIndex,
downloadedItem?.userData?.subtitleStreamIndex,
]);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.subtitleStreamIndex
: (defaultSubtitleIndex ?? -1),
audioIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.audioStreamIndex
: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
downloadedItem?.userData?.audioStreamIndex,
downloadedItem?.userData?.subtitleStreamIndex,
downloadedItem?.userData?.isTranscoded,
]);
useEffect(() => {
if (!Platform.isTV && itemWithSources) {
navigation.setOptions({
headerRight: () =>
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
useEffect(() => {
if (!Platform.isTV && itemWithSources) {
navigation.setOptions({
headerRight: () =>
item &&
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
)),
});
}
}, [
item,
navigation,
user,
itemWithSources,
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
]);
useEffect(() => {
if (item) {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}
}, [item, orientation]);
if (!item || !selectedOptions) return null;
return (
<View
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className='flex-1'
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
)}
</View>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
onLoad={onLogoLoad}
onError={onLogoLoad}
/>
) : (
<View />
)
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
) : (
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' />
)}
{user?.Policy?.IsAdministrator &&
!settings.hideRemoteSessionButton && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
colors={itemColors}
/>
<View className='w-1' />
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
)}
</View>
)}
</View>
)),
});
}
}, [
item,
navigation,
user,
itemWithSources,
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
]);
useEffect(() => {
if (item) {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}
}, [item, orientation]);
if (!item || !selectedOptions) return null;
return (
<View
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className='flex-1'
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
}}
contentFit='contain'
onLoad={onLogoLoad}
onError={onLogoLoad}
/>
) : (
<View />
)
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
</View>
)}
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-2' />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
<ItemPeopleSections item={item} />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-2' />
)}
{!isOffline && <SimilarItems itemId={item.Id} />}
</>
)}
</View>
</ParallaxScrollView>
</View>
);
};
<ItemPeopleSections item={item} />
// Memoize the mobile component
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
{!isOffline && <SimilarItems itemId={item.Id} />}
</>
)}
</View>
</ParallaxScrollView>
</View>
);
},
);
// Exported component that renders TV or mobile version based on platform
export const ItemContent: React.FC<ItemContentProps> = (props) => {
if (Platform.isTV && ItemContentTV) {
return <ItemContentTV {...props} />;
}
return <MemoizedItemContentMobile {...props} />;
};

View File

@@ -0,0 +1,967 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { File } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import {
TVBackdrop,
TVButton,
TVCastCrewText,
TVCastSection,
TVFavoriteButton,
TVMetadataBadges,
TVOptionButton,
TVPlayedButton,
TVProgressBar,
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface ItemContentTVProps {
item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
}
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const { t } = useTranslation();
const queryClient = useQueryClient();
const _itemColors = useImageColorsReturn({ item });
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item?.Id);
// State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// Fetch season episodes for episodes
const { data: seasonEpisodes = [] } = useQuery({
queryKey: ["episodes", item?.SeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
userId: user.Id,
seasonId: item.SeasonId,
enableUserData: true,
fields: ["MediaSources", "Overview"],
});
return res.data.Items || [];
},
enabled:
!!api &&
!!user?.Id &&
!!item?.SeriesId &&
!!item?.SeasonId &&
item?.Type === "Episode",
});
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
// Enable language preference application for TV
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
// Set default play options
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const navigateToPlayer = useCallback(
(playbackPosition: string) => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition,
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[item, selectedOptions, isOffline, router],
);
const handlePlay = () => {
if (!item || !selectedOptions) return;
const hasPlaybackProgress =
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
if (hasPlaybackProgress) {
Alert.alert(
t("item_card.resume_playback"),
t("item_card.resume_playback_description"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("item_card.play_from_start"),
onPress: () => navigateToPlayer("0"),
},
{
text: t("item_card.continue_from", {
time: formatDuration(item.UserData?.PlaybackPositionTicks),
}),
onPress: () =>
navigateToPlayer(
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
),
isPreferred: true,
},
],
);
} else {
navigateToPlayer("0");
}
};
// TV Option Modal hook for quality, audio, media source selectors
const { showOptions } = useTVOptionModal();
// TV Subtitle Modal hook
const { showSubtitleModal } = useTVSubtitleModal();
// State for first actor card ref (used for focus guide)
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
// Get available audio tracks
const audioTracks = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Audio",
);
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks (raw MediaStream[] for label lookup)
const subtitleStreams = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Store handleSubtitleChange in a ref for stable callback reference
const handleSubtitleChangeRef = useRef<((index: number) => void) | null>(
null,
);
// State to trigger refresh of local subtitles list
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
// Starting index for local (client-downloaded) subtitles
const LOCAL_SUBTITLE_INDEX_START = -100;
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
// Also includes locally downloaded subtitles from OpenSubtitles
const subtitleTracksForModal = useMemo((): Track[] => {
const tracks: Track[] = subtitleStreams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index: stream.Index ?? -1,
setTrack: () => {
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
// Add locally downloaded subtitles (from OpenSubtitles)
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) {
continue;
}
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
// For ItemContent (outside player), just update the selected index
// The actual subtitle will be loaded when playback starts
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
// Get available media sources
const mediaSources = useMemo(() => {
return (itemWithSources ?? item)?.MediaSources ?? [];
}, [item, itemWithSources]);
// Audio options for selector
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
return audioTracks.map((track) => ({
label:
track.DisplayTitle ||
`${track.Language || "Unknown"} (${track.Codec})`,
value: track.Index!,
selected: track.Index === selectedOptions?.audioIndex,
}));
}, [audioTracks, selectedOptions?.audioIndex]);
// Media source options for selector
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
return mediaSources.map((source) => {
const videoStream = source.MediaStreams?.find(
(s) => s.Type === "Video",
);
const displayName =
videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
return {
label: displayName,
value: source,
selected: source.Id === selectedOptions?.mediaSource?.Id,
};
});
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
// Quality/bitrate options for selector
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
return BITRATES.map((bitrate) => ({
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions?.bitrate?.value,
}));
}, [selectedOptions?.bitrate?.value]);
// Handlers for option changes
const handleAudioChange = useCallback((audioIndex: number) => {
setSelectedOptions((prev) =>
prev ? { ...prev, audioIndex } : undefined,
);
}, []);
const handleSubtitleChange = useCallback((subtitleIndex: number) => {
setSelectedOptions((prev) =>
prev ? { ...prev, subtitleIndex } : undefined,
);
}, []);
// Keep the ref updated with the latest callback
handleSubtitleChangeRef.current = handleSubtitleChange;
const handleMediaSourceChange = useCallback(
(mediaSource: MediaSourceInfo) => {
const defaultAudio = mediaSource.MediaStreams?.find(
(s) => s.Type === "Audio" && s.IsDefault,
);
const defaultSubtitle = mediaSource.MediaStreams?.find(
(s) => s.Type === "Subtitle" && s.IsDefault,
);
setSelectedOptions((prev) =>
prev
? {
...prev,
mediaSource,
audioIndex: defaultAudio?.Index ?? prev.audioIndex,
subtitleIndex: defaultSubtitle?.Index ?? -1,
}
: undefined,
);
},
[],
);
const handleQualityChange = useCallback((bitrate: Bitrate) => {
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
}, []);
// Handle server-side subtitle download - invalidate queries to refresh tracks
const handleServerSubtitleDownloaded = useCallback(() => {
if (item?.Id) {
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
}
}, [item?.Id, queryClient]);
// Handle local subtitle download - trigger refresh of subtitle tracks
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
setLocalSubtitlesRefreshKey((prev) => prev + 1);
}, []);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
if (!api || !item?.Id) return [];
try {
// Fetch fresh item data with media sources
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
});
const freshItem = response.data;
const mediaSourceId = selectedOptions?.mediaSource?.Id;
// Find the matching media source
const mediaSource = mediaSourceId
? freshItem.MediaSources?.find(
(s: MediaSourceInfo) => s.Id === mediaSourceId,
)
: freshItem.MediaSources?.[0];
// Get subtitle streams from the fresh data
const streams =
mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? [];
// Convert to Track[] with setTrack callbacks
const tracks: Track[] = streams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index: stream.Index ?? -1,
setTrack: () => {
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
// Add locally downloaded subtitles
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) continue;
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
}
}, [api, item?.Id, selectedOptions?.mediaSource?.Id]);
// Get display values for buttons
const selectedAudioLabel = useMemo(() => {
const track = audioTracks.find(
(t) => t.Index === selectedOptions?.audioIndex,
);
return track?.DisplayTitle || track?.Language || t("item_card.audio");
}, [audioTracks, selectedOptions?.audioIndex, t]);
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none");
// Check if it's a local subtitle (negative index starting at -100)
if (
selectedOptions?.subtitleIndex !== undefined &&
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
) {
const localTrack = subtitleTracksForModal.find(
(t) => t.index === selectedOptions.subtitleIndex,
);
return localTrack?.name || t("item_card.subtitles.label");
}
const track = subtitleStreams.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [
subtitleStreams,
subtitleTracksForModal,
selectedOptions?.subtitleIndex,
t,
]);
const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource;
if (!source) return t("item_card.video");
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
}, [selectedOptions?.mediaSource, t]);
const selectedQualityLabel = useMemo(() => {
return selectedOptions?.bitrate?.key || t("item_card.quality");
}, [selectedOptions?.bitrate?.key, t]);
// Format year and duration
const year = item?.ProductionYear;
const duration = item?.RunTimeTicks
? runtimeTicksToMinutes(item.RunTimeTicks)
: null;
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
const remainingTime = hasProgress
? runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)
: null;
// Get director
const director = item?.People?.find((p) => p.Type === "Director");
// Get cast (first 3 for text display)
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
// Get full cast for visual display (up to 10 actors)
const fullCast = useMemo(() => {
return (
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
);
}, [item?.People]);
// Whether to show visual cast section
const showVisualCast =
(item?.Type === "Movie" ||
item?.Type === "Series" ||
item?.Type === "Episode") &&
fullCast.length > 0;
// Series/Season image URLs for episodes
const seriesImageUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId) return null;
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
}, [api, item?.Type, item?.SeriesId]);
const seasonImageUrl = useMemo(() => {
if (item?.Type !== "Episode") return null;
const seasonId = item.SeasonId || item.ParentId;
if (!seasonId) return null;
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
const episodeThumbnailUrl = useMemo(() => {
if (item?.Type !== "Episode" || !api) return null;
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item]);
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
const seriesThumbUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]);
// Navigation handlers
const handleActorPress = useCallback(
(personId: string) => {
router.push(`/(auth)/persons/${personId}`);
},
[router],
);
const handleSeriesPress = useCallback(() => {
if (item?.SeriesId) {
router.push(`/(auth)/series/${item.SeriesId}`);
}
}, [router, item?.SeriesId]);
const handleSeasonPress = useCallback(() => {
if (item?.SeriesId && item?.ParentIndexNumber) {
router.push(
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
);
}
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)");
router.replace(navigation as any);
},
[router],
);
if (!item || !selectedOptions) return null;
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
}}
>
{/* Full-screen backdrop */}
<TVBackdrop item={item} />
{/* Main content area */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Logo/Title + Metadata */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 150,
width: "80%",
marginBottom: 24,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 20,
}}
numberOfLines={2}
>
{item.Name}
</Text>
)}
{/* Episode info for TV shows */}
{item.Type === "Episode" && (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: typography.title,
color: "#FFFFFF",
fontWeight: "600",
}}
>
{item.SeriesName}
</Text>
<Text
style={{
fontSize: typography.body,
color: "white",
marginTop: 6,
}}
>
S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name}
</Text>
</View>
)}
{/* Metadata badges row */}
<TVMetadataBadges
year={year}
duration={duration}
officialRating={item.OfficialRating}
communityRating={item.CommunityRating}
/>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={item.Genres} />
</View>
)}
{/* Overview */}
{item.Overview && (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text
style={{
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
</View>
</BlurView>
)}
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 32,
}}
>
<TVButton
onPress={handlePlay}
hasTVPreferredFocus
variant='primary'
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{hasProgress
? `${remainingTime} ${t("item_card.left")}`
: t("common.play")}
</Text>
</TVButton>
<TVFavoriteButton item={item} />
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>
{/* Playback options */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 24,
}}
>
{/* Quality selector */}
<TVOptionButton
label={t("item_card.quality")}
value={selectedQualityLabel}
maxWidth={200}
onPress={() =>
showOptions({
title: t("item_card.quality"),
options: qualityOptions,
onSelect: handleQualityChange,
})
}
/>
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
label={t("item_card.video")}
value={selectedMediaSourceLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.video"),
options: mediaSourceOptions,
onSelect: handleMediaSourceChange,
})
}
/>
)}
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
label={t("item_card.audio")}
value={selectedAudioLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.audio"),
options: audioOptions,
onSelect: handleAudioChange,
})
}
/>
)}
{/* Subtitle selector */}
{(subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
maxWidth={280}
onPress={() =>
showSubtitleModal({
item,
mediaSourceId: selectedOptions?.mediaSource?.Id,
subtitleTracks: subtitleTracksForModal,
currentSubtitleIndex:
selectedOptions?.subtitleIndex ?? -1,
onDisableSubtitles: () => handleSubtitleChange(-1),
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
onLocalSubtitleDownloaded:
handleLocalSubtitleDownloaded,
refreshSubtitleTracks,
})
}
/>
)}
</View>
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<TVProgressBar
progress={
(item.UserData?.PlaybackPositionTicks || 0) /
item.RunTimeTicks
}
fillColor='#FFFFFF'
/>
)}
</View>
{/* Right side - Poster */}
<View
style={{
width:
item.Type === "Episode"
? SCREEN_WIDTH * 0.35
: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
{item.Type === "Episode" ? (
<Image
source={{
uri:
settings.showSeriesPosterOnEpisode && seriesThumbUrl
? seriesThumbUrl
: episodeThumbnailUrl!,
}}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
)}
</View>
</View>
</View>
{/* Additional info section */}
<View style={{ marginTop: 40 }}>
{/* Season Episodes - Episode only */}
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
}}
>
{t("item_card.more_from_this_season")}
</Text>
<TVEpisodeList
episodes={seasonEpisodes}
currentEpisodeId={item.Id}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
firstEpisodeRefSetter={setFirstEpisodeRef}
horizontalPadding={insets.left + 80}
/>
</View>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
horizontalPadding={insets.left + 80}
/>
)}
{/* Cast & Crew (text version - director, etc.) */}
<TVCastCrewText
director={director}
cast={cast}
hideCast={showVisualCast}
/>
{/* Technical details */}
{selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<TVTechnicalDetails
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
</View>
</ScrollView>
</View>
);
},
);
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
export const ItemContent = ItemContentTV;

View File

@@ -0,0 +1,163 @@
import React from "react";
import { Dimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export const ItemContentSkeletonTV: React.FC = () => {
const insets = useSafeAreaInsets();
return (
<View
style={{
flex: 1,
flexDirection: "row",
paddingTop: insets.top + 140,
paddingHorizontal: insets.left + 80,
}}
>
{/* Left side - Content placeholders */}
<View style={{ flex: 1 }}>
{/* Logo placeholder */}
<View
style={{
height: 150,
width: "80%",
backgroundColor: "#1a1a1a",
borderRadius: 8,
marginBottom: 24,
}}
/>
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
gap: 12,
marginBottom: 20,
}}
>
<View
style={{
height: 24,
width: 60,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 50,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Genres placeholder */}
<View
style={{
flexDirection: "row",
gap: 8,
marginBottom: 24,
}}
>
<View
style={{
height: 28,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 100,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 70,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
</View>
{/* Overview placeholder */}
<View
style={{
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
height: 18,
width: "100%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "90%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "75%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Play button placeholder */}
<View
style={{
height: 56,
width: 180,
backgroundColor: "#1a1a1a",
borderRadius: 12,
}}
/>
</View>
{/* Right side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
</View>
);
};

View File

@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
{t("item_card.subtitles.label")}
</Text>
<SubtitleStreamInfo
subtitleStreams={

View File

@@ -154,7 +154,7 @@ export const MediaSourceButton: React.FC<Props> = ({
}));
groups.push({
title: t("item_card.subtitles"),
title: t("item_card.subtitles.label"),
options: [noneOption, ...subtitleOptions],
});
}

View File

@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{/* Password Input */}
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-400 text-sm mb-2'>
{t("login.password")}
{t("login.password_placeholder")}
</Text>
<BottomSheetTextInput
value={password}
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
secureTextEntry
autoFocus
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{isLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("login.login")
t("common.login")
)}
</Button>
</View>

View File

@@ -1,12 +1,28 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Option types
export type RadioOption<T = any> = {
type: "radio";
@@ -201,6 +217,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -230,12 +264,30 @@ const PlatformDropdownComponent = ({
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
<ContextMenu.Items>
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
@@ -250,27 +302,40 @@ const PlatformDropdownComponent = ({
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
options={radioOptions.map((opt) => opt.label)}
variant='menu'
selectedIndex={radioOptions.findIndex(
(opt) => opt.selected,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
/>,
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
@@ -278,17 +343,18 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
}
@@ -299,17 +365,16 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
@@ -318,21 +383,20 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
return items;
})}
</ContextMenu.Items>
</ContextMenu>
</Host>
</Menu>
</Host>
</View>
);
}

View File

@@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { chromecast } from "../utils/profiles/chromecast";
import { chromecasth265 } from "../utils/profiles/chromecasth265";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
@@ -430,7 +430,7 @@ export const PlayButton: React.FC<Props> = ({
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -69,6 +69,7 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
const queryString = queryParams.toString();
@@ -77,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
@@ -122,10 +131,17 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(selectedServer.address);
try {
await onQuickLogin(selectedServer.address, selectedAccount.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),

View File

@@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const trigger = (
<View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
{t("item_card.subtitles.label")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
@@ -97,7 +97,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.subtitles")}
title={t("item_card.subtitles.label")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}

View File

@@ -1,8 +1,37 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native";
import { Platform, View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (Platform.isTV) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
return (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
);
}
return null;
}
// Mobile: Show purple triangle for unwatched
return (
<>
{item.UserData?.Played === false &&

View File

@@ -1,909 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getTvShowsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
interpolate,
runOnJS,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "../common/ItemImage";
import { getItemNavigation } from "../common/TouchableItemRouter";
import type { SelectedOptions } from "../ItemContent";
import { PlayButton } from "../PlayButton";
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
}
// Layout Constants
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 260;
const GENRES_BOTTOM_POSITION = 220;
const OVERVIEW_BOTTOM_POSITION = 165;
const CONTROLS_BOTTOM_POSITION = 80;
const DOTS_BOTTOM_POSITION = 40;
// Size Constants
const DOT_HEIGHT = 6;
const DOT_ACTIVE_WIDTH = 20;
const DOT_INACTIVE_WIDTH = 12;
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 10;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
const DOT_BORDER_RADIUS = 3;
const LOGO_SKELETON_BORDER_RADIUS = 8;
const TEXT_SKELETON_BORDER_RADIUS = 4;
const PLAY_BUTTON_BORDER_RADIUS = 25;
const PLAYED_STATUS_BORDER_RADIUS = 20;
// Animation Constants
const DOT_ANIMATION_DURATION = 300;
const CAROUSEL_TRANSITION_DURATION = 250;
const PAN_ACTIVE_OFFSET = 10;
const TRANSLATION_THRESHOLD = 0.2;
const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const OVERVIEW_FONT_SIZE = 14;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
const OVERVIEW_MAX_LINES = 2;
const OVERVIEW_MAX_WIDTH = "80%";
// Opacity Constants
const OVERLAY_OPACITY = 0.3;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
// Color Constants
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
const SKELETON_ELEMENT_COLOR = "#333";
const SKELETON_ACTIVE_DOT_COLOR = "#666";
const _EMPTY_STATE_COLOR = "#666";
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
const LOGO_WIDTH_PERCENTAGE = "80%";
const DotIndicator = ({
index,
currentIndex,
onPress,
}: {
index: number;
currentIndex: number;
onPress: (index: number) => void;
}) => {
const isActive = index === currentIndex;
const animatedStyle = useAnimatedStyle(() => ({
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
}));
return (
<Pressable
onPress={() => onPress(index)}
style={{
padding: DOT_PADDING, // Increase touch area
}}
>
<Animated.View
style={[
{
height: DOT_HEIGHT,
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
borderRadius: DOT_BORDER_RADIUS,
},
animatedStyle,
]}
/>
</Pressable>
);
};
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
scrollOffset,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const isLandscape = screenWidth >= screenHeight;
const carouselHeight = useMemo(
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
[isLandscape, screenHeight],
);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-initialIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
useQuery({
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres", "Overview"],
limit: 2,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres", "Overview"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
{
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
return response.data || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
},
);
const items = useMemo(() => {
const continueItems = continueWatchingData ?? [];
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
const allItems = [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
// Deduplicate by item ID to prevent duplicate keys
const seen = new Set<string>();
return allItems.filter((item) => {
if (item.Id && !seen.has(item.Id)) {
seen.add(item.Id);
return true;
}
return false;
});
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
const hasItems = items.length > 0;
// Only get play settings if we have valid items
const currentItem = hasItems ? items[currentIndex] : null;
// Extract colors for the current item only (for performance)
const currentItemColors = useImageColorsReturn({ item: currentItem });
// Create a fallback empty item for useDefaultPlaySettings when no item is available
const itemForPlaySettings = currentItem || { MediaSources: [] };
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
// Only set options if we have valid current item
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
} else {
setSelectedOptions(undefined);
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
currentIndex,
currentItem,
]);
useEffect(() => {
if (!hasItems) {
setCurrentIndex(initialIndex);
translateX.value = -initialIndex * screenWidth;
return;
}
setCurrentIndex((prev) => {
const newIndex = Math.min(prev, items.length - 1);
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
useEffect(() => {
if (hasItems) {
onItemChange?.(currentIndex);
}
}, [hasItems, currentIndex, onItemChange]);
const goToIndex = useCallback(
(index: number) => {
if (!hasItems || index < 0 || index >= items.length) return;
translateX.value = withTiming(-index * screenWidth, {
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
});
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, screenWidth, translateX],
);
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const togglePlayedStatus = useMarkAsPlayed(items);
const headerAnimatedStyle = useAnimatedStyle(() => {
if (!scrollOffset) return {};
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[-carouselHeight / 2, 0, carouselHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[2, 1, 1],
),
},
],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: carouselHeight,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Overview Skeleton */}
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
gap: 6,
}}
>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH * 0.7,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: carouselHeight,
position: "relative",
}}
>
{/* Background Backdrop */}
<Animated.View
style={[
{
width: "100%",
height: "100%",
position: "absolute",
},
headerAnimatedStyle,
]}
>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Overview Section - for Episodes and Movies */}
{(item.Type === "Episode" || item.Type === "Movie") &&
item.Overview && (
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
fontSize: OVERVIEW_FONT_SIZE,
fontWeight: "400",
textAlign: "center",
maxWidth: OVERVIEW_MAX_WIDTH,
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{item.Overview}
</Animated.Text>
</TouchableOpacity>
</View>
)}
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<MarkAsPlayedLargeButton
isPlayed={item.UserData?.Played ?? false}
onToggle={togglePlayedStatus}
/>
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: carouselHeight,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: carouselHeight, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: carouselHeight, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

@@ -1,51 +0,0 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { Platform, View } from "react-native";
import { RoundButton } from "../RoundButton";
interface MarkAsPlayedLargeButtonProps {
isPlayed: boolean;
onToggle: (isPlayed: boolean) => void;
}
export const MarkAsPlayedLargeButton: React.FC<
MarkAsPlayedLargeButtonProps
> = ({ isPlayed, onToggle }) => {
if (Platform.OS === "ios")
return (
<Host
style={{
flex: 0,
width: 50,
height: 50,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
<View>
<Ionicons
name='checkmark'
size={24}
color='white'
style={{
marginTop: 6,
marginLeft: 1,
}}
/>
</View>
</Button>
</Host>
);
return (
<View>
<RoundButton
size='large'
icon={isPlayed ? "checkmark" : "checkmark"}
onPress={() => onToggle(isPlayed)}
/>
</View>
);
};

View File

@@ -0,0 +1,196 @@
/**
* A modal listing an item's chapters. Each row shows the chapter name and its
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
* chapter and closes the modal. Player-agnostic — the seek is injected.
*/
import { Ionicons } from "@expo/vector-icons";
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { memo, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import {
type ChapterEntry,
chapterStartsMs,
formatChapterTime,
sortedChapters,
} from "@/utils/chapters";
interface ChapterListProps {
visible: boolean;
chapters: ChapterInfo[] | null | undefined;
/** Current playback position in milliseconds (to highlight the row). */
currentPositionMs: number;
/** Seek the player to this millisecond position. */
onSeek: (positionMs: number) => void;
onClose: () => void;
}
const ROW_HEIGHT = 48;
function ChapterListComponent({
visible,
chapters,
currentPositionMs,
onSeek,
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
// every tick — chapters is the only input that drives the underlying array.
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
const activeIndex = useMemo(() => {
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (currentPositionMs >= starts[i]) idx = i;
else break;
}
return idx;
}, [currentPositionMs, starts]);
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
// children mounted across visible toggles, so subsequent opens never scroll.
// Trigger an imperative scroll each time the sheet becomes visible.
useEffect(() => {
if (!visible || activeIndex < 0 || entries.length === 0) return;
const raf = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({
index: activeIndex,
animated: false,
viewPosition: 0.5,
});
});
return () => cancelAnimationFrame(raf);
}, [visible, activeIndex, entries.length]);
return (
<Modal
visible={visible}
transparent
animationType='slide'
onRequestClose={onClose}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
onPress={onClose}
hitSlop={10}
accessibilityRole='button'
accessibilityLabel={t("chapters.close")}
>
<Ionicons name='close' size={24} color={Colors.text} />
</Pressable>
</View>
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
onScrollToIndexFailed={(info) => {
// Required when getItemLayout is provided and the target index
// is outside the currently rendered window. Fallback to an
// offset-based scroll, then retry the precise scroll once a
// frame has elapsed.
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: false,
});
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
animated: false,
viewPosition: 0.5,
});
}, 50);
}}
renderItem={({ item, index }) => {
const positionMs = item.positionMs;
const isActive = index === activeIndex;
return (
<Pressable
onPress={() => {
onSeek(positionMs);
onClose();
}}
style={[
styles.row,
isActive && { backgroundColor: `${Colors.primary}33` },
]}
>
<Text
style={[
styles.rowText,
{ color: isActive ? Colors.primary : Colors.text },
]}
numberOfLines={1}
>
{item.chapter.Name ||
t("chapters.chapter_number", { number: index + 1 })}
</Text>
<Text style={styles.rowTime}>
{formatChapterTime(positionMs)}
</Text>
</Pressable>
);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
}
export const ChapterList = memo(ChapterListComponent);
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
},
title: {
color: Colors.text,
fontSize: 17,
fontWeight: "700",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
height: ROW_HEIGHT,
},
rowText: {
fontSize: 15,
flex: 1,
},
rowTime: {
color: Colors.icon,
fontSize: 13,
marginLeft: 12,
},
});

View File

@@ -0,0 +1,87 @@
/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);

View File

@@ -1,50 +1,146 @@
import React, { useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRef, useState } from "react";
import {
Animated,
Easing,
Platform,
Pressable,
TextInput,
type TextInputProps,
TouchableOpacity,
View,
} from "react-native";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface InputProps extends TextInputProps {
extraClassName?: string; // new prop for additional classes
extraClassName?: string;
}
export function Input(props: InputProps) {
const { style, extraClassName = "", ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
// TV-only: scales the input font with the tvTypographyScale setting.
// Not consumed by the mobile branch below.
const tvTypography = useScaledTVTypography();
return Platform.isTV ? (
<TouchableOpacity
onPress={() => inputRef?.current?.focus?.()}
activeOpacity={1}
>
<TextInput
ref={inputRef}
className={`
w-full text-lg px-5 py-4 rounded-2xl
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
text-white ${extraClassName}
`}
allowFontScaling={false}
style={[
style,
{
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
},
]}
placeholderTextColor={"#ffffffff"}
clearButtonMode='while-editing'
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...otherProps}
/>
</TouchableOpacity>
) : (
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
if (Platform.isTV) {
// Scale the whole input (box height, padding, icon) proportionally with the
// font so the component grows/shrinks with the tvTypographyScale setting.
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
const fontSize = tvTypography.body;
const factor = fontSize / 28;
const height = Math.round(56 * factor);
const paddingLeft = Math.round(24 * factor);
const iconSize = Math.round(26 * factor);
const iconMarginRight = Math.round(14 * factor);
const containerStyle = {
height,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)",
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft,
};
const inputElement = (
<>
<Ionicons
name='search'
size={iconSize}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: iconMarginRight }}
/>
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor='#666'
style={[
{
flex: 1,
height,
fontSize,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</>
);
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Animated.View
style={{
transform: [{ scale }],
}}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : (
<View
style={[
containerStyle,
{
backgroundColor: isFocused
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.08)",
},
]}
>
{inputElement}
</View>
)}
</Animated.View>
</Pressable>
);
}
// Mobile version unchanged
return (
<TextInput
ref={inputRef}
className='p-4 rounded-xl bg-neutral-900'
className={`p-4 rounded-xl bg-neutral-900 ${extraClassName}`}
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react";
import { View } from "react-native";
import { Platform, View } from "react-native";
interface ProgressBarProps {
item: BaseItemDto;
@@ -39,8 +39,9 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
<View
style={{
width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
/>
</>
);

View File

@@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
Platform,
TouchableOpacity,
type TouchableOpacityProps,
} from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
@@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
}
if (item.Type === "Playlist") {
if (Platform.isTV) {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
return {
pathname: "/music/playlist/[playlistId]" as const,
params: { playlistId: item.Id! },

View File

@@ -0,0 +1,532 @@
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
KeyboardAvoidingView,
Linking,
Platform,
ScrollView,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService";
type ScreenState =
| "scanning"
| "no-permission"
| "confirm"
| "form"
| "sending"
| "success"
| "error";
interface ParsedPairingCode {
code: string;
}
type ExpoCameraModule = typeof import("expo-camera");
const ExpoCamera: ExpoCameraModule | null = Platform.isTV
? null
: require("expo-camera");
export const CompanionLoginScreen: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [screenState, setScreenState] = useState<ScreenState>(
Platform.isTV ? "form" : "scanning",
);
const [pairingCode, setPairingCode] = useState<string>("");
const [serverUrl, setServerUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Pre-fill server URL and username from current session
useEffect(() => {
if (api?.basePath) {
setServerUrl(api.basePath);
}
if (user?.Name) {
setUsername(user.Name);
}
}, [api?.basePath, user?.Name]);
// Request camera permission
useEffect(() => {
if (!ExpoCamera) return;
ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => {
if (!response.granted) {
ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => {
if (!result.granted) {
setScreenState("no-permission");
}
});
}
});
}, []);
const validateAndParseQR = useCallback(
(data: string): ParsedPairingCode | null => {
try {
const parsed = JSON.parse(data);
if (
parsed.action === "streamyfin-pair" &&
typeof parsed.code === "string" &&
parsed.code.length > 0
) {
return { code: parsed.code };
}
return null;
} catch {
return null;
}
},
[],
);
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (screenState !== "scanning") return;
const parsed = validateAndParseQR(data);
if (!parsed) {
setErrorMessage(t("companion_login.error_invalid_qr"));
setScreenState("error");
return;
}
setPairingCode(parsed.code);
// If user is logged in, show confirmation screen (still needs password)
// Otherwise, go straight to the full form
if (user?.Name && api?.basePath) {
setScreenState("confirm");
} else {
setScreenState("form");
}
},
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
);
const handleSendCredentials = useCallback(async () => {
if (
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode
) {
return;
}
setScreenState("sending");
try {
await sendCredentialsToTV(
pairingCode,
serverUrl.trim(),
username.trim(),
password,
);
setScreenState("success");
} catch {
setErrorMessage(t("companion_login.error_generic"));
setScreenState("error");
}
}, [pairingCode, serverUrl, username, password, t]);
const handleScanAgain = useCallback(() => {
setPairingCode("");
setErrorMessage(null);
setPassword("");
setScreenState("scanning");
}, []);
const handleDone = useCallback(() => {
router.back();
}, [router]);
const handleUseDifferentUser = useCallback(() => {
setUsername("");
setPassword("");
setScreenState("form");
}, []);
const handleEnterCodeManually = useCallback(() => {
setScreenState("form");
}, []);
if (screenState === "no-permission") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_permission_denied")}
</Text>
{Platform.OS === "ios" && (
<TouchableOpacity
onPress={() => Linking.openSettings()}
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
>
<Text className='text-base font-semibold text-white'>
{t("companion_login.open_settings")}
</Text>
</TouchableOpacity>
)}
<Button
onPress={handleDone}
color='white'
className='mt-4'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "success") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.success_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.pairing_tv_connecting")}
</Text>
<Button
onPress={handleDone}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "error") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{errorMessage}
</Text>
<View className='mt-4 flex-row gap-3'>
<Button
onPress={handleScanAgain}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleDone}
color='white'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
</View>
);
}
if (screenState === "sending") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='text-xl text-white'>
{t("companion_login.authorizing")}
</Text>
</View>
</View>
);
}
if (screenState === "confirm") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 24,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-center text-2xl font-bold text-white'>
{t("companion_login.login_as", { username })}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.on_server", {
server: serverUrl.replace(/^https?:\/\//, ""),
})}
</Text>
<View className='mb-6 items-center'>
<Text className='mb-1 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
{pairingCode}
</Text>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
autoFocus
/>
</View>
<View className='mt-2'>
<Button
onPress={handleSendCredentials}
disabled={!password.trim()}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
<View className='mt-6 items-center'>
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
<Text className='text-base text-gray-400 underline'>
{t("companion_login.use_different_user")}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
<Text className='text-sm text-gray-500 underline'>
{t("companion_login.scan_again")}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
if (screenState === "form") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 14,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-2xl font-bold text-white'>
{t("companion_login.pairing_enter_credentials")}
</Text>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-center text-2xl font-bold tracking-[6px] text-white'
value={pairingCode}
onChangeText={setPairingCode}
placeholder={t("companion_login.pairing_code_label")}
placeholderTextColor='#6B7280'
autoCapitalize='characters'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.server")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={serverUrl}
onChangeText={setServerUrl}
placeholder={t("server.server_url_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
keyboardType='url'
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.username_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={username}
onChangeText={setUsername}
placeholder={t("login.username_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
/>
</View>
<View className='flex-row justify-center gap-3'>
<Button
onPress={handleScanAgain}
color='black'
className='w-40 border border-neutral-700 bg-neutral-800'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleSendCredentials}
disabled={
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode.trim()
}
className='w-40'
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const CameraView = ExpoCamera?.CameraView;
if (!CameraView) {
return (
<View className='flex-1 bg-black items-center justify-center p-8'>
<Button
onPress={handleEnterCodeManually}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.enter_code_manually")}
</Button>
</View>
);
}
return (
<View className='flex-1 bg-black items-center justify-center'>
{/* Camera full screen */}
<CameraView
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
{/* Dark overlay */}
<View className='absolute inset-0 bg-black/60' />
{/* Center scan area */}
<View className='items-center'>
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
<Text className='mt-6 text-center text-base text-white'>
{t("companion_login.align_qr")}
</Text>
<TouchableOpacity
onPress={handleEnterCodeManually}
className='mt-4 px-5 py-2'
>
<Text className='text-sm text-gray-400 underline'>
{t("companion_login.enter_code_manually")}
</Text>
</TouchableOpacity>
</View>
</View>
);
};

View File

@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}, [process?.progress]);
// Return null after all hooks have been called
if (!process || !process.item || !process.item.Id) {
if (!process?.item?.Id) {
return null;
}

View File

@@ -0,0 +1,231 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 10;
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
async (
itemType: BaseItemKind,
startIndex: number = 0,
limit: number = 20,
) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
startIndex: startIndex,
limit: limit,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
[api, user],
);
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Series", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Movie", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Episode", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Video", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("BoxSet", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Playlist", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
}}
>
<View style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
/>
</View>
</ScrollView>
);
};

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -44,6 +45,9 @@ import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
// Conditionally load TV version
const HomeTV = Platform.isTV ? require("./Home.tv").Home : null;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
@@ -64,7 +68,7 @@ type MediaListSectionType = {
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const Home = () => {
const HomeMobile = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -86,6 +90,10 @@ export const Home = () => {
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
@@ -595,11 +603,14 @@ export const Home = () => {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
const streamystatsIndex =
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
@@ -687,3 +698,11 @@ export const Home = () => {
</ScrollView>
);
};
// Exported component that renders TV or mobile version based on platform
export const Home = () => {
if (Platform.isTV && HomeTV) {
return <HomeTV />;
}
return <HomeMobile />;
};

View File

@@ -1,4 +1,4 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -12,37 +12,46 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useSegments } from "expo-router";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
Animated,
Easing,
ScrollView,
View,
} from "react-native";
import Animated, {
useAnimatedRef,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import {
apiAtom,
cacheVersionAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
import { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = scaleSize(100);
// Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = scaleSize(24);
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -51,97 +60,141 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
parentId?: string;
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = InfiniteScrollingCollectionListSection;
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
const BACKDROP_DEBOUNCE_MS = 300;
export const HomeWithCarousel = () => {
const router = useRouter();
export const Home = () => {
const typography = useScaledTVTypography();
const _router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const cacheVersion = useAtomValue(cacheVersionAtom);
const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const { settings } = useSettings();
const scrollRef = useRef<ScrollView>(null);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal();
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
invalidateCache();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Handle item focus with debounce
const handleItemFocus = useCallback((item: BaseItemDto) => {
// Clear any pending debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"),
);
// Set new timer to update focused item after debounce delay
debounceTimerRef.current = setTimeout(() => {
setFocusedItem(item);
}, BACKDROP_DEBOUNCE_MS);
}, []);
const segments = useSegments();
// Cleanup debounce timer on unmount
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
animatedScrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Get backdrop URL from focused item (only if setting is enabled)
const backdropUrl = useMemo(() => {
if (!settings.showHomeBackdrop || !focusedItem) return null;
return getBackdropUrl({
api,
item: focusedItem,
quality: 90,
width: 1920,
});
}, [api, focusedItem, settings.showHomeBackdrop]);
// Crossfade animation for backdrop transitions
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(null);
const layer0Opacity = useRef(new Animated.Value(0)).current;
const layer1Opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!backdropUrl) return;
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
try {
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
if (isCancelled) return;
// Determine which layer to fade in
const incomingLayer = activeLayer === 0 ? 1 : 0;
const incomingOpacity =
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
const outgoingOpacity =
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
// Set the new URL on the incoming layer
if (incomingLayer === 0) {
setLayer0Url(backdropUrl);
} else {
setLayer1Url(backdropUrl);
}
// Small delay to ensure image component has the new URL
await new Promise((resolve) => setTimeout(resolve, 50));
if (isCancelled) return;
// Crossfade: fade in the incoming layer, fade out the outgoing
Animated.parallel([
Animated.timing(incomingOpacity, {
toValue: 1,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(outgoingOpacity, {
toValue: 0,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!isCancelled) {
setActiveLayer(incomingLayer);
}
});
};
performCrossfade();
return () => {
unsubscribe();
isCancelled = true;
};
}, [segments]);
}, [backdropUrl]);
const {
data,
@@ -162,8 +215,72 @@ export const HomeWithCarousel = () => {
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
// Fetch hero items (Continue Watching + Next Up combined)
const { data: heroItems } = useQuery({
queryKey: ["home", "heroItems", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Overview"],
startIndex: 0,
limit: 10,
}),
getTvShowsApi(api).getNextUp({
userId: user.Id,
startIndex: 0,
limit: 10,
fields: ["Overview"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
// Combine, sort by recent activity, and dedupe
const combined = [...resumeItems, ...nextUpItems];
const sorted = combined.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of sorted) {
if (!item.Id || seen.has(item.Id)) continue;
seen.add(item.Id);
deduped.push(item);
}
return deduped.slice(0, 15);
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
useEffect(() => {
updateTVDiscovery({
api,
sections: [
{
title: t("home.continue_and_next_up"),
items: heroItems,
},
],
});
}, [api, heroItems, t]);
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
@@ -178,44 +295,36 @@ export const HomeWithCarousel = () => {
);
}, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize: number = 10,
pageSize = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 100, // Fetch a larger set for pagination
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
limit: 10,
fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
parentId,
}),
[api, user?.Id],
);
@@ -244,7 +353,6 @@ export const HomeWithCarousel = () => {
);
});
// Helper to sort items by most recent activity
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
return items.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
@@ -253,7 +361,6 @@ export const HomeWithCarousel = () => {
});
};
// Helper to deduplicate items by ID
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
const seen = new Set<string>();
return items.filter((item) => {
@@ -263,29 +370,25 @@ export const HomeWithCarousel = () => {
});
};
// Build the first sections based on merge setting
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
? [
{
title: t("home.continue_and_next_up"),
queryKey: ["home", "continueAndNextUp"],
queryFn: async ({ pageParam = 0 }) => {
// Fetch both in parallel
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: 0,
limit: 20,
}),
getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: 0,
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
@@ -293,12 +396,10 @@ export const HomeWithCarousel = () => {
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
// Combine, sort by recent activity, deduplicate
const combined = [...resumeItems, ...nextUpItems];
const sorted = sortByRecentActivity(combined);
const deduplicated = deduplicateById(sorted);
// Paginate client-side
return deduplicated.slice(pageParam, pageParam + 10);
},
type: "InfiniteScrollingCollectionList",
@@ -314,9 +415,8 @@ export const HomeWithCarousel = () => {
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: pageParam,
limit: 10,
})
@@ -332,10 +432,9 @@ export const HomeWithCarousel = () => {
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
@@ -348,7 +447,6 @@ export const HomeWithCarousel = () => {
const ss: Section[] = [
...firstSections,
...latestMediaViews,
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
...(!settings?.streamyStatsMovieRecommendations
? [
{
@@ -409,29 +507,26 @@ export const HomeWithCarousel = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 100, // Fetch larger set
limit: section.latest?.limit || 10,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
@@ -461,6 +556,22 @@ export const HomeWithCarousel = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
// Determine if hero should be shown (separate setting from backdrop)
// We need this early to calculate which sections will actually be rendered
const showHero = useMemo(() => {
return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
}, [heroItems, settings.showTVHeroCarousel]);
// Get sections that will actually be rendered (accounting for hero slicing)
// When hero is shown, skip the first sections since hero already displays that content
// - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up)
// - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
const renderedSections = useMemo(() => {
if (!showHero) return sections;
const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2;
return sections.slice(sectionsToSkip);
}, [sections, showHero, settings.mergeNextUpAndContinueWatching]);
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
@@ -476,32 +587,44 @@ export const HomeWithCarousel = () => {
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{title}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{subtitle}
</Text>
<View style={{ marginTop: 24 }}>
<Button
color='black'
onPress={retryCheck}
justify='center'
className='mt-2'
className='px-4'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
<Ionicons name='refresh' size={24} color='white' />
)
}
>
@@ -518,9 +641,31 @@ export const HomeWithCarousel = () => {
if (e1)
return (
<View className='flex flex-col items-center justify-center h-full -mt-6'>
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
<Text className='text-center opacity-70'>
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("home.oops")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("home.error_message")}
</Text>
</View>
@@ -528,55 +673,122 @@ export const HomeWithCarousel = () => {
if (l1)
return (
<View className='justify-center items-center h-full'>
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Loader />
</View>
);
return (
<Animated.ScrollView
scrollToOverflowEnabled={true}
ref={animatedScrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16}
bounces={false}
overScrollMode='never'
style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => {
setScrollY(event.nativeEvent.contentOffset.y);
}}
>
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 0,
<View key={cacheVersion} style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
{!showHero && settings.showHomeBackdrop && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
)}
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
}}
>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
{/* Hero Carousel - Apple TV+ style featured content */}
{showHero && heroItems && (
<TVHeroCarousel
items={heroItems}
onItemFocus={handleItemFocus}
onItemLongPress={showItemActions}
/>
)}
<View
style={{
gap: SECTION_GAP,
paddingTop: showHero ? SECTION_GAP : 0,
}}
>
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
{renderedSections.map((section, index) => {
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
const displayedSectionsLength = renderedSections.length;
const streamystatsIndex =
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<>
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsSeriesRecommendations && (
@@ -585,17 +797,22 @@ export const HomeWithCarousel = () => {
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists />
<StreamystatsPromotedWatchlists
onItemFocus={handleItemFocus}
/>
)}
</>
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
// First section only gets preferred focus if hero is not shown
const isFirstSection = index === 0 && !showHero;
return (
<View key={index} className='flex flex-col space-y-4'>
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
@@ -603,19 +820,9 @@ export const HomeWithCarousel = () => {
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
/>
{streamystatsSections}
</View>
);
}
if (section.type === "MediaListSection") {
return (
<View key={index} className='flex flex-col space-y-4'>
<MediaListSection
queryKey={section.queryKey}
queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
parentId={section.parentId}
/>
{streamystatsSections}
</View>
@@ -624,8 +831,7 @@ export const HomeWithCarousel = () => {
return null;
})}
</View>
</View>
<View className='h-24' />
</Animated.ScrollView>
</ScrollView>
</View>
);
};

View File

@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
},
initialPageParam: 0,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,

View File

@@ -0,0 +1,388 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
type QueryFunction,
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
View,
type ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const _SCALE_PADDING = scaleSize(20);
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
hideIfEmpty?: boolean;
pageSize?: number;
onPressSeeAll?: () => void;
enabled?: boolean;
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
parentId?: string;
}
type Typography = ReturnType<typeof useScaledTVTypography>;
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{
onPress: () => void;
orientation: "horizontal" | "vertical";
disabled?: boolean;
onFocus?: () => void;
onBlur?: () => void;
typography: Typography;
posterSizes: PosterSizes;
}> = ({
onPress,
orientation,
disabled,
onFocus,
onBlur,
typography,
posterSizes,
}) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
<View style={{ width }}>
<TVFocusablePoster
onPress={onPress}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
>
<View
style={{
width,
aspectRatio,
borderRadius: scaleSize(24),
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='arrow-forward'
size={scaleSize(32)}
color='white'
style={{ marginBottom: scaleSize(8) }}
/>
<Text
style={{
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "600",
}}
>
{t("common.seeAll", { defaultValue: "See all" })}
</Text>
</View>
</TVFocusablePoster>
</View>
);
};
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 10,
enabled = true,
isFirstSection = false,
onItemFocus,
parentId,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const effectivePageSize = Math.max(1, pageSize);
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
// Pass through focus callbacks without tracking internal state
const handleItemFocus = useCallback(
(item: BaseItemDto) => {
onItemFocus?.(item);
},
[onItemFocus],
);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation();
const allItems = useMemo(() => {
const items = data?.pages.flat() ?? [];
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of items) {
const id = item.Id;
if (!id) continue;
if (seen.has(id)) continue;
seen.add(id);
deduped.push(item);
}
return deduped;
}, [data]);
const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleSeeAllPress = useCallback(() => {
if (!parentId) return;
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: {
libraryId: parentId,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
},
} as any);
}, [router, parentId]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
return (
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
width={itemWidth}
/>
</View>
);
},
[
orientation,
isFirstSection,
itemWidth,
handleItemPress,
showItemActions,
handleItemFocus,
ITEM_GAP,
],
);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
{/* Section Header */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: scaleSize(20),
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: sizes.padding.horizontal,
}}
>
{t("home.no_items")}
</Text>
)}
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.gaps.small,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: itemWidth }}>
<View
style={{
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
) : (
<FlatList
ref={flatListRef}
horizontal
data={allItems}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (
<View
style={{
marginLeft: itemWidth / 2,
marginRight: ITEM_GAP,
justifyContent: "center",
height:
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}}
>
<ActivityIndicator size='small' color='white' />
</View>
)}
{parentId && allItems.length > 0 && (
<TVSeeAllCard
onPress={handleSeeAllPress}
orientation={orientation}
disabled={disabled}
typography={typography}
posterSizes={posterSizes}
/>
)}
</View>
}
/>
)}
</View>
);
};

View File

@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,

View File

@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -0,0 +1,382 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const SCALE_PADDING = scaleSize(20);
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
onItemFocus?: (item: BaseItemDto) => void;
}
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
watchlist,
jellyfinServerId,
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const { data: items, isLoading } = useQuery({
queryKey: [
"streamystats",
"watchlist",
watchlist.id,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
watchlistId: watchlist.id,
jellyfinServerId,
});
const itemIds = watchlistDetail.data?.items;
if (!itemIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: itemIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(settings?.streamyStatsServerUrl) &&
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[posterSizes.poster, ITEM_GAP],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
width={posterSizes.poster}
/>
</View>
);
},
[
ITEM_GAP,
posterSizes.poster,
handleItemPress,
showItemActions,
onItemFocus,
],
);
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{watchlist.name}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
}}
/>
)}
</View>
);
};
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const typography = useScaledTVTypography();
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: watchlists,
isLoading,
isError,
} = useQuery({
queryKey: [
"streamystats",
"promotedWatchlists",
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamystatsApi.getPromotedWatchlists({
jellyfinServerId,
includePreview: false,
});
return response.data || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
if (isLoading) {
return (
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: scaleSize(16),
width: scaleSize(128),
backgroundColor: "#262626",
borderRadius: scaleSize(4),
marginLeft: sizes.padding.horizontal,
marginBottom: scaleSize(20),
}}
/>
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
</View>
);
}
return (
<>
{watchlists?.map((watchlist) => (
<WatchlistSection
key={watchlist.id}
watchlist={watchlist}
jellyfinServerId={jellyfinServerId!}
onItemFocus={onItemFocus}
{...props}
/>
))}
</>
);
};

View File

@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -0,0 +1,278 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
limit?: number;
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
limit = 20,
enabled = true,
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: recommendationIds,
isLoading: isLoadingRecommendations,
isError: isRecommendationsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
type,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<string[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamyStatsApi.getRecommendationIds(
jellyfinServerId,
type,
limit,
);
const data = response as StreamystatsRecommendationsIdsResponse;
if (type === "Movie") {
return data.data.movies || [];
}
return data.data.series || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
const {
data: items,
isLoading: isLoadingItems,
isError: isItemsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
"items",
type,
recommendationIds,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !recommendationIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: recommendationIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
const isLoading = isLoadingRecommendations || isLoadingItems;
const isError = isRecommendationsError || isItemsError;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: sizes.posters.poster + sizes.gaps.item,
offset: (sizes.posters.poster + sizes.gaps.item) * index,
index,
}),
[sizes],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: sizes.gaps.item }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
width={sizes.posters.poster}
/>
</View>
);
},
[sizes, handleItemPress, showItemActions, onItemFocus],
);
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
{title}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: sizes.gaps.item,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: sizes.posters.poster }}>
<View
style={{
backgroundColor: "#262626",
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: sizes.padding.scale,
}}
/>
)}
</View>
);
};

View File

@@ -0,0 +1,672 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Animated,
Dimensions,
Easing,
FlatList,
Platform,
Pressable,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
interface TVHeroCarouselProps {
items: BaseItemDto[];
onItemFocus?: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
}
interface HeroCardProps {
item: BaseItemDto;
isFirst: boolean;
sizes: ScaledTVSizes;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
// Check if glass effect is available (tvOS 26+)
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
const posterUrl = useMemo(() => {
if (!api) return null;
// For episodes, always use series thumb
if (item.Type === "Episode") {
if (item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
}
}
// For non-episodes, use item's own thumb/primary
if (item.ImageTags?.Thumb) {
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
}
if (item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
}
return null;
}, [api, item]);
const animateTo = useCallback(
(value: number) =>
Animated.timing(scale, {
toValue: value,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start(),
[scale],
);
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(sizes.animation.focusScale);
onFocus(item);
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
const handleBlur = useCallback(() => {
setFocused(false);
animateTo(1);
}, [animateTo]);
const handlePress = useCallback(() => {
onPress(item);
}, [onPress, item]);
const handleLongPress = useCallback(() => {
onLongPress?.(item);
}, [onLongPress, item]);
// Use glass poster for tvOS 26+
if (useGlass && posterUrl) {
const progress = item.UserData?.PlayedPercentage || 0;
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }}
>
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
width={sizes.posters.episode}
style={{ width: sizes.posters.episode }}
/>
</Pressable>
);
}
// Fallback for non-tvOS or older tvOS
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: sizes.gaps.item }}
>
<Animated.View
style={{
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: scaleSize(24),
overflow: "hidden",
transform: [{ scale }],
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? scaleSize(20) : 0,
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "rgba(255,255,255,0.1)",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='film-outline'
size={scaleSize(48)}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
<ProgressBar item={item} />
</Animated.View>
</Pressable>
);
},
);
// Debounce delay to prevent rapid backdrop changes when navigating fast
const BACKDROP_DEBOUNCE_MS = 300;
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
items,
onItemFocus,
onItemLongPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const router = useRouter();
// Active item for featured display (debounced)
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
items[0] || null,
);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Crossfade animation state
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(null);
const layer0Opacity = useRef(new Animated.Value(0)).current;
const layer1Opacity = useRef(new Animated.Value(0)).current;
// Get backdrop URL for active item
const backdropUrl = useMemo(() => {
if (!activeItem) return null;
return getBackdropUrl({
api,
item: activeItem,
quality: 90,
width: 1920,
});
}, [api, activeItem]);
// Get logo URL for active item
const logoUrl = useMemo(() => {
if (!activeItem) return null;
return getLogoImageUrlById({ api, item: activeItem });
}, [api, activeItem]);
// Crossfade effect for backdrop
useEffect(() => {
if (!backdropUrl) return;
let isCancelled = false;
const performCrossfade = async () => {
try {
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
if (isCancelled) return;
const incomingLayer = activeLayer === 0 ? 1 : 0;
const incomingOpacity =
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
const outgoingOpacity =
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
if (incomingLayer === 0) {
setLayer0Url(backdropUrl);
} else {
setLayer1Url(backdropUrl);
}
await new Promise((resolve) => setTimeout(resolve, 50));
if (isCancelled) return;
Animated.parallel([
Animated.timing(incomingOpacity, {
toValue: 1,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(outgoingOpacity, {
toValue: 0,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!isCancelled) {
setActiveLayer(incomingLayer);
}
});
};
performCrossfade();
return () => {
isCancelled = true;
};
}, [backdropUrl]);
// Handle card focus with debounce
const handleCardFocus = useCallback(
(item: BaseItemDto) => {
// Clear any pending debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer to update active item after debounce delay
debounceTimerRef.current = setTimeout(() => {
setActiveItem(item);
onItemFocus?.(item);
}, BACKDROP_DEBOUNCE_MS);
},
[onItemFocus],
);
// Handle card press - navigate to item
const handleCardPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
// Get metadata for active item
const year = activeItem?.ProductionYear;
const duration = activeItem?.RunTimeTicks
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
: null;
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
// Get display title
const displayTitle = useMemo(() => {
if (!activeItem) return "";
if (activeItem.Type === "Episode") {
return activeItem.SeriesName || activeItem.Name || "";
}
return activeItem.Name || "";
}, [activeItem]);
// Get subtitle for episodes
const episodeSubtitle = useMemo(() => {
if (activeItem?.Type !== "Episode") return null;
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
}, [activeItem]);
// Memoize hero items to prevent re-renders
const heroItems = useMemo(() => items.slice(0, 8), [items]);
// Memoize renderItem for FlatList
const renderHeroCard = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<HeroCard
item={item}
isFirst={index === 0}
sizes={sizes}
onFocus={handleCardFocus}
onPress={handleCardPress}
onLongPress={onItemLongPress}
/>
),
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
);
// Memoize keyExtractor
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
<View
style={{
height: heroHeight + insets.top + tvosTopPadding,
width: "100%",
paddingTop: insets.top + tvosTopPadding,
}}
>
{/* Backdrop layers with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.4)", "transparent"]}
locations={[0, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: "40%",
}}
/>
{/* Horizontal gradient for left side text contrast */}
<LinearGradient
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
locations={[0, 0.5, 0.85]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "70%",
}}
/>
</View>
{/* Content overlay - text elements with padding */}
<View
style={{
position: "absolute",
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
bottom:
scaleSize(40) +
sizes.posters.episode * (9 / 16) +
sizes.gaps.small * 2 +
scaleSize(20),
}}
>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: scaleSize(100),
width: SCREEN_WIDTH * 0.35,
marginBottom: scaleSize(16),
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
{displayTitle}
</Text>
)}
{/* Episode subtitle */}
{episodeSubtitle && (
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.9)",
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
{episodeSubtitle}
</Text>
)}
{/* Description */}
{activeItem?.Overview && (
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.8)",
marginBottom: scaleSize(16),
maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4,
}}
numberOfLines={2}
>
{activeItem.Overview}
</Text>
)}
{/* Metadata badges */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(16),
}}
>
{year && (
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{year}
</Text>
)}
{duration && (
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{duration}
</Text>
)}
{activeItem?.OfficialRating && (
<View
style={{
paddingHorizontal: scaleSize(8),
paddingVertical: scaleSize(2),
borderRadius: scaleSize(4),
borderWidth: scaleSize(1),
borderColor: "rgba(255,255,255,0.5)",
}}
>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{activeItem.OfficialRating}
</Text>
</View>
)}
{hasProgress && (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(6),
}}
>
<View
style={{
width: scaleSize(60),
height: scaleSize(4),
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: scaleSize(2),
overflow: "hidden",
}}
>
<View
style={{
width: `${playedPercent}%`,
height: "100%",
backgroundColor: "#FFFFFF",
borderRadius: 2,
}}
/>
</View>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.8)",
}}
>
{Math.round(playedPercent)}%
</Text>
</View>
)}
</View>
</View>
{/* Thumbnail carousel - edge-to-edge */}
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: scaleSize(40),
}}
>
<FlatList
horizontal
data={heroItems}
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={3}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,141 @@
import React, { useRef, useState } from "react";
import {
Animated,
Easing,
Pressable,
StyleSheet,
TextInput,
type TextInputProps,
} from "react-native";
import { Text } from "@/components/common/Text";
interface TVPinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
value: string;
onChangeText: (text: string) => void;
length?: number;
label?: string;
hasTVPreferredFocus?: boolean;
}
export interface TVPinInputRef {
focus: () => void;
}
const TVPinInputComponent = React.forwardRef<TVPinInputRef, TVPinInputProps>(
(props, ref) => {
const {
value,
onChangeText,
length = 4,
label,
hasTVPreferredFocus,
placeholder,
...rest
} = props;
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
const handleChangeText = (text: string) => {
// Only allow numeric input and limit to length
const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
onChangeText(numericText);
};
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
inputRef.current?.focus();
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.container,
{
transform: [{ scale }],
borderColor: isFocused ? "#6366F1" : "#374151",
},
]}
>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
keyboardType='number-pad'
maxLength={length}
secureTextEntry
placeholder={placeholder || `Enter ${length}-digit PIN`}
placeholderTextColor='#6B7280'
style={styles.input}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
{...rest}
/>
</Animated.View>
</Pressable>
);
},
);
TVPinInputComponent.displayName = "TVPinInput";
export const TVPinInput = TVPinInputComponent;
const styles = StyleSheet.create({
container: {
backgroundColor: "#1F2937",
borderRadius: 12,
borderWidth: 2,
paddingHorizontal: 20,
paddingVertical: 4,
minWidth: 280,
},
label: {
fontSize: 14,
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
marginTop: 8,
},
input: {
fontSize: 24,
color: "#fff",
fontWeight: "500",
textAlign: "center",
height: 56,
letterSpacing: 8,
},
});

View File

@@ -0,0 +1,47 @@
import { sortBy } from "lodash";
import React, { useMemo } from "react";
import { View } from "react-native";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { TVDiscoverSlide } from "./TVDiscoverSlide";
interface TVDiscoverProps {
sliders?: DiscoverSlider[];
}
// Only show movie/TV slides on TV - skip genres, networks, studios for now
const SUPPORTED_SLIDE_TYPES = [
DiscoverSliderType.TRENDING,
DiscoverSliderType.POPULAR_MOVIES,
DiscoverSliderType.UPCOMING_MOVIES,
DiscoverSliderType.POPULAR_TV,
DiscoverSliderType.UPCOMING_TV,
];
export const TVDiscover: React.FC<TVDiscoverProps> = ({ sliders }) => {
const sortedSliders = useMemo(
() =>
sortBy(
(sliders ?? []).filter(
(s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type),
),
"order",
"asc",
),
[sliders],
);
if (!sliders || sortedSliders.length === 0) return null;
return (
<View>
{sortedSliders.map((slide, index) => (
<TVDiscoverSlide
key={slide.id}
slide={slide}
isFirstSlide={index === 0}
/>
))}
</View>
);
};

View File

@@ -0,0 +1,270 @@
import { Ionicons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps {
item: MovieResult | TvResult;
isFirstItem?: boolean;
}
const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
const posterUrl = item.posterPath
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
: null;
const title = getTitle(item);
const year = getYear(item);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const handlePress = () => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
id: String(item.id),
mediaType: item.mediaType,
},
});
};
return (
<Pressable
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem}
>
<Animated.View
style={[
animatedStyle,
{
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#262626",
}}
>
<Ionicons
name='image-outline'
size={40}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
{isInLibrary && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
}}
numberOfLines={2}
>
{title}
</Text>
{year && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{year}
</Text>
)}
</Animated.View>
</Pressable>
);
};
interface TVDiscoverSlideProps {
slide: DiscoverSlider;
isFirstSlide?: boolean;
}
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
slide,
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", "tv", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: Record<string, unknown> = {
page: Number(pageParam),
};
switch (slide.type) {
case DiscoverSliderType.TRENDING:
endpoint = Endpoints.DISCOVER_TRENDING;
break;
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
endpoint = Endpoints.DISCOVER_MOVIES;
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
params = {
...params,
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
};
break;
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
endpoint = Endpoints.DISCOVER_TV;
if (slide.type === DiscoverSliderType.UPCOMING_TV)
params = {
...params,
firstAirDateGte: new Date().toISOString().split("T")[0],
};
break;
}
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!jellyseerrApi,
staleTime: 0,
});
const flatData = useMemo(
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id",
) as (MovieResult | TvResult)[],
[data, isJellyseerrMovieOrTvResult],
);
const slideTitle = t(
`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`,
);
if (!flatData || flatData.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{slideTitle}
</Text>
<FlatList
horizontal
data={flatData}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<TVDiscoverPoster
item={item}
isFirstItem={isFirstSlide && index === 0}
/>
)}
/>
</View>
);
};

View File

@@ -0,0 +1,877 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useLocalSearchParams } from "expo-router";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import { Loader } from "@/components/Loader";
import { JellyserrRatings } from "@/components/Ratings";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type Season from "@/utils/jellyseerr/server/entity/Season";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
// Cast card component
interface TVCastCardProps {
person: {
id: number;
name: string;
character?: string;
profilePath?: string;
};
imageProxy: (path: string, size?: string) => string;
onPress: () => void;
refSetter?: (ref: View | null) => void;
}
const TVCastCard: React.FC<TVCastCardProps> = ({
person,
imageProxy,
onPress,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
const profileUrl = person.profilePath
? imageProxy(person.profilePath, "w185")
: null;
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Animated.View
style={[
animatedStyle,
{
width: 140,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
width: 120,
height: 120,
borderRadius: 60,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{profileUrl ? (
<Image
source={{ uri: profileUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='person' size={48} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={2}
>
{person.name}
</Text>
{person.character && (
<Text
style={{
fontSize: 14,
color: focused
? "rgba(255,255,255,0.7)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 4,
}}
numberOfLines={1}
>
{person.character}
</Text>
)}
</Animated.View>
</Pressable>
);
};
export const TVJellyseerrPage: React.FC = () => {
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
const { showSeasonSelectModal } = useTVSeasonSelectModal();
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
const {
data: details,
isFetching,
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
// Get seasons with status for TV shows
const seasons = useMemo(() => {
if (!details || mediaType !== MediaType.TV) return [];
const tvDetails = details as TvDetails;
const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0,
);
const requestedSeasons =
tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
[];
return (
tvDetails.seasons?.map((season) => ({
...season,
status:
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber,
)?.status ??
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber,
)?.status ??
MediaStatus.UNKNOWN,
})) ?? []
);
}, [details, mediaType]);
const _allSeasonsAvailable = useMemo(
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons],
);
// Check if there are any requestable seasons (status === UNKNOWN)
const hasRequestableSeasons = useMemo(
() =>
seasons.some(
(season) =>
season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
),
[seasons],
);
// Get cast
const cast = useMemo(() => {
return details?.credits?.cast?.slice(0, 10) ?? [];
}, [details]);
// Backdrop URL
const backdropUrl = useMemo(() => {
const path = details?.backdropPath || result.backdropPath;
return path
? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
: null;
}, [details, result.backdropPath, jellyseerrApi]);
// Poster URL
const posterUrl = useMemo(() => {
if (posterSrc) return posterSrc;
const path = details?.posterPath;
return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
}, [posterSrc, details, jellyseerrApi]);
// Handlers
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (_error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (_error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleRequest = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: mediaType!,
onRequested: refetch,
});
return;
}
requestMedia(mediaTitle, body, refetch);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
showRequestModal,
]);
const handleRequestAll = useCallback(() => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: MediaType.TV,
tvdbId: details?.externalIds?.tvdbId,
seasons: seasons
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: MediaType.TV,
onRequested: refetch,
});
return;
}
requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
}, [
details,
result,
seasons,
hasAdvancedRequestPermission,
requestMedia,
mediaTitle,
refetch,
t,
showRequestModal,
]);
const handleOpenSeasonSelectModal = useCallback(() => {
showSeasonSelectModal({
seasons: seasons.filter((s) => s.seasonNumber !== 0),
title: mediaTitle,
mediaId: Number(result.id!),
tvdbId: details?.externalIds?.tvdbId,
hasAdvancedRequestPermission,
onRequested: refetch,
});
}, [
seasons,
mediaTitle,
result,
details,
hasAdvancedRequestPermission,
refetch,
showSeasonSelectModal,
]);
const handlePlay = useCallback(() => {
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
if (!jellyfinMediaId) return;
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params: { id: jellyfinMediaId },
});
}, [details, mediaType, router]);
const handleCastPress = useCallback(
(personId: number) => {
router.push(`/(auth)/jellyseerr/person/${personId}` as any);
},
[router],
);
const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
const requestedByName =
pendingRequest?.requestedBy?.displayName ||
pendingRequest?.requestedBy?.username ||
pendingRequest?.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user");
if (isLoading || isFetching) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: "#000" }}>
{/* Full-screen backdrop */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{backdropUrl ? (
<Image
source={{ uri: backdropUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
transition={300}
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
{/* Bottom gradient */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
{/* Left gradient */}
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Poster + Content */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
backgroundColor: "rgba(255,255,255,0.1)",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='image-outline'
size={48}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
</View>
</View>
{/* Right side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Ratings */}
{details && (
<JellyserrRatings
result={
details as MovieDetails | TvDetails | MovieResult | TvResult
}
/>
)}
{/* Title */}
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginTop: 8,
marginBottom: 12,
}}
numberOfLines={2}
>
{mediaTitle}
</Text>
{/* Year */}
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginBottom: 16,
}}
>
{releaseYear}
</Text>
{/* Genres */}
{details?.genres && details.genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={details.genres.map((g) => g.name)} />
</View>
)}
{/* Overview */}
{(details?.overview || result.overview) && (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text
style={{
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
numberOfLines={4}
>
{details?.overview || result.overview}
</Text>
</View>
</BlurView>
)}
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 24,
}}
>
{hasJellyfinMedia && (
<TVButton
onPress={handlePlay}
hasTVPreferredFocus
variant='primary'
refSetter={setPlayButtonRef}
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{t("common.play")}
</Text>
</TVButton>
)}
{/* Request button - only show for movies, TV series use Request All + season cards */}
{canRequest && mediaType === MediaType.MOVIE && (
<TVButton
onPress={handleRequest}
variant='secondary'
hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
scaleAmount={1.01}
>
<Ionicons
name='add'
size={24}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
)}
{/* Request All button for TV series */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
hasRequestableSeasons && (
<TVButton
onPress={handleRequestAll}
variant='secondary'
hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
>
<View
style={{
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
<Ionicons
name='bag-add'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_all")}
</Text>
</View>
</TVButton>
)}
{/* Request Seasons button for TV series */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
hasRequestableSeasons && (
<TVButton
onPress={handleOpenSeasonSelectModal}
variant='secondary'
>
<View
style={{
height: 40,
flexDirection: "row",
alignItems: "center",
}}
>
<Ionicons
name='list'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_seasons")}
</Text>
</View>
</TVButton>
)}
</View>
{/* Approve/Decline for managers */}
{canManageRequests && pendingRequest && (
<View style={{ marginBottom: 24 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
}}
>
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginLeft: 8,
}}
>
{t("jellyseerr.requested_by", { user: requestedByName })}
</Text>
</View>
<View style={{ flexDirection: "row", gap: 16 }}>
<TVButton onPress={handleApproveRequest} variant='secondary'>
<Ionicons
name='checkmark'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.approve")}
</Text>
</TVButton>
<TVButton onPress={handleDeclineRequest} variant='secondary'>
<Ionicons
name='close'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.decline")}
</Text>
</TVButton>
</View>
</View>
)}
</View>
</View>
{/* Cast section */}
{cast.length > 0 && jellyseerrApi && (
<View style={{ marginTop: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("jellyseerr.cast")}
</Text>
{/* Focus guides for bidirectional navigation - stacked together */}
{/* Downward: action buttons → first cast card */}
{firstCastCardRef && (
<TVFocusGuideView
destinations={[firstCastCardRef]}
style={{
height: 1,
width: SCREEN_WIDTH,
marginLeft: -(insets.left + 80),
}}
/>
)}
{/* Upward: cast → action buttons */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{
height: 1,
width: SCREEN_WIDTH,
marginLeft: -(insets.left + 80),
}}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: 16,
gap: 28,
}}
>
{cast.map((person, index) => (
<TVCastCard
key={person.id}
person={person}
imageProxy={(path, size) =>
jellyseerrApi.imageProxy(path, size || "w185")
}
onPress={() => handleCastPress(person.id)}
refSetter={index === 0 ? setFirstCastCardRef : undefined}
/>
))}
</ScrollView>
</View>
)}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,519 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
BackHandler,
Easing,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { TVRequestOptionRow } from "./TVRequestOptionRow";
import { TVToggleOptionRow } from "./TVToggleOptionRow";
interface TVRequestModalProps {
visible: boolean;
requestBody?: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onClose: () => void;
onRequested: () => void;
}
export const TVRequestModal: React.FC<TVRequestModalProps> = ({
visible,
requestBody,
title,
id,
mediaType,
onClose,
onRequested,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
mediaType,
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Handle back button to close modal
useEffect(() => {
if (!visible) return;
const handleBackPress = () => {
// If a sub-selector is open, close it first
if (activeSelector) {
setActiveSelector(null);
} else {
onClose();
}
return true; // Prevent default back behavior
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [visible, activeSelector, onClose]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
});
const defaultProfile: QualityProfile | undefined = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder | undefined = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(() => {
return (
defaultServiceDetails?.tags.filter((t) =>
defaultServiceDetails?.server.activeTags?.includes(t.id),
) ?? []
);
}, [defaultServiceDetails]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
// Option builders
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
() =>
defaultServiceDetails?.profiles.map((profile) => ({
label: profile.name,
value: profile.id,
selected:
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
})) || [],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
() =>
defaultServiceDetails?.rootFolders.map((folder) => ({
label: pathTitleExtractor(folder),
value: folder.path,
selected:
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
})) || [],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const userOptions: TVOptionItem<number>[] = useMemo(
() =>
users?.map((user) => ({
label: user.displayName,
value: user.id,
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
})) || [],
[users, jellyseerrUser, requestOverrides.userId],
);
const tagItems = useMemo(() => {
return (
defaultServiceDetails?.tags.map((tag) => ({
id: tag.id,
label: tag.label,
selected:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
})) ?? []
);
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
// Selected display values
const selectedProfileName = useMemo(() => {
const profile = defaultServiceDetails?.profiles.find(
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
);
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
}, [
defaultServiceDetails?.profiles,
requestOverrides.profileId,
defaultProfile,
t,
]);
const selectedFolderName = useMemo(() => {
const folder = defaultServiceDetails?.rootFolders.find(
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
);
return folder
? pathTitleExtractor(folder)
: defaultFolder
? pathTitleExtractor(defaultFolder)
: t("jellyseerr.select");
}, [
defaultServiceDetails?.rootFolders,
requestOverrides.rootFolder,
defaultFolder,
t,
]);
const selectedUserName = useMemo(() => {
const user = users?.find(
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
);
return (
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
);
}, [users, requestOverrides.userId, jellyseerrUser, t]);
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
(tagId: number) => {
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tagId);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tagId)
: [...currentTags, tagId],
};
});
},
[defaultTags],
);
const handleRequest = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...requestBody,
...requestOverrides,
};
const seasonTitle =
requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: requestBody.seasons[0],
})
: requestBody?.seasons && requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
body,
onRequested,
);
}, [
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
title,
requestMedia,
onRequested,
t,
]);
if (!visible) return null;
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<>
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("jellyseerr.advanced")}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
}}
>
{title}
</Text>
{isDataLoaded ? (
<ScrollView
style={{ maxHeight: 320, overflow: "visible" }}
showsVerticalScrollIndicator={false}
>
<View
style={{
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
}}
>
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
<TVToggleOptionRow
label={t("jellyseerr.tags")}
items={tagItems}
onToggle={handleTagToggle}
/>
)}
</View>
</ScrollView>
) : (
<View
style={{
height: 200,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ color: "rgba(255,255,255,0.5)" }}>
{t("common.loading")}
</Text>
</View>
)}
<View style={{ marginTop: 24 }}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</>
);
};

View File

@@ -0,0 +1,86 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVRequestOptionRowProps {
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
label,
value,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.02,
});
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
borderRadius: 10,
borderWidth: 1,
borderColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
}}
>
{label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text
style={{
fontSize: typography.body,
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
<Ionicons
name='chevron-forward'
size={18}
color={focused ? "#FFFFFF" : "rgba(255,255,255,0.5)"}
/>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { Animated, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface ToggleItem {
id: number;
label: string;
selected: boolean;
}
interface TVToggleChipProps {
item: ToggleItem;
onToggle: (id: number) => void;
disabled?: boolean;
}
const TVToggleChip: React.FC<TVToggleChipProps> = ({
item,
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.08,
});
return (
<Pressable
onPress={() => onToggle(item.id)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: focused
? "#fff"
: item.selected
? "rgba(255,255,255,0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 20,
borderWidth: 1,
borderColor: focused
? "#fff"
: item.selected
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.15)",
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: item.selected || focused ? "600" : "400",
}}
>
{item.label}
</Text>
</Animated.View>
</Pressable>
);
};
interface TVToggleOptionRowProps {
label: string;
items: ToggleItem[];
onToggle: (id: number) => void;
disabled?: boolean;
}
export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
label,
items,
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
if (items.length === 0) return null;
return (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 10,
}}
>
{label}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ gap: 10, paddingVertical: 12 }}
>
{items.map((item) => (
<TVToggleChip
key={item.id}
item={item}
onToggle={onToggle}
disabled={disabled}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,4 @@
export { TVJellyseerrPage } from "./TVJellyseerrPage";
export { TVRequestModal } from "./TVRequestModal";
export { TVRequestOptionRow } from "./TVRequestOptionRow";
export { TVToggleOptionRow } from "./TVToggleOptionRow";

View File

@@ -0,0 +1,109 @@
import {
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export const Libraries: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const { settings } = useSettings();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
staleTime: 60,
});
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
queryKey: ["library", item.Id],
queryFn: async () => {
if (!item.Id || !user?.Id || !api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
userId: user?.Id,
});
return response.data;
},
staleTime: 60 * 1000,
});
}
}, [data, api, queryClient, user?.Id]);
const insets = useSafeAreaInsets();
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className='h-4' />
)
}
/>
);
};

View File

@@ -0,0 +1,406 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 80;
const CARD_HEIGHT = 220;
const CARD_GAP = 24;
const SCALE_PADDING = 20;
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
interface LibraryWithPreview extends BaseItemDto {
previewItems?: BaseItemDto[];
itemCount?: number;
}
const TVLibraryRow: React.FC<{
library: LibraryWithPreview;
isFirst: boolean;
onPress: () => void;
}> = ({ library, isFirst, onPress }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.7)).current;
const animateTo = (toScale: number, toOpacity: number) => {
Animated.parallel([
Animated.timing(scale, {
toValue: toScale,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: toOpacity,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const backdropUrl = useMemo(() => {
// Try to get backdrop from library or first preview item
if (library.previewItems?.[0]) {
return getBackdropUrl({
api,
item: library.previewItems[0],
width: 1920,
});
}
return getBackdropUrl({
api,
item: library,
width: 1920,
});
}, [api, library]);
const iconName = icons[library.CollectionType!] || "folder";
const itemTypeName = useMemo(() => {
if (library.CollectionType === "movies")
return t("library.item_types.movies");
if (library.CollectionType === "tvshows")
return t("library.item_types.series");
if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets");
if (library.CollectionType === "playlists")
return t("library.item_types.playlists");
if (library.CollectionType === "music")
return t("library.item_types.items");
return t("library.item_types.items");
}, [library.CollectionType, t]);
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02, 1);
}}
onBlur={() => {
setFocused(false);
animateTo(1, 0.7);
}}
hasTVPreferredFocus={isFirst}
>
<Animated.View
style={{
transform: [{ scale }],
opacity,
height: CARD_HEIGHT,
borderRadius: 20,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: focused ? 4 : 0,
borderColor: "#FFFFFF",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 30 : 0,
}}
>
{/* Background Image */}
{backdropUrl && (
<Image
source={{ uri: backdropUrl }}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
contentFit='cover'
cachePolicy='memory-disk'
/>
)}
{/* Gradient Overlay */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
/>
{/* Content */}
<View
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 40,
}}
>
{/* Icon Container */}
<BlurView
intensity={60}
tint='dark'
style={{
width: 80,
height: 80,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
<Ionicons name={iconName} size={40} color='#FFFFFF' />
</BlurView>
{/* Text Content */}
<View style={{ marginLeft: 24, flex: 1 }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
}}
>
{library.Name}
</Text>
{library.itemCount !== undefined && (
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginTop: 4,
textShadowColor: "rgba(0,0,0,0.8)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}
>
{library.itemCount} {itemTypeName}
</Text>
)}
</View>
{/* Arrow Indicator */}
<Animated.View
style={{
opacity: focused ? 1 : 0.5,
}}
>
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
</Animated.View>
</View>
</Animated.View>
</Pressable>
);
};
export const TVLibraries: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { data: userViews, isLoading: viewsLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || [];
},
staleTime: 60 * 1000,
enabled: !!api && !!user?.Id,
});
const libraries = useMemo(
() =>
userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books")
.filter((l) => l.CollectionType !== "music") || [],
[userViews, settings?.hiddenLibraries],
);
// Fetch item counts and preview items for each library
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
queryFn: async () => {
const results: LibraryWithPreview[] = await Promise.all(
libraries.map(async (library) => {
let itemType: string | undefined;
if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
else if (library.CollectionType === "playlists")
itemType = "Playlist";
const isPlaylistsLib = library.CollectionType === "playlists";
// Fetch count
const countResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined,
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
// Fetch preview items with backdrops
const previewResponse = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 1,
sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"],
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
return {
...library,
itemCount: countResponse.data.TotalRecordCount,
previewItems: previewResponse.data.Items || [],
};
}),
);
return results;
},
enabled: !!api && !!user?.Id && libraries.length > 0,
staleTime: 60 * 1000,
});
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`,
params: { libraryId: library.Id! },
});
} else {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: library.Id! },
});
}
},
[router],
);
const renderItem = useCallback(
({ item, index }: { item: LibraryWithPreview; index: number }) => (
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
<TVLibraryRow
library={item}
isFirst={index === 0}
onPress={() => handleLibraryPress(item)}
/>
</View>
),
[handleLibraryPress],
);
const isLoading = viewsLoading || dataLoading;
const displayLibraries = librariesWithData || libraries;
if (isLoading && libraries.length === 0) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
if (!displayLibraries || displayLibraries.length === 0) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_libraries_found")}
</Text>
</View>
);
}
return (
<View
style={{
flex: 1,
paddingTop: insets.top + 80,
paddingBottom: insets.bottom + 40,
}}
>
<FlatList
data={displayLibraries}
keyExtractor={(item) => item.Id || ""}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
contentContainerStyle={{
paddingBottom: 40,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingVertical: SCALE_PADDING,
}}
/>
</View>
);
};

View File

@@ -0,0 +1,176 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_LIBRARY_CARD_WIDTH = 280;
export const TV_LIBRARY_CARD_HEIGHT = 180;
interface Props {
library: BaseItemDto;
}
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
movies: "film",
tvshows: "tv",
music: "musical-notes",
books: "book",
homevideos: "videocam",
boxsets: "albums",
playlists: "list",
folders: "folder",
livetv: "tv",
musicvideos: "musical-notes",
photos: "images",
trailers: "videocam",
unknown: "help-circle",
} as const;
export const TVLibraryCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: library,
}),
[api, library],
);
const itemType = useMemo(() => {
let _itemType: BaseItemKind | undefined;
if (library.CollectionType === "movies") {
_itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
} else if (library.CollectionType === "homevideos") {
_itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
_itemType = "MusicVideo";
}
return _itemType;
}, [library.CollectionType]);
const itemTypeName = useMemo(() => {
let nameStr: string;
if (library.CollectionType === "movies") {
nameStr = t("library.item_types.movies");
} else if (library.CollectionType === "tvshows") {
nameStr = t("library.item_types.series");
} else if (library.CollectionType === "boxsets") {
nameStr = t("library.item_types.boxsets");
} else {
nameStr = t("library.item_types.items");
}
return nameStr;
}, [library.CollectionType, t]);
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data.TotalRecordCount;
},
enabled: !!api && !!user?.Id && !!library.Id,
});
const iconName = icons[library.CollectionType!] || "folder";
return (
<View
style={{
width: TV_LIBRARY_CARD_WIDTH,
height: TV_LIBRARY_CARD_HEIGHT,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: 1,
borderColor: "#333",
}}
>
{url && (
<Image
source={{ uri: url }}
style={{
position: "absolute",
width: "100%",
height: "100%",
}}
cachePolicy='memory-disk'
/>
)}
<View
style={{
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: url ? "rgba(0, 0, 0, 0.6)" : "transparent",
}}
/>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
>
<Ionicons name={iconName} size={48} color='#e5e5e5' />
<Text
numberOfLines={1}
style={{
fontSize: typography.body,
fontWeight: "600",
color: "#FFFFFF",
marginTop: 12,
textAlign: "center",
}}
>
{library.Name}
</Text>
{itemsCount !== undefined && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}
>
{itemsCount} {itemTypeName}
</Text>
)}
</View>
</View>
);
};

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_HEIGHT, CARD_WIDTH };

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

@@ -0,0 +1,146 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { TVGuideProgramCell } from "./TVGuideProgramCell";
interface TVGuideChannelRowProps {
programs: BaseItemDto[];
baseTime: Date;
pixelsPerHour: number;
minProgramWidth: number;
hoursToShow: number;
onProgramPress: (program: BaseItemDto) => void;
disabled?: boolean;
firstProgramRefSetter?: (ref: View | null) => void;
}
export const TVGuideChannelRow: React.FC<TVGuideChannelRowProps> = ({
programs,
baseTime,
pixelsPerHour,
minProgramWidth,
hoursToShow,
onProgramPress,
disabled = false,
firstProgramRefSetter,
}) => {
const isCurrentlyAiring = (program: BaseItemDto): boolean => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
const getTimeOffset = (startDate: string): number => {
const start = new Date(startDate);
const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000;
return Math.max(0, (diffMinutes / 60) * pixelsPerHour);
};
// Filter programs for this channel and within the time window
const filteredPrograms = useMemo(() => {
const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000);
return programs
.filter((p) => {
if (!p.StartDate || !p.EndDate) return false;
const start = new Date(p.StartDate);
const end = new Date(p.EndDate);
// Program overlaps with our time window
return end > baseTime && start < endTime;
})
.sort((a, b) => {
const dateA = new Date(a.StartDate || 0);
const dateB = new Date(b.StartDate || 0);
return dateA.getTime() - dateB.getTime();
});
}, [programs, baseTime, hoursToShow]);
// Calculate program cells with positions (absolute positioning)
const programCells = useMemo(() => {
return filteredPrograms.map((program) => {
if (!program.StartDate || !program.EndDate) {
return { program, width: minProgramWidth, left: 0 };
}
// Clamp the start time to baseTime if program started earlier
const programStart = new Date(program.StartDate);
const effectiveStart = programStart < baseTime ? baseTime : programStart;
// Clamp the end time to the window end
const windowEnd = new Date(
baseTime.getTime() + hoursToShow * 60 * 60 * 1000,
);
const programEnd = new Date(program.EndDate);
const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd;
const durationMinutes =
(effectiveEnd.getTime() - effectiveStart.getTime()) / 60000;
const width = Math.max(
(durationMinutes / 60) * pixelsPerHour - 4,
minProgramWidth,
); // -4 for gap
const left = getTimeOffset(effectiveStart.toISOString());
return {
program,
width,
left,
};
});
}, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]);
const totalWidth = hoursToShow * pixelsPerHour;
return (
<View style={[styles.container, { width: totalWidth }]}>
{programCells.map(({ program, width, left }, index) => (
<View
key={program.Id || index}
style={[styles.programCellWrapper, { left, width }]}
>
<TVGuideProgramCell
program={program}
width={width}
isCurrentlyAiring={isCurrentlyAiring(program)}
onPress={() => onProgramPress(program)}
disabled={disabled}
refSetter={index === 0 ? firstProgramRefSetter : undefined}
/>
</View>
))}
{/* Empty state */}
{programCells.length === 0 && (
<View style={[styles.noPrograms, { width: totalWidth - 8 }]}>
{/* Empty row indicator */}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
height: 80,
position: "relative",
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
backgroundColor: "rgba(20, 20, 20, 1)",
},
programCellWrapper: {
position: "absolute",
top: 4,
bottom: 4,
},
noPrograms: {
position: "absolute",
left: 4,
top: 4,
bottom: 4,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
},
});

View File

@@ -0,0 +1,154 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuidePageNavigationProps {
currentPage: number;
totalPages: number;
onPrevious: () => void;
onNext: () => void;
disabled?: boolean;
prevButtonRefSetter?: (ref: View | null) => void;
}
interface NavButtonProps {
onPress: () => void;
icon: keyof typeof Ionicons.glyphMap;
label: string;
isDisabled: boolean;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
const NavButton: React.FC<NavButtonProps> = ({
onPress,
icon,
label,
isDisabled,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const visuallyDisabled = isDisabled || disabled;
const handlePress = () => {
if (!visuallyDisabled) {
onPress();
}
};
return (
<Pressable
ref={refSetter}
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
focusable={!disabled}
>
<Animated.View
style={[
styles.navButton,
animatedStyle,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
opacity: visuallyDisabled ? 0.3 : 1,
},
]}
>
<Ionicons
name={icon}
size={20}
color={focused ? "#000000" : "#FFFFFF"}
/>
<Text
style={[
styles.navButtonText,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVGuidePageNavigation: React.FC<TVGuidePageNavigationProps> = ({
currentPage,
totalPages,
onPrevious,
onNext,
disabled = false,
prevButtonRefSetter,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
return (
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<NavButton
onPress={onPrevious}
icon='chevron-back'
label={t("live_tv.previous")}
isDisabled={currentPage <= 1}
disabled={disabled}
refSetter={prevButtonRefSetter}
/>
<NavButton
onPress={onNext}
icon='chevron-forward'
label={t("live_tv.next")}
isDisabled={currentPage >= totalPages}
disabled={disabled}
/>
</View>
<Text style={[styles.pageText, { fontSize: typography.callout }]}>
{t("live_tv.page_of", { current: currentPage, total: totalPages })}
</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 16,
},
buttonsContainer: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
navButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
navButtonText: {
fontWeight: "600",
},
pageText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -0,0 +1,148 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideProgramCellProps {
program: BaseItemDto;
width: number;
isCurrentlyAiring: boolean;
onPress: () => void;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
program,
width,
isCurrentlyAiring,
onPress,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1,
duration: 120,
});
const formatTime = (date: string | null | undefined) => {
if (!date) return "";
const d = new Date(date);
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
style={{ width }}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused
? "#FFFFFF"
: isCurrentlyAiring
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
focused && styles.focusedShadow,
]}
>
{/* LIVE badge */}
{isCurrentlyAiring && (
<View style={styles.liveBadge}>
<Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
>
LIVE
</Text>
</View>
)}
{/* Program name */}
<Text
numberOfLines={2}
style={[
styles.programName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{program.Name}
</Text>
{/* Time range */}
<Text
numberOfLines={1}
style={[
styles.timeText,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
{formatTime(program.StartDate)} - {formatTime(program.EndDate)}
</Text>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
height: 70,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
justifyContent: "center",
overflow: "hidden",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
liveBadge: {
position: "absolute",
top: 6,
right: 6,
backgroundColor: "#EF4444",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
zIndex: 10,
elevation: 10,
},
liveBadgeText: {
color: "#FFFFFF",
fontWeight: "bold",
},
programName: {
fontWeight: "600",
marginBottom: 4,
},
timeText: {
fontWeight: "400",
},
});

View File

@@ -0,0 +1,64 @@
import { BlurView } from "expo-blur";
import React from "react";
import { StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideTimeHeaderProps {
baseTime: Date;
hoursToShow: number;
pixelsPerHour: number;
}
export const TVGuideTimeHeader: React.FC<TVGuideTimeHeaderProps> = ({
baseTime,
hoursToShow,
pixelsPerHour,
}) => {
const typography = useScaledTVTypography();
const hours: Date[] = [];
for (let i = 0; i < hoursToShow; i++) {
const hour = new Date(baseTime);
hour.setMinutes(0, 0, 0);
hour.setHours(baseTime.getHours() + i);
hours.push(hour);
}
const formatHour = (date: Date) => {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<BlurView intensity={40} tint='dark' style={styles.container}>
{hours.map((hour, index) => (
<View key={index} style={[styles.hourCell, { width: pixelsPerHour }]}>
<Text style={[styles.hourText, { fontSize: typography.callout }]}>
{formatHour(hour)}
</Text>
</View>
))}
</BlurView>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
height: 44,
},
hourCell: {
justifyContent: "center",
paddingLeft: 12,
borderLeftWidth: 1,
borderLeftColor: "rgba(255, 255, 255, 0.1)",
},
hourText: {
color: "rgba(255, 255, 255, 0.6)",
fontWeight: "500",
},
});

View File

@@ -0,0 +1,433 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
StyleSheet,
TVFocusGuideView,
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 { TVGuideChannelRow } from "./TVGuideChannelRow";
import { TVGuidePageNavigation } from "./TVGuidePageNavigation";
import { TVGuideTimeHeader } from "./TVGuideTimeHeader";
// Design constants
const CHANNEL_COLUMN_WIDTH = 240;
const PIXELS_PER_HOUR = 250;
const ROW_HEIGHT = 80;
const TIME_HEADER_HEIGHT = 44;
const CHANNELS_PER_PAGE = 20;
const MIN_PROGRAM_WIDTH = 80;
const HORIZONTAL_PADDING = 60;
// Channel label component
const ChannelLabel: React.FC<{
channel: BaseItemDto;
typography: ReturnType<typeof useScaledTVTypography>;
}> = ({ channel, typography }) => (
<View style={styles.channelLabel}>
<Text
numberOfLines={1}
style={[styles.channelNumber, { fontSize: typography.callout }]}
>
{channel.ChannelNumber}
</Text>
<Text
numberOfLines={1}
style={[styles.channelName, { fontSize: typography.callout }]}
>
{channel.Name}
</Text>
</View>
);
export const TVLiveTVGuide: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [currentPage, setCurrentPage] = useState(1);
// Scroll refs for synchronization
const channelListRef = useRef<ScrollView>(null);
const mainVerticalRef = useRef<ScrollView>(null);
// Focus guide refs for bidirectional navigation
const [firstProgramRef, setFirstProgramRef] = useState<View | null>(null);
const [prevButtonRef, setPrevButtonRef] = useState<View | null>(null);
// Base time - start of current hour, end time - end of day
const [{ baseTime, endOfDay, hoursToShow }] = useState(() => {
const now = new Date();
now.setMinutes(0, 0, 0);
const endOfDayTime = new Date(now);
endOfDayTime.setHours(23, 59, 59, 999);
const hoursUntilEndOfDay = Math.ceil(
(endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000),
);
return {
baseTime: now,
endOfDay: endOfDayTime,
hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour
};
});
// Current time indicator position (relative to program grid start)
const [currentTimeOffset, setCurrentTimeOffset] = useState(0);
// Update current time indicator every minute
useEffect(() => {
const updateCurrentTime = () => {
const now = new Date();
const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000;
const offset = (diffMinutes / 60) * PIXELS_PER_HOUR;
setCurrentTimeOffset(offset);
};
updateCurrentTime();
const interval = setInterval(updateCurrentTime, 60000);
return () => clearInterval(interval);
}, [baseTime]);
// Sync vertical scroll between channel list and main grid
const handleVerticalScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
channelListRef.current?.scrollTo({ y: offsetY, animated: false });
},
[],
);
// Fetch channels
const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
queryKey: ["livetv", "tv-guide", "channels"],
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 totalChannels = channelsData?.TotalRecordCount ?? 0;
const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE);
const allChannels = channelsData?.Items ?? [];
// Get channels for current page
const paginatedChannels = useMemo(() => {
const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE;
return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE);
}, [allChannels, currentPage]);
const channelIds = useMemo(
() => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[],
[paginatedChannels],
);
// Fetch programs for visible channels
const { data: programsData } = useQuery({
queryKey: [
"livetv",
"tv-guide",
"programs",
channelIds,
baseTime.toISOString(),
endOfDay.toISOString(),
],
queryFn: async () => {
if (!api || channelIds.length === 0) return null;
const res = await getLiveTvApi(api).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: baseTime.toISOString(),
ChannelIds: channelIds,
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: channelIds.length > 0,
staleTime: 2 * 60 * 1000, // 2 minutes
});
const programs = programsData?.Items ?? [];
// Group programs by channel
const programsByChannel = useMemo(() => {
const grouped: Record<string, BaseItemDto[]> = {};
for (const program of programs) {
const channelId = program.ChannelId;
if (channelId) {
if (!grouped[channelId]) {
grouped[channelId] = [];
}
grouped[channelId].push(program);
}
}
return grouped;
}, [programs]);
const handleProgramPress = useCallback(
(program: BaseItemDto) => {
// Navigate to play the program/channel
const queryParams = new URLSearchParams({
itemId: program.Id ?? "",
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[router],
);
const handlePreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage((p) => p - 1);
}
}, [currentPage]);
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage((p) => p + 1);
}
}, [currentPage, totalPages]);
const isLoading = isLoadingChannels;
const totalWidth = hoursToShow * PIXELS_PER_HOUR;
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (paginatedChannels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_programs")}
</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Page Navigation */}
{totalPages > 1 && (
<View style={{ paddingHorizontal: insets.left + HORIZONTAL_PADDING }}>
<TVGuidePageNavigation
currentPage={currentPage}
totalPages={totalPages}
onPrevious={handlePreviousPage}
onNext={handleNextPage}
prevButtonRefSetter={setPrevButtonRef}
/>
</View>
)}
{/* Bidirectional focus guides */}
{firstProgramRef && (
<TVFocusGuideView
destinations={[firstProgramRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{prevButtonRef && (
<TVFocusGuideView
destinations={[prevButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Main grid container */}
<View style={styles.gridWrapper}>
{/* Fixed channel column */}
<View
style={[
styles.channelColumn,
{
width: CHANNEL_COLUMN_WIDTH,
marginLeft: insets.left + HORIZONTAL_PADDING,
},
]}
>
{/* Spacer for time header */}
<View style={{ height: TIME_HEADER_HEIGHT }} />
{/* Channel labels - synced with main scroll */}
<ScrollView
ref={channelListRef}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => (
<ChannelLabel
key={channel.Id ?? index}
channel={channel}
typography={typography}
/>
))}
</ScrollView>
</View>
{/* Scrollable programs area */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.horizontalScroll}
contentContainerStyle={{
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ width: totalWidth, position: "relative" }}>
{/* Time header */}
<TVGuideTimeHeader
baseTime={baseTime}
hoursToShow={hoursToShow}
pixelsPerHour={PIXELS_PER_HOUR}
/>
{/* Programs grid - vertical scroll */}
<ScrollView
ref={mainVerticalRef}
onScroll={handleVerticalScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => {
const channelPrograms = channel.Id
? (programsByChannel[channel.Id] ?? [])
: [];
return (
<TVGuideChannelRow
key={channel.Id ?? index}
programs={channelPrograms}
baseTime={baseTime}
pixelsPerHour={PIXELS_PER_HOUR}
minProgramWidth={MIN_PROGRAM_WIDTH}
hoursToShow={hoursToShow}
onProgramPress={handleProgramPress}
firstProgramRefSetter={
index === 0 ? setFirstProgramRef : undefined
}
/>
);
})}
</ScrollView>
{/* Current time indicator */}
{currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
<View
style={[
styles.currentTimeIndicator,
{
left: currentTimeOffset,
top: 0,
height:
TIME_HEADER_HEIGHT +
paginatedChannels.length * ROW_HEIGHT,
},
]}
/>
)}
</View>
</ScrollView>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
gridWrapper: {
flex: 1,
flexDirection: "row",
},
channelColumn: {
backgroundColor: "rgba(40, 40, 40, 1)",
borderRightWidth: 1,
borderRightColor: "rgba(255, 255, 255, 0.2)",
},
channelLabel: {
height: ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
},
channelNumber: {
color: "rgba(255, 255, 255, 0.5)",
fontWeight: "400",
marginBottom: 2,
},
channelName: {
color: "#FFFFFF",
fontWeight: "600",
},
horizontalScroll: {
flex: 1,
},
currentTimeIndicator: {
position: "absolute",
width: 2,
backgroundColor: "#EF4444",
zIndex: 10,
pointerEvents: "none",
},
});

View File

@@ -0,0 +1,265 @@
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 { 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";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
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();
}
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 || "")} />;
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Header with Title and Tabs */}
<View
style={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: 24,
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 24,
}}
>
Live TV
</Text>
{/* Tab Bar */}
<View
style={{
flexDirection: "row",
gap: 8,
}}
>
{TABS.map((tab) => (
<TVTabButton
key={tab.id}
label={t(tab.labelKey)}
active={activeTab === tab.id}
onSelect={() => handleTabSelect(tab.id)}
hasTVPreferredFocus={activeTab === tab.id}
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>
);
};

458
components/login/Login.tsx Normal file
View File

@@ -0,0 +1,458 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
});
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// Handle URL params for server connection
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
}
})();
}, [_apiUrl]);
// Handle auto-login when api is ready and credentials are provided via URL params
useEffect(() => {
if (api?.basePath && _apiUrl && _username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, [api?.basePath, _apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{
mode: "cors",
},
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
} catch {}
}, []);
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
return (
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 justify-center'>
<View className='px-4 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};

View File

@@ -0,0 +1,155 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps {
account: SavedServerAccount;
onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean;
}
export const TVAccountCard: React.FC<TVAccountCardProps> = ({
account,
onPress,
onLongPress,
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const glowOpacity = useRef(new Animated.Value(0)).current;
const animateFocus = (focused: boolean) => {
Animated.parallel([
Animated.timing(scale, {
toValue: focused ? 1.03 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(glowOpacity, {
toValue: focused ? 0.6 : 0,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
switch (account.securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const getSecurityText = (): string => {
switch (account.securityType) {
case "pin":
return t("save_account.pin_code");
case "password":
return t("save_account.password");
default:
return t("save_account.no_protection");
}
};
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: scaleSize(16),
paddingHorizontal: scaleSize(24),
paddingVertical: scaleSize(20),
flexDirection: "row",
alignItems: "center",
}}
>
{/* Avatar */}
<View
style={{
width: scaleSize(56),
height: scaleSize(56),
backgroundColor: "#404040",
borderRadius: scaleSize(28),
alignItems: "center",
justifyContent: "center",
marginRight: scaleSize(20),
}}
>
<Ionicons name='person' size={scaleSize(28)} color='white' />
</View>
{/* Account Info */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: scaleSize(22),
fontWeight: "600",
color: "#FFFFFF",
}}
>
{account.username}
</Text>
<Text
style={{
fontSize: scaleSize(16),
color: "#9CA3AF",
marginTop: scaleSize(4),
}}
>
{getSecurityText()}
</Text>
</View>
{/* Security Icon */}
<Ionicons
name={getSecurityIcon()}
size={scaleSize(24)}
color='#fff'
/>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,83 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVAddIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVAddIcon = React.forwardRef<View, TVAddIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(16) : 0,
},
]}
>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: scaleSize(14),
borderWidth: scaleSize(2),
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='add'
size={scaleSize(56)}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,123 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
import { TVInput } from "./TVInput";
interface TVAddServerFormProps {
onConnect: (url: string) => Promise<void>;
onStartPairing?: () => void;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
onConnect,
onStartPairing,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [serverURL, setServerURL] = useState("");
const handleConnect = async () => {
if (serverURL.trim()) {
await onConnect(serverURL.trim());
}
};
const isDisabled = disabled || loading;
const handleBack = useCallback(() => {
if (isDisabled) return false;
onBack();
return true;
}, [isDisabled, onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: scaleSize(60),
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "left",
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Server URL Input */}
<View
style={{
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
onChangeText={setServerURL}
keyboardType='url'
autoCapitalize='none'
textContentType='URL'
returnKeyType='done'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Connect Button */}
<View style={{ marginBottom: scaleSize(24) }}>
<Button
onPress={handleConnect}
loading={loading}
disabled={loading || !serverURL.trim()}
color='white'
>
{t("server.connect_button")}
</Button>
</View>
{/* Pair with Phone */}
{onStartPairing && (
<View>
<Button
onPress={onStartPairing}
className='bg-neutral-800 border border-neutral-700'
>
{t("pairing.pair_with_phone")}
</Button>
</View>
)}
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,185 @@
import { t } from "i18next";
import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
import { TVInput } from "./TVInput";
import { TVSaveAccountToggle } from "./TVSaveAccountToggle";
interface TVAddUserFormProps {
serverName: string;
serverAddress: string;
onLogin: (
username: string,
password: string,
saveAccount: boolean,
) => Promise<void>;
onQuickConnect: () => Promise<void>;
onBack: () => void;
loading?: boolean;
disabled?: boolean;
}
export const TVAddUserForm: React.FC<TVAddUserFormProps> = ({
serverName,
serverAddress,
onLogin,
onQuickConnect,
onBack,
loading = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const [credentials, setCredentials] = useState({
username: "",
password: "",
});
const [saveAccount, setSaveAccount] = useState(false);
const handleLogin = async () => {
if (credentials.username.trim()) {
await onLogin(credentials.username, credentials.password, saveAccount);
}
};
const isDisabled = disabled || loading;
const handleBack = useCallback(() => {
if (isDisabled) return false;
onBack();
return true;
}, [isDisabled, onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: scaleSize(60),
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(8),
}}
>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text style={{ color: "#fff" }}>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: scaleSize(40),
}}
>
{serverAddress}
</Text>
{/* Username Input */}
<View
style={{
marginBottom: scaleSize(24),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
returnKeyType='next'
hasTVPreferredFocus
disabled={isDisabled}
/>
</View>
{/* Password Input */}
<View
style={{
marginBottom: scaleSize(32),
paddingHorizontal: scaleSize(8),
}}
>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
secureTextEntry
autoCapitalize='none'
textContentType='password'
returnKeyType='done'
disabled={isDisabled}
/>
</View>
{/* Save Account Toggle */}
<View
style={{
marginBottom: scaleSize(40),
paddingHorizontal: scaleSize(8),
}}
>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
label={t("save_account.save_for_later")}
disabled={isDisabled}
/>
</View>
{/* Login Button */}
<View style={{ marginBottom: scaleSize(16) }}>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim() || loading}
color='white'
>
{t("login.login_button")}
</Button>
</View>
{/* Quick Connect Button */}
<Button
onPress={onQuickConnect}
color='black'
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,83 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVBackIconProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVBackIcon = React.forwardRef<View, TVBackIconProps>(
({ label, onPress, hasTVPreferredFocus, disabled = false }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(16) : 0,
},
]}
>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
marginBottom: scaleSize(14),
borderWidth: scaleSize(2),
borderColor: focused ? "#fff" : "rgba(255,255,255,0.3)",
borderStyle: "dashed",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='arrow-back'
size={scaleSize(56)}
color={focused ? "#fff" : "rgba(255,255,255,0.5)"}
/>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.7)",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,91 @@
import React, { useRef, useState } from "react";
import {
Animated,
Easing,
Pressable,
TextInput,
type TextInputProps,
} from "react-native";
import { scaleSize } from "@/utils/scaleSize";
interface TVInputProps extends TextInputProps {
label?: string;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVInput: React.FC<TVInputProps> = ({
label,
placeholder,
hasTVPreferredFocus,
disabled = false,
style,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const scale = useRef(new Animated.Value(1)).current;
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 200,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
const displayPlaceholder = placeholder || label;
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
borderRadius: scaleSize(12),
backgroundColor: isFocused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
}}
>
<TextInput
ref={inputRef}
placeholder={displayPlaceholder}
placeholderTextColor='rgba(255,255,255,0.35)'
allowFontScaling={false}
style={[
{
height: scaleSize(64),
fontSize: scaleSize(22),
color: "#FFFFFF",
paddingHorizontal: scaleSize(20),
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,809 @@
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { useTVMenuKeyInterception } from "@/hooks/useTVBackPress";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { selectedTVServerAtom } from "@/utils/atoms/selectedTVServer";
import { storage } from "@/utils/mmkv";
import {
generatePairingCode,
type PairingCredentials,
startPairingListener,
} from "@/utils/pairingService";
import { scaleSize } from "@/utils/scaleSize";
import {
type AccountSecurityType,
getPreviousServers,
hashPIN,
removeServerFromList,
type SavedServer,
type SavedServerAccount,
saveAccountCredential,
} from "@/utils/secureCredentials";
import { TVAddServerForm } from "./TVAddServerForm";
import { TVAddUserForm } from "./TVAddUserForm";
import { TVPasswordEntryModal } from "./TVPasswordEntryModal";
import { TVPINEntryModal } from "./TVPINEntryModal";
import { TVQRCodeDisplay } from "./TVQRCodeDisplay";
import { TVSaveAccountModal } from "./TVSaveAccountModal";
import { TVServerSelectionScreen } from "./TVServerSelectionScreen";
import { TVUserSelectionScreen } from "./TVUserSelectionScreen";
type TVLoginScreen =
| "server-selection"
| "qr-code-display"
| "loading"
| "user-selection"
| "add-server"
| "add-user";
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
// Selected server persistence
const [selectedTVServer, setSelectedTVServer] = useAtom(selectedTVServerAtom);
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
// Get current servers list
const previousServers = useMemo(() => {
try {
return JSON.parse(_previousServers || "[]") as SavedServer[];
} catch {
return [];
}
}, [_previousServers]);
// Current screen state
const [currentScreen, setCurrentScreen] =
useState<TVLoginScreen>("server-selection");
// No interception on server-selection so that it can go back to home screen on tvOS
useTVMenuKeyInterception(currentScreen !== "server-selection");
// Current selected server for user selection screen
const [currentServer, setCurrentServer] = useState<SavedServer | null>(null);
const [serverName, setServerName] = useState<string>("");
// Loading states
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
// Save account state
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// PIN/Password entry for saved accounts
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen =
showSaveModal || pinModalVisible || passwordModalVisible;
// Pairing state (companion login via phone)
const [showPairingQR, setShowPairingQR] = useState(false);
const [pairingCode, setPairingCode] = useState("");
const [pendingPairingCredentials, setPendingPairingCredentials] = useState<{
serverUrl: string;
username: string;
password: string;
} | null>(null);
// Ref to prevent double-handling when onSave and onClose both fire
const pairingHandledRef = useRef(false);
// Refresh servers list helper
const refreshServers = () => {
const servers = getPreviousServers();
setPreviousServers(JSON.stringify(servers));
};
// Initialize on mount - check if we have a persisted server
useEffect(() => {
if (selectedTVServer) {
// Find the full server data from previousServers
const server = previousServers.find(
(s) => s.address === selectedTVServer.address,
);
if (server) {
setCurrentServer(server);
setServerName(selectedTVServer.name || "");
setCurrentScreen("user-selection");
} else {
// Server no longer exists, clear persistence
setSelectedTVServer(null);
}
}
}, []);
// Stop Quick Connect polling when leaving the login page
useEffect(() => {
return () => {
stopQuickConnectPolling();
setShowPairingQR(false);
};
}, [stopQuickConnectPolling]);
// Handle URL params for server connection
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({ address: _apiUrl });
}
})();
}, [_apiUrl]);
// Handle auto-login when api is ready and credentials are provided via URL params
useEffect(() => {
if (api?.basePath && _apiUrl && _username && _password) {
login(_username, _password);
}
}, [api?.basePath, _apiUrl, _username, _password]);
// Update header
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerShown: false,
});
}, [serverName, navigation]);
// Server URL checking
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{ mode: "cors" },
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
}
// Handle connecting to a new server
const handleConnect = useCallback(
async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
// Update server list and get the new server data
refreshServers();
// Find or create server entry
const servers = getPreviousServers();
const server = servers.find((s) => s.address === result);
if (server) {
setCurrentServer(server);
setSelectedTVServer({ address: result, name: serverName });
setCurrentScreen("user-selection");
}
} catch (error) {
if (__DEV__) console.error("[TVLogin] Error in handleConnect:", error);
}
},
[checkUrl, setServer, serverName, setSelectedTVServer],
);
// Handle selecting an existing server
const handleServerSelect = (server: SavedServer) => {
setCurrentServer(server);
setServerName(server.name || "");
setSelectedTVServer({ address: server.address, name: server.name });
setCurrentScreen("user-selection");
};
// Handle changing server (back from user selection)
const handleChangeServer = () => {
setSelectedTVServer(null);
setCurrentServer(null);
setServerName("");
removeServer();
setCurrentScreen("server-selection");
};
// Handle deleting a server
const handleDeleteServer = async (server: SavedServer) => {
await removeServerFromList(server.address);
refreshServers();
// If we deleted the currently selected server, clear it
if (selectedTVServer?.address === server.address) {
setSelectedTVServer(null);
setCurrentServer(null);
}
};
// Handle user selection
const handleUserSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
switch (account.securityType) {
case "none":
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
break;
case "pin":
setSelectedAccount(account);
setPinModalVisible(true);
break;
case "password":
setSelectedAccount(account);
setPasswordModalVisible(true);
break;
}
};
// Handle PIN success
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithSavedCredential(
currentServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
}
setSelectedAccount(null);
};
// Handle password submit
const handlePasswordSubmit = async (password: string) => {
if (currentServer && selectedAccount) {
setCurrentScreen("loading");
setLoading(true);
try {
await loginWithPassword(
currentServer.address,
selectedAccount.username,
password,
);
} catch {
Alert.alert(
t("login.connection_failed"),
t("login.invalid_username_or_password"),
[
{
text: t("common.ok"),
onPress: () => setCurrentScreen("user-selection"),
},
],
);
} finally {
setLoading(false);
}
}
setPasswordModalVisible(false);
setSelectedAccount(null);
};
// Handle forgot PIN
const handleForgotPIN = async () => {
setSelectedAccount(null);
setPinModalVisible(false);
};
// Handle login with credentials (from add user form)
const handleLogin = async (
username: string,
password: string,
saveAccount: boolean,
) => {
if (!currentServer) return;
if (saveAccount) {
setPendingLogin({ username, password });
setShowSaveModal(true);
} else {
await performLogin(username, password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
const pairingCreds = pendingPairingCredentials;
if (pairingCreds) {
// Pairing flow: mark as handled, login, then save credential
pairingHandledRef.current = true;
setPendingPairingCredentials(null);
setPendingLogin(null);
setLoading(true);
try {
await loginWithPassword(
pairingCreds.serverUrl,
pairingCreds.username,
pairingCreds.password,
);
// Save credential after successful login
try {
const token = storage.getString("token");
const userJson = storage.getString("user");
const storedServerUrl = storage.getString("serverUrl");
if (token && userJson && storedServerUrl) {
const user = JSON.parse(userJson);
let pinHash: string | undefined;
if (securityType === "pin" && pinCode) {
pinHash = await hashPIN(pinCode);
}
await saveAccountCredential({
serverUrl: storedServerUrl,
serverName: storedServerUrl,
token,
userId: user.Id || "",
username: pairingCreds.username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
}
} catch (saveError) {
if (__DEV__)
console.error(
"[TVLogin] Failed to save pairing credential:",
saveError,
);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
setLoading(false);
}
return;
}
// Normal login flow
if (pendingLogin && currentServer) {
setLoading(true);
try {
await login(pendingLogin.username, pendingLogin.password, serverName, {
saveAccount: true,
securityType,
pinCode,
});
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
}
};
// Handle quick connect
const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[{ text: t("login.got_it") }],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
// Navigate to QR screen with a fresh code and active listener
const goToQRScreen = useCallback(() => {
const code = generatePairingCode();
setPairingCode(code);
setShowPairingQR(true);
setCurrentScreen("qr-code-display");
}, []);
// Handle pairing with companion phone
const handleStartPairing = useCallback(() => {
goToQRScreen();
}, [goToQRScreen]);
// Handle credentials received from companion
const handlePairingCredentials = useCallback(
(credentials: PairingCredentials) => {
setShowPairingQR(false);
setCurrentScreen("loading");
// Store credentials and show save modal (same UX as normal login)
setPendingPairingCredentials({
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
});
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
},
[],
);
// Listen for pairing credentials when QR is shown
useEffect(() => {
if (!showPairingQR || !pairingCode) return;
const cleanup = startPairingListener(
pairingCode,
handlePairingCredentials,
(error) => {
if (__DEV__) console.error("[TVLogin] Pairing error:", error);
setShowPairingQR(false);
Alert.alert(t("login.error_title"), t("companion_login.error_generic"));
},
);
// Auto-dismiss after 5 minutes
const timeout = setTimeout(
() => {
setShowPairingQR(false);
},
5 * 60 * 1000,
);
return () => {
cleanup();
clearTimeout(timeout);
};
}, [showPairingQR, pairingCode, handlePairingCredentials]);
// Render current screen
const renderScreen = () => {
// If API is connected but we're on server/user selection,
// it means we need to show add-user form
if (
api?.basePath &&
currentScreen !== "add-user" &&
currentScreen !== "loading"
) {
// API is ready, show add-user form
return (
<TVAddUserForm
serverName={serverName}
serverAddress={api.basePath}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={handleChangeServer}
loading={loading}
disabled={isAnyModalOpen}
/>
);
}
switch (currentScreen) {
case "server-selection":
return (
<TVServerSelectionScreen
onServerSelect={handleServerSelect}
onAddServer={() => setCurrentScreen("add-server")}
onDeleteServer={handleDeleteServer}
disabled={isAnyModalOpen}
/>
);
case "user-selection":
if (!currentServer) {
setCurrentScreen("server-selection");
return null;
}
return (
<TVUserSelectionScreen
server={currentServer}
onUserSelect={handleUserSelect}
onAddUser={() => {
// Set the server in JellyfinProvider and go to add-user
setServer({ address: currentServer.address });
setCurrentScreen("add-user");
}}
onChangeServer={handleChangeServer}
disabled={isAnyModalOpen || loading}
/>
);
case "add-server":
return (
<TVAddServerForm
onConnect={handleConnect}
onStartPairing={handleStartPairing}
onBack={() => setCurrentScreen("server-selection")}
loading={loadingServerCheck}
disabled={isAnyModalOpen}
/>
);
case "qr-code-display":
return (
<TVQRCodeDisplay
code={pairingCode}
onBack={() => {
setShowPairingQR(false);
setCurrentScreen("add-server");
}}
/>
);
case "loading":
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
fontSize: scaleSize(24),
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(12),
}}
>
{t("pairing.logging_in")}
</Text>
<Text
style={{
fontSize: scaleSize(16),
color: "#9CA3AF",
}}
>
{t("pairing.logging_in_description")}
</Text>
</View>
);
case "add-user":
return (
<TVAddUserForm
serverName={serverName}
serverAddress={currentServer?.address || api?.basePath || ""}
onLogin={handleLogin}
onQuickConnect={handleQuickConnect}
onBack={() => {
removeServer();
setCurrentScreen("user-selection");
}}
loading={loading}
disabled={isAnyModalOpen}
/>
);
default:
return null;
}
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>{renderScreen()}</View>
{/* Save Account Modal */}
<TVSaveAccountModal
visible={showSaveModal}
onClose={() => {
// If onSave already handled this, just clean up
if (pairingHandledRef.current) {
pairingHandledRef.current = false;
return;
}
setShowSaveModal(false);
if (pendingPairingCredentials) {
// Pairing: user dismissed without saving, login anyway
const creds = pendingPairingCredentials;
setPendingPairingCredentials(null);
setPendingLogin(null);
loginWithPassword(
creds.serverUrl,
creds.username,
creds.password,
).catch((error) => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});
return;
}
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || ""}
/>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={handleForgotPIN}
serverUrl={currentServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
};

View File

@@ -0,0 +1,488 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Animated,
Easing,
Pressable,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
import { verifyAccountPIN } from "@/utils/secureCredentials";
interface TVPINEntryModalProps {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
onForgotPIN?: () => void;
serverUrl: string;
userId: string;
username: string;
}
// Number pad button
const NumberPadButton: React.FC<{
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
isBackspace?: boolean;
disabled?: boolean;
}> = ({ value, onPress, hasTVPreferredFocus, isBackspace, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.1);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
styles.numberButton,
{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
},
]}
>
{isBackspace ? (
<Ionicons
name='backspace-outline'
size={28}
color={focused ? "#000" : "#fff"}
/>
) : (
<Text
style={[styles.numberText, { color: focused ? "#000" : "#fff" }]}
>
{value}
</Text>
)}
</Animated.View>
</Pressable>
);
};
// PIN dot indicator
const PinDot: React.FC<{ filled: boolean; error: boolean }> = ({
filled,
error,
}) => (
<View
style={[
styles.pinDot,
filled && styles.pinDotFilled,
error && styles.pinDotError,
]}
/>
);
// Forgot PIN link
const ForgotPINLink: React.FC<{
onPress: () => void;
label: string;
}> = ({ onPress, label }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 100,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
paddingHorizontal: scaleSize(16),
paddingVertical: scaleSize(10),
borderRadius: scaleSize(8),
backgroundColor: focused ? "rgba(255,255,255,0.15)" : "transparent",
}}
>
<Text
style={{
fontSize: scaleSize(16),
color: focused ? "#fff" : "rgba(255,255,255,0.5)",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
visible,
onClose,
onSuccess,
onForgotPIN,
serverUrl,
userId,
username,
}) => {
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const contentScale = useRef(new Animated.Value(0.9)).current;
const shakeAnimation = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
setPinCode("");
setError(false);
setIsVerifying(false);
overlayOpacity.setValue(0);
contentScale.setValue(0.9);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(contentScale, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible, overlayOpacity, contentScale]);
const shake = () => {
Animated.sequence([
Animated.timing(shakeAnimation, {
toValue: 15,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: -15,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: 15,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(shakeAnimation, {
toValue: 0,
duration: 50,
useNativeDriver: true,
}),
]).start();
};
const handleNumberPress = async (num: string) => {
if (isVerifying || pinCode.length >= 4) return;
setError(false);
const newPin = pinCode + num;
setPinCode(newPin);
// Auto-verify when 4 digits entered
if (newPin.length === 4) {
setIsVerifying(true);
try {
const isValid = await verifyAccountPIN(serverUrl, userId, newPin);
if (isValid) {
onSuccess();
setPinCode("");
} else {
setError(true);
shake();
setTimeout(() => setPinCode(""), 300);
}
} catch {
setError(true);
shake();
setTimeout(() => setPinCode(""), 300);
} finally {
setIsVerifying(false);
}
}
};
const handleBackspace = () => {
if (isVerifying) return;
setError(false);
setPinCode((prev) => prev.slice(0, -1));
};
const handleForgotPIN = () => {
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.continue"),
style: "destructive",
onPress: () => {
onClose();
onForgotPIN?.();
},
},
]);
};
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.contentContainer,
{ transform: [{ scale: contentScale }] },
]}
>
<BlurView intensity={60} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header */}
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
<Text style={styles.subtitle}>{username}</Text>
{/* PIN Dots */}
<Animated.View
style={[
styles.pinDotsContainer,
{ transform: [{ translateX: shakeAnimation }] },
]}
>
{[0, 1, 2, 3].map((i) => (
<PinDot key={i} filled={pinCode.length > i} error={error} />
))}
</Animated.View>
{/* Number Pad */}
{isReady && (
<View style={styles.numberPad}>
{/* Row 1: 1-3 */}
<View style={styles.numberRow}>
<NumberPadButton
value='1'
onPress={() => handleNumberPress("1")}
hasTVPreferredFocus
disabled={isVerifying}
/>
<NumberPadButton
value='2'
onPress={() => handleNumberPress("2")}
disabled={isVerifying}
/>
<NumberPadButton
value='3'
onPress={() => handleNumberPress("3")}
disabled={isVerifying}
/>
</View>
{/* Row 2: 4-6 */}
<View style={styles.numberRow}>
<NumberPadButton
value='4'
onPress={() => handleNumberPress("4")}
disabled={isVerifying}
/>
<NumberPadButton
value='5'
onPress={() => handleNumberPress("5")}
disabled={isVerifying}
/>
<NumberPadButton
value='6'
onPress={() => handleNumberPress("6")}
disabled={isVerifying}
/>
</View>
{/* Row 3: 7-9 */}
<View style={styles.numberRow}>
<NumberPadButton
value='7'
onPress={() => handleNumberPress("7")}
disabled={isVerifying}
/>
<NumberPadButton
value='8'
onPress={() => handleNumberPress("8")}
disabled={isVerifying}
/>
<NumberPadButton
value='9'
onPress={() => handleNumberPress("9")}
disabled={isVerifying}
/>
</View>
{/* Row 4: empty, 0, backspace */}
<View style={styles.numberRow}>
<View style={styles.numberButtonPlaceholder} />
<NumberPadButton
value='0'
onPress={() => handleNumberPress("0")}
disabled={isVerifying}
/>
<NumberPadButton
value=''
onPress={handleBackspace}
isBackspace
disabled={isVerifying || pinCode.length === 0}
/>
</View>
</View>
)}
{/* Forgot PIN */}
{isReady && onForgotPIN && (
<View style={styles.forgotContainer}>
<ForgotPINLink
onPress={handleForgotPIN}
label={t("pin.forgot_pin")}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
},
contentContainer: {
width: "100%",
maxWidth: 400,
},
blurContainer: {
borderRadius: scaleSize(24),
overflow: "hidden",
},
content: {
padding: scaleSize(40),
alignItems: "center",
},
title: {
fontSize: scaleSize(28),
fontWeight: "bold",
color: "#fff",
marginBottom: scaleSize(8),
textAlign: "center",
},
subtitle: {
fontSize: scaleSize(18),
color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(32),
textAlign: "center",
},
pinDotsContainer: {
flexDirection: "row",
gap: scaleSize(16),
marginBottom: scaleSize(32),
},
pinDot: {
width: scaleSize(20),
height: scaleSize(20),
borderRadius: scaleSize(10),
borderWidth: scaleSize(2),
borderColor: "rgba(255,255,255,0.4)",
backgroundColor: "transparent",
},
pinDotFilled: {
backgroundColor: "#fff",
borderColor: "#fff",
},
pinDotError: {
borderColor: "#ef4444",
backgroundColor: "#ef4444",
},
numberPad: {
gap: scaleSize(12),
marginBottom: scaleSize(24),
},
numberRow: {
flexDirection: "row",
gap: scaleSize(12),
},
numberButton: {
width: scaleSize(72),
height: scaleSize(72),
borderRadius: scaleSize(36),
justifyContent: "center",
alignItems: "center",
},
numberButtonPlaceholder: {
width: scaleSize(72),
height: scaleSize(72),
},
numberText: {
fontSize: scaleSize(28),
fontWeight: "600",
},
forgotContainer: {
marginTop: 8,
},
});

View File

@@ -0,0 +1,352 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
Pressable,
StyleSheet,
TextInput,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
interface TVPasswordEntryModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (password: string) => Promise<void>;
username: string;
}
// TV Submit Button
const TVSubmitButton: React.FC<{
onPress: () => void;
label: string;
loading?: boolean;
disabled?: boolean;
}> = ({ onPress, label, loading = false, disabled = false }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
const isDisabled = disabled || loading;
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "#fff"
: isDisabled
? "#4a4a4a"
: "rgba(255,255,255,0.15)",
paddingHorizontal: scaleSize(24),
paddingVertical: scaleSize(14),
borderRadius: scaleSize(10),
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: scaleSize(8),
minWidth: scaleSize(120),
opacity: isDisabled ? 0.5 : 1,
},
]}
>
{loading ? (
<ActivityIndicator size='small' color={focused ? "#000" : "#fff"} />
) : (
<>
<Ionicons
name='log-in-outline'
size={scaleSize(20)}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: scaleSize(16),
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>
{label}
</Text>
</>
)}
</Animated.View>
</Pressable>
);
};
// TV Focusable Password Input
const TVPasswordInput: React.FC<{
value: string;
onChangeText: (text: string) => void;
placeholder: string;
onSubmitEditing: () => void;
hasTVPreferredFocus?: boolean;
}> = ({
value,
onChangeText,
placeholder,
onSubmitEditing,
hasTVPreferredFocus,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
const inputRef = useRef<TextInput>(null);
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={() => {
handleFocus();
inputRef.current?.focus();
}}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: "#1F2937",
borderRadius: scaleSize(12),
borderWidth: scaleSize(2),
borderColor: focused ? "#fff" : "#374151",
paddingHorizontal: scaleSize(16),
paddingVertical: scaleSize(14),
},
]}
>
<TextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor='#6B7280'
secureTextEntry
autoCapitalize='none'
autoCorrect={false}
style={{
color: "#fff",
fontSize: scaleSize(18),
}}
onSubmitEditing={onSubmitEditing}
returnKeyType='done'
/>
</Animated.View>
</Pressable>
);
};
export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
visible,
onClose,
onSubmit,
username,
}) => {
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
useEffect(() => {
if (visible) {
// Reset state when opening
setPassword("");
setError(null);
setIsLoading(false);
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
// Close the modal on the TV remote back/menu button while it is open.
useTVBackPress(() => {
if (!visible) return false;
onClose();
return true;
}, [visible, onClose]);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));
return;
}
setIsLoading(true);
setError(null);
try {
await onSubmit(password);
setPassword("");
} catch {
setError(t("password.invalid_password"));
} finally {
setIsLoading(false);
}
};
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t("password.enter_password")}</Text>
<Text style={styles.subtitle}>
{t("password.enter_password_for", { username })}
</Text>
</View>
{/* Password Input */}
{isReady && (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
{t("login.password_placeholder")}
</Text>
<TVPasswordInput
value={password}
onChangeText={(text) => {
setPassword(text);
setError(null);
}}
placeholder={t("login.password_placeholder")}
onSubmitEditing={handleSubmit}
hasTVPreferredFocus
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)}
{/* Submit Button */}
{isReady && (
<View style={styles.buttonContainer}>
<TVSubmitButton
onPress={handleSubmit}
label={t("login.login")}
loading={isLoading}
disabled={!password}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: scaleSize(24),
paddingBottom: scaleSize(50),
overflow: "visible",
},
header: {
paddingHorizontal: scaleSize(48),
marginBottom: scaleSize(24),
},
title: {
fontSize: scaleSize(28),
fontWeight: "bold",
color: "#fff",
marginBottom: scaleSize(4),
},
subtitle: {
fontSize: scaleSize(16),
color: "rgba(255,255,255,0.6)",
},
inputContainer: {
paddingHorizontal: scaleSize(48),
marginBottom: scaleSize(20),
},
inputLabel: {
fontSize: scaleSize(14),
color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(8),
},
errorText: {
color: "#ef4444",
fontSize: scaleSize(14),
marginTop: scaleSize(8),
},
buttonContainer: {
paddingHorizontal: scaleSize(48),
alignItems: "flex-start",
},
});

View File

@@ -0,0 +1,115 @@
import { t } from "i18next";
import React, { useCallback } from "react";
import { View } from "react-native";
import QRCode from "react-native-qrcode-svg";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
interface TVQRCodeDisplayProps {
code: string;
onBack?: () => void;
}
export const TVQRCodeDisplay: React.FC<TVQRCodeDisplayProps> = ({
code,
onBack,
}) => {
const typography = useScaledTVTypography();
const qrSize = scaleSize(280);
const cardPadding = scaleSize(16);
const sectionPadding = scaleSize(32);
const outerPadding = scaleSize(60);
const qrData = JSON.stringify({
action: "streamyfin-pair",
code,
});
const handleBack = useCallback(() => {
if (!onBack) return false;
onBack();
return true;
}, [onBack]);
useTVBackPress(() => handleBack(), [handleBack]);
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: outerPadding,
}}
>
{/* QR Code */}
<View
style={{
alignItems: "center",
paddingVertical: sectionPadding,
paddingHorizontal: cardPadding,
borderRadius: scaleSize(16),
backgroundColor: "rgba(255, 255, 255, 0.05)",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: scaleSize(8),
}}
>
{t("pairing.waiting_for_phone")}
</Text>
<View
style={{
padding: cardPadding,
borderRadius: scaleSize(12),
backgroundColor: "#FFFFFF",
}}
>
<QRCode
value={qrData}
size={qrSize}
color='#000000'
backgroundColor='#FFFFFF'
/>
</View>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
letterSpacing: scaleSize(8),
marginTop: scaleSize(16),
}}
>
{code}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: scaleSize(8),
}}
>
{t("pairing.scan_with_phone")}
</Text>
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,440 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
import { scaleSize } from "@/utils/scaleSize";
import type { AccountSecurityType } from "@/utils/secureCredentials";
interface TVSaveAccountModalProps {
visible: boolean;
onClose: () => void;
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
username: string;
}
interface SecurityOption {
type: AccountSecurityType;
titleKey: string;
descriptionKey: string;
icon: keyof typeof Ionicons.glyphMap;
}
const SECURITY_OPTIONS: SecurityOption[] = [
{
type: "none",
titleKey: "save_account.no_protection",
descriptionKey: "save_account.no_protection_desc",
icon: "flash-outline",
},
{
type: "pin",
titleKey: "save_account.pin_code",
descriptionKey: "save_account.pin_code_desc",
icon: "keypad-outline",
},
{
type: "password",
titleKey: "save_account.password",
descriptionKey: "save_account.password_desc",
icon: "lock-closed-outline",
},
];
// Custom Save Button with TV focus
const TVSaveButton: React.FC<{
onPress: () => void;
label: string;
disabled?: boolean;
hasTVPreferredFocus?: boolean;
}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "#fff"
: disabled
? "#4a4a4a"
: "rgba(255,255,255,0.15)",
paddingHorizontal: scaleSize(24),
paddingVertical: scaleSize(14),
borderRadius: scaleSize(10),
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
opacity: disabled ? 0.5 : 1,
},
]}
>
<Ionicons
name='checkmark'
size={scaleSize(20)}
color={focused ? "#000" : "#fff"}
/>
<Text
style={{
fontSize: scaleSize(16),
color: focused ? "#000" : "#fff",
fontWeight: "600",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// Back Button for PIN step
const TVBackButton: React.FC<{
onPress: () => void;
label: string;
hasTVPreferredFocus?: boolean;
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: scaleSize(20),
paddingVertical: scaleSize(12),
borderRadius: scaleSize(10),
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
},
]}
>
<Ionicons
name='chevron-back'
size={scaleSize(20)}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: scaleSize(16),
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVSaveAccountModal: React.FC<TVSaveAccountModalProps> = ({
visible,
onClose,
onSave,
username,
}) => {
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [step, setStep] = useState<"select" | "pin">("select");
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
const [pinCode, setPinCode] = useState("");
const [pinError, setPinError] = useState<string | null>(null);
const pinInputRef = useRef<TVPinInputRef>(null);
// Use useState for focus tracking (per TV focus guide)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
useEffect(() => {
if (visible) {
// Reset state when opening
setStep("select");
setSelectedType("none");
setPinCode("");
setPinError(null);
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
// Focus the first card when ready
useEffect(() => {
if (isReady && firstCardRef && step === "select") {
const timer = setTimeout(() => {
(firstCardRef as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady, firstCardRef, step]);
useEffect(() => {
if (step === "pin" && isReady) {
const timer = setTimeout(() => {
pinInputRef.current?.focus();
}, 150);
return () => clearTimeout(timer);
}
}, [step, isReady]);
const handleOptionSelect = (type: AccountSecurityType) => {
setSelectedType(type);
if (type === "pin") {
setStep("pin");
setPinCode("");
setPinError(null);
} else {
// For "none" or "password", save immediately
onSave(type);
resetAndClose();
}
};
const handlePinSave = () => {
if (pinCode.length !== 4) {
setPinError(t("pin.enter_4_digits"));
return;
}
onSave("pin", pinCode);
resetAndClose();
};
const handleBack = () => {
setStep("select");
setPinCode("");
setPinError(null);
};
const resetAndClose = () => {
setStep("select");
setSelectedType("none");
setPinCode("");
setPinError(null);
onClose();
};
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t("save_account.title")}</Text>
<Text style={styles.subtitle}>{username}</Text>
</View>
{step === "select" ? (
// Security selection step
<>
<Text style={styles.sectionTitle}>
{t("save_account.security_option")}
</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{SECURITY_OPTIONS.map((option, index) => (
<TVOptionCard
key={option.type}
ref={index === 0 ? setFirstCardRef : undefined}
label={t(option.titleKey)}
sublabel={t(option.descriptionKey)}
selected={selectedType === option.type}
hasTVPreferredFocus={index === 0}
onPress={() => handleOptionSelect(option.type)}
width={220}
height={100}
/>
))}
</ScrollView>
)}
</>
) : (
// PIN entry step
<>
<Text style={styles.sectionTitle}>{t("pin.setup_pin")}</Text>
<View style={styles.pinContainer}>
<TVPinInput
ref={pinInputRef}
value={pinCode}
onChangeText={(text) => {
setPinCode(text);
setPinError(null);
}}
length={4}
autoFocus
/>
{pinError && <Text style={styles.errorText}>{pinError}</Text>}
</View>
{isReady && (
<View style={styles.buttonRow}>
<TVBackButton
onPress={handleBack}
label={t("common.back")}
hasTVPreferredFocus
/>
<TVSaveButton
onPress={handlePinSave}
label={t("save_account.save_button")}
disabled={pinCode.length !== 4}
/>
</View>
)}
</>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: scaleSize(24),
paddingBottom: scaleSize(50),
overflow: "visible",
},
header: {
paddingHorizontal: scaleSize(48),
marginBottom: scaleSize(20),
},
title: {
fontSize: scaleSize(28),
fontWeight: "bold",
color: "#fff",
marginBottom: scaleSize(4),
},
subtitle: {
fontSize: scaleSize(16),
color: "rgba(255,255,255,0.6)",
},
sectionTitle: {
fontSize: scaleSize(16),
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(16),
paddingHorizontal: scaleSize(48),
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(10),
gap: scaleSize(12),
},
buttonRow: {
marginTop: scaleSize(20),
paddingHorizontal: scaleSize(48),
flexDirection: "row",
gap: scaleSize(16),
alignItems: "center",
},
pinContainer: {
paddingHorizontal: scaleSize(48),
alignItems: "center",
marginBottom: scaleSize(10),
},
errorText: {
color: "#ef4444",
fontSize: scaleSize(14),
marginTop: scaleSize(12),
textAlign: "center",
},
});

View File

@@ -0,0 +1,119 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
interface TVSaveAccountToggleProps {
value: boolean;
onValueChange: (value: boolean) => void;
label: string;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
value,
onValueChange,
label,
hasTVPreferredFocus,
disabled = false,
}) => {
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const glowOpacity = useRef(new Animated.Value(0)).current;
const animateFocus = (focused: boolean) => {
Animated.parallel([
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(glowOpacity, {
toValue: focused ? 0.6 : 0,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
return (
<Pressable
onPress={() => onValueChange(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: scaleSize(16),
paddingHorizontal: scaleSize(24),
paddingVertical: scaleSize(20),
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={{
fontSize: scaleSize(20),
color: "#FFFFFF",
}}
>
{label}
</Text>
<View
pointerEvents='none'
style={{
width: scaleSize(60),
height: scaleSize(34),
borderRadius: scaleSize(17),
backgroundColor: value ? "#fff" : "#3f3f46",
justifyContent: "center",
paddingHorizontal: scaleSize(3),
}}
>
<View
style={{
width: scaleSize(28),
height: scaleSize(28),
borderRadius: scaleSize(14),
backgroundColor: value ? "#000" : "#fff",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,212 @@
import { LinearGradient } from "expo-linear-gradient";
import React, { useMemo } from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
// Sci-fi gradient color pairs (from, to) - cyberpunk/neon vibes
const SERVER_GRADIENTS: [string, string][] = [
["#00D4FF", "#0066FF"], // Cyan to Blue
["#FF00E5", "#7B00FF"], // Magenta to Purple
["#00FF94", "#00B4D8"], // Neon Green to Cyan
["#FF6B35", "#F72585"], // Orange to Pink
["#4CC9F0", "#7209B7"], // Sky Blue to Violet
["#06D6A0", "#118AB2"], // Mint to Ocean Blue
["#FFD60A", "#FF006E"], // Yellow to Hot Pink
["#8338EC", "#3A86FF"], // Purple to Blue
["#FB5607", "#FFBE0B"], // Orange to Gold
["#00F5D4", "#00BBF9"], // Aqua to Azure
["#F15BB5", "#9B5DE5"], // Pink to Lavender
["#00C49A", "#00509D"], // Teal to Navy
["#E63946", "#F4A261"], // Red to Peach
["#2EC4B6", "#011627"], // Turquoise to Dark Blue
["#FF0099", "#493240"], // Hot Pink to Plum
["#11998E", "#38EF7D"], // Teal to Lime
["#FC466B", "#3F5EFB"], // Pink to Indigo
["#C471ED", "#12C2E9"], // Orchid to Sky
["#F857A6", "#FF5858"], // Pink to Coral
["#00B09B", "#96C93D"], // Emerald to Lime
["#7F00FF", "#E100FF"], // Violet to Magenta
["#1FA2FF", "#12D8FA"], // Blue to Cyan
["#F09819", "#EDDE5D"], // Orange to Yellow
["#FF416C", "#FF4B2B"], // Pink to Red Orange
["#654EA3", "#EAAFC8"], // Purple to Rose
["#00C6FF", "#0072FF"], // Light Blue to Blue
["#F7971E", "#FFD200"], // Orange to Gold
["#56AB2F", "#A8E063"], // Green to Lime
["#DA22FF", "#9733EE"], // Magenta to Purple
["#02AAB0", "#00CDAC"], // Teal variations
["#ED213A", "#93291E"], // Red to Dark Red
["#FDC830", "#F37335"], // Yellow to Orange
["#00B4DB", "#0083B0"], // Ocean Blue
["#C33764", "#1D2671"], // Berry to Navy
["#E55D87", "#5FC3E4"], // Pink to Sky Blue
["#403B4A", "#E7E9BB"], // Dark to Cream
["#F2709C", "#FF9472"], // Rose to Peach
["#1D976C", "#93F9B9"], // Forest to Mint
["#CC2B5E", "#753A88"], // Crimson to Purple
["#42275A", "#734B6D"], // Plum shades
["#BDC3C7", "#2C3E50"], // Silver to Slate
["#DE6262", "#FFB88C"], // Salmon to Apricot
["#06BEB6", "#48B1BF"], // Teal shades
["#EB3349", "#F45C43"], // Red to Orange Red
["#DD5E89", "#F7BB97"], // Pink to Tan
["#56CCF2", "#2F80ED"], // Sky to Blue
["#007991", "#78FFD6"], // Deep Teal to Mint
["#C6FFDD", "#FBD786"], // Mint to Yellow
["#F953C6", "#B91D73"], // Pink to Magenta
["#B24592", "#F15F79"], // Purple to Coral
];
// Generate a consistent gradient index based on URL (deterministic hash)
// Uses cyrb53 hash - fast and good distribution
const getGradientForString = (str: string): [string, string] => {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const hash = 4294967296 * (2097151 & h2) + (h1 >>> 0);
const index = Math.abs(hash) % SERVER_GRADIENTS.length;
return SERVER_GRADIENTS[index];
};
export interface TVServerIconProps {
name: string;
address: string;
onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVServerIcon = React.forwardRef<View, TVServerIconProps>(
(
{
name,
address,
onPress,
onLongPress,
hasTVPreferredFocus,
disabled = false,
},
ref,
) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
// Get the first letter of the server name (or address if no name)
const displayName = name || address;
const initial = displayName.charAt(0).toUpperCase();
// Get a consistent gradient based on the server URL (deterministic)
// Use address as primary key, fallback to name + displayName for uniqueness
const hashKey = address || name || displayName;
const [gradientStart, gradientEnd] = useMemo(
() => getGradientForString(hashKey),
[hashKey],
);
return (
<Pressable
ref={ref}
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
shadowColor: gradientStart,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.7 : 0,
shadowRadius: focused ? scaleSize(24) : 0,
},
]}
>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
overflow: "hidden",
marginBottom: scaleSize(14),
borderWidth: focused ? scaleSize(3) : 0,
borderColor: "#fff",
}}
>
<LinearGradient
colors={[gradientStart, gradientEnd]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
opacity: focused ? 1 : 0.85,
}}
>
<Text
style={{
fontSize: scaleSize(48),
fontWeight: "bold",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.3)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
}}
>
{initial}
</Text>
</LinearGradient>
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: scaleSize(4),
}}
numberOfLines={3}
>
{displayName}
</Text>
{name && (
<Text
style={{
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={3}
>
{address.replace(/^https?:\/\//, "")}
</Text>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,138 @@
import { Image } from "expo-image";
import { t } from "i18next";
import React, { useMemo } from "react";
import { Alert, ScrollView, View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import type { SavedServer } from "@/utils/secureCredentials";
import { TVAddIcon } from "./TVAddIcon";
import { TVServerIcon } from "./TVServerIcon";
interface TVServerSelectionScreenProps {
onServerSelect: (server: SavedServer) => void;
onAddServer: () => void;
onDeleteServer: (server: SavedServer) => void;
disabled?: boolean;
}
export const TVServerSelectionScreen: React.FC<
TVServerSelectionScreenProps
> = ({ onServerSelect, onAddServer, onDeleteServer, disabled = false }) => {
const typography = useScaledTVTypography();
const [_previousServers] = useMMKVString("previousServers");
const previousServers = useMemo(() => {
try {
return JSON.parse(_previousServers || "[]") as SavedServer[];
} catch {
return [];
}
}, [_previousServers]);
const hasServers = previousServers.length > 0;
const handleDeleteServer = (server: SavedServer) => {
Alert.alert(
t("server.remove_server"),
t("server.remove_server_description", {
server: server.name || server.address,
}),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: () => onDeleteServer(server),
},
],
);
};
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
alignItems: "center",
paddingHorizontal: scaleSize(60),
}}
>
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: scaleSize(16) }}>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: scaleSize(150), height: scaleSize(150) }}
contentFit='contain'
/>
</View>
{/* Title */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: scaleSize(8),
}}
>
Streamyfin
</Text>
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginBottom: scaleSize(48),
}}
>
{hasServers
? t("server.select_your_server")
: t("server.add_server_to_get_started")}
</Text>
{/* Server Icons Grid */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: scaleSize(20),
gap: scaleSize(24),
}}
style={{ overflow: "visible" }}
>
{previousServers.map((server, index) => (
<TVServerIcon
key={server.address}
name={server.name || ""}
address={server.address}
onPress={() => onServerSelect(server)}
onLongPress={() => handleDeleteServer(server)}
hasTVPreferredFocus={index === 0}
disabled={disabled}
/>
))}
{/* Add Server Button */}
<TVAddIcon
label={t("server.add_server")}
onPress={onAddServer}
hasTVPreferredFocus={!hasServers}
disabled={disabled}
/>
</ScrollView>
</View>
</ScrollView>
);
};

View File

@@ -0,0 +1,167 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import React, { useState } from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
import { scaleSize } from "@/utils/scaleSize";
import type { AccountSecurityType } from "@/utils/secureCredentials";
export interface TVUserIconProps {
username: string;
securityType: AccountSecurityType;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
serverAddress?: string;
userId?: string;
primaryImageTag?: string;
}
export const TVUserIcon = React.forwardRef<View, TVUserIconProps>(
(
{
username,
securityType,
onPress,
hasTVPreferredFocus,
disabled = false,
serverAddress,
userId,
primaryImageTag,
},
ref,
) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
const [imageError, setImageError] = useState(false);
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
switch (securityType) {
case "pin":
return "keypad";
case "password":
return "lock-closed";
default:
return "key";
}
};
const hasSecurityProtection = securityType !== "none";
const imageUrl =
serverAddress && userId && primaryImageTag && !imageError
? getUserImageUrl({
serverAddress,
userId,
primaryImageTag,
width: 280,
})
: null;
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
overflow: "visible",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(16) : 0,
},
]}
>
<View style={{ position: "relative" }}>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
overflow: "hidden",
backgroundColor: focused
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.1)",
marginBottom: scaleSize(14),
borderWidth: focused ? scaleSize(3) : 0,
borderColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: scaleSize(140), height: scaleSize(140) }}
contentFit='cover'
onError={() => setImageError(true)}
/>
) : (
<Ionicons
name='person'
size={scaleSize(56)}
color={
focused ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.4)"
}
/>
)}
</View>
{/* Security badge */}
{hasSecurityProtection && (
<View
style={{
position: "absolute",
bottom: scaleSize(10),
right: scaleSize(10),
width: scaleSize(32),
height: scaleSize(32),
borderRadius: scaleSize(16),
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.9)",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: scaleSize(2) },
shadowOpacity: 0.3,
shadowRadius: scaleSize(4),
}}
>
<Ionicons
name={getSecurityIcon()}
size={scaleSize(16)}
color='#000'
/>
</View>
)}
</View>
<Text
style={{
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
}}
numberOfLines={2}
>
{username}
</Text>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,143 @@
import { t } from "i18next";
import React, { useCallback } from "react";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
import { TVAddIcon } from "./TVAddIcon";
import { TVBackIcon } from "./TVBackIcon";
import { TVUserIcon } from "./TVUserIcon";
interface TVUserSelectionScreenProps {
server: SavedServer;
onUserSelect: (account: SavedServerAccount) => void;
onAddUser: () => void;
onChangeServer: () => void;
disabled?: boolean;
}
export const TVUserSelectionScreen: React.FC<TVUserSelectionScreenProps> = ({
server,
onUserSelect,
onAddUser,
onChangeServer,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const accounts = server.accounts || [];
const hasAccounts = accounts.length > 0;
const handleBackPress = useCallback(() => {
if (disabled) return false;
onChangeServer();
return true;
}, [disabled, onChangeServer]);
useTVBackPress(handleBackPress, [handleBackPress]);
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: scaleSize(60),
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
width: "100%",
alignItems: "center",
paddingHorizontal: scaleSize(60),
}}
>
{/* Server Info Header */}
<View style={{ marginBottom: scaleSize(48), alignItems: "center" }}>
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: scaleSize(8),
}}
>
{server.name || server.address}
</Text>
{server.name && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
}}
>
{server.address.replace(/^https?:\/\//, "")}
</Text>
)}
<Text
style={{
fontSize: typography.body,
color: "#6B7280",
textAlign: "center",
marginTop: scaleSize(16),
}}
>
{hasAccounts
? t("login.select_user")
: t("login.add_user_to_login")}
</Text>
</View>
{/* User Icons Grid with Back and Add buttons */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: scaleSize(20),
gap: scaleSize(24),
}}
style={{ overflow: "visible" }}
>
{/* Back/Change Server Button (left) */}
<TVBackIcon
label={t("server.change_server")}
onPress={onChangeServer}
disabled={disabled}
/>
{/* User Icons */}
{accounts.map((account, index) => (
<TVUserIcon
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => onUserSelect(account)}
hasTVPreferredFocus={index === 0}
disabled={disabled}
serverAddress={server.address}
userId={account.userId}
primaryImageTag={account.primaryImageTag}
/>
))}
{/* Add User Button (right) */}
<TVAddIcon
label={t("login.add_user")}
onPress={onAddUser}
hasTVPreferredFocus={!hasAccounts}
disabled={disabled}
/>
</ScrollView>
</View>
</ScrollView>
);
};

View File

@@ -243,7 +243,7 @@ export const MiniPlayerBar: React.FC = () => {
]}
>
<Animated.View style={[styles.touchable, animatedBarStyle]}>
{Platform.OS === "ios" ? (
{Platform.OS === "ios" && !Platform.isTV ? (
<GlassEffectView style={styles.blurContainer}>
<View
style={{

View File

@@ -0,0 +1,570 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
FlatList,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { Loader } from "@/components/Loader";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const ACTOR_IMAGE_SIZE = 250;
const SCALE_PADDING = 20;
interface TVActorPageProps {
personId: string;
}
export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const posterSizes = useScaledTVPosterSizes();
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
// Track which filmography item is currently focused for dynamic backdrop
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
// Fetch actor details
const { data: item, isLoading: isLoadingActor } = useQuery({
queryKey: ["item", personId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: personId,
}),
enabled: !!personId && !!api,
staleTime: 60,
});
// Fetch movies
const { data: movies = [], isLoading: isLoadingMovies } = useQuery({
queryKey: ["actor", "movies", personId],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [personId],
startIndex: 0,
limit: 20,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Movie"],
recursive: true,
fields: ["ParentId", "PrimaryImageAspectRatio"],
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
collapseBoxSetItems: false,
});
return response.data.Items || [];
},
enabled: !!personId && !!api && !!user?.Id,
staleTime: 60,
});
// Fetch series
const { data: series = [], isLoading: isLoadingSeries } = useQuery({
queryKey: ["actor", "series", personId],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [personId],
startIndex: 0,
limit: 20,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Series"],
recursive: true,
fields: ["ParentId", "PrimaryImageAspectRatio"],
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
collapseBoxSetItems: false,
});
return response.data.Items || [];
},
enabled: !!personId && !!api && !!user?.Id,
staleTime: 60,
});
// Get backdrop URL from the currently focused filmography item
// Changes dynamically as user navigates through the list
const backdropUrl = useMemo(() => {
// Use focused item if available, otherwise fall back to first movie or series
const itemForBackdrop = focusedItem ?? movies[0] ?? series[0];
if (!itemForBackdrop) return null;
return getBackdropUrl({
api,
item: itemForBackdrop,
quality: 90,
width: 1920,
});
}, [api, focusedItem, movies, series]);
// Crossfade animation for backdrop transitions
// Use two alternating layers for smooth crossfade
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(null);
const layer0Opacity = useRef(new Animated.Value(1)).current;
const layer1Opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!backdropUrl) return;
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
try {
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
if (isCancelled) return;
// Determine which layer to fade in
const incomingLayer = activeLayer === 0 ? 1 : 0;
const incomingOpacity =
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
const outgoingOpacity =
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
// Set the new URL on the incoming layer
if (incomingLayer === 0) {
setLayer0Url(backdropUrl);
} else {
setLayer1Url(backdropUrl);
}
// Small delay to ensure image component has the new URL
await new Promise((resolve) => setTimeout(resolve, 50));
if (isCancelled) return;
// Crossfade: fade in the incoming layer, fade out the outgoing
Animated.parallel([
Animated.timing(incomingOpacity, {
toValue: 1,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(outgoingOpacity, {
toValue: 0,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!isCancelled) {
// After animation completes, switch the active layer
setActiveLayer(incomingLayer);
}
});
};
performCrossfade();
return () => {
isCancelled = true;
};
}, [backdropUrl]);
// Get actor image URL
const actorImageUrl = useMemo(() => {
if (!item?.Id || !api?.basePath) return null;
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`;
}, [api?.basePath, item?.Id]);
// Handle filmography item press
const handleItemPress = useCallback(
(filmItem: BaseItemDto) => {
const navigation = getItemNavigation(filmItem, from);
router.push(navigation as any);
},
[from, router],
);
// List item layout
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
);
// Render movie filmography item
const renderMovieItem = useCallback(
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={filmItem}
orientation='vertical'
onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
</View>
),
[handleItemPress, showItemActions, posterSizes.poster],
);
// Render series filmography item
const renderSeriesItem = useCallback(
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={filmItem}
orientation='vertical'
onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={movies.length === 0 && index === 0}
width={posterSizes.poster}
/>
</View>
),
[handleItemPress, showItemActions, posterSizes.poster, movies.length],
);
if (isLoadingActor) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
if (!item?.Id) return null;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Full-screen backdrop with crossfade - two alternating layers */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url ? (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url ? (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
</Animated.View>
{/* Gradient overlay for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
{/* Main content area */}
<ScrollView
style={{
flex: 1,
}}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: 60,
}}
>
{/* Top section - Actor image + Info */}
<View
style={{
flexDirection: "row",
marginBottom: 48,
}}
>
{/* Left side - Circular actor image */}
<View
style={{
width: ACTOR_IMAGE_SIZE,
height: ACTOR_IMAGE_SIZE,
borderRadius: ACTOR_IMAGE_SIZE / 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginRight: 50,
borderWidth: 3,
borderColor: "rgba(255,255,255,0.2)",
}}
>
{actorImageUrl ? (
<Image
source={{ uri: actorImageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='person'
size={80}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
{/* Right side - Info */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Actor name */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
numberOfLines={2}
>
{item.Name}
</Text>
{/* Production year / Birth year */}
{item.ProductionYear && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 16,
}}
>
{item.ProductionYear}
</Text>
)}
{/* Biography */}
{item.Overview && (
<Text
style={{
fontSize: typography.body,
color: "#D1D5DB",
lineHeight: typography.body * 1.4,
maxWidth: SCREEN_WIDTH * 0.45,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
)}
</View>
</View>
{/* Filmography sections */}
<View style={{ flex: 1, overflow: "visible" }}>
{/* Movies Section */}
{isLoadingMovies ? (
<View
style={{
height: 300,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
) : (
movies.length > 0 && (
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.movies")}
</Text>
<FlatList
horizontal
data={movies}
keyExtractor={(filmItem) => filmItem.Id!}
renderItem={renderMovieItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={6}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
</View>
)
)}
{/* Series Section */}
{isLoadingSeries ? (
<View
style={{
height: 300,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
) : (
series.length > 0 && (
<View>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.shows")}
</Text>
<FlatList
horizontal
data={series}
keyExtractor={(filmItem) => filmItem.Id!}
renderItem={renderSeriesItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={6}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
</View>
)
)}
{/* Empty state - only show if both sections are empty and not loading */}
{!isLoadingMovies &&
!isLoadingSeries &&
movies.length === 0 &&
series.length === 0 && (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: SCALE_PADDING,
}}
>
{t("common.no_results")}
</Text>
)}
</View>
</ScrollView>
</View>
);
};

View File

@@ -1,8 +1,17 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
@@ -28,7 +37,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<Host
style={{
@@ -40,43 +49,54 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Menu
label={
<Button
variant='glass'
modifiers={[]}
modifiers={[buttonStyle("glass")]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}}
/>
<Picker
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
</ContextMenu>
}
>
<Menu
label={`${t("library.filters.sort_by")}: ${t(
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
)}`}
>
{sortOptions.map((item) => {
const isSelected =
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
return (
<Button
key={item}
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() =>
setJellyseerrOrderBy(
item as unknown as JellyseerrSearchSort,
)
}
/>
);
})}
</Menu>
<Menu
label={`${t("library.filters.sort_order")}: ${t(
`library.filters.${jellyseerrSortOrder}`,
)}`}
>
{orderOptions.map((item) => {
const isSelected = jellyseerrSortOrder === item;
return (
<Button
key={item}
label={t(`library.filters.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() => setJellyseerrSortOrder(item)}
/>
);
})}
</Menu>
</Menu>
</Host>
);
}

View File

@@ -1,7 +1,16 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, HStack, Spacer } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
@@ -15,42 +24,31 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
setSearchType,
t,
}) => {
if (Platform.OS === "ios") {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Host style={{ height: 40, flex: 1 }}>
<HStack spacing={8}>
<Button
variant={searchType === "Library" ? "glassProminent" : "glass"}
modifiers={[
buttonStyle(
searchType === "Library" ? "glassProminent" : "glass",
),
]}
onPress={() => setSearchType("Library")}
>
{t("search.library")}
</Button>
</Host>
<Host
style={{
height: 40,
width: 100,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
label={t("search.library")}
/>
<Button
variant={searchType === "Discover" ? "glassProminent" : "glass"}
modifiers={[
buttonStyle(
searchType === "Discover" ? "glassProminent" : "glass",
),
]}
onPress={() => setSearchType("Discover")}
>
{t("search.discover")}
</Button>
</Host>
</>
label={t("search.discover")}
/>
<Spacer />
</HStack>
</Host>
);
}

View File

@@ -0,0 +1,454 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps {
item: MovieResult | TvResult;
onPress: () => void;
isFirstItem?: boolean;
}
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item,
onPress,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
const posterUrl = item.posterPath
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
: null;
const title = getTitle(item);
const year = getYear(item);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem}
>
<Animated.View
style={[
animatedStyle,
{
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#262626",
}}
>
<Ionicons
name='image-outline'
size={40}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
{isInLibrary && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
}}
numberOfLines={2}
>
{title}
</Text>
{year && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{year}
</Text>
)}
</Animated.View>
</Pressable>
);
};
interface TVJellyseerrPersonPosterProps {
item: PersonResult;
onPress: () => void;
}
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
item,
onPress,
}) => {
const typography = useScaledTVTypography();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
const posterUrl = item.profilePath
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null;
return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View
style={[
animatedStyle,
{
width: 160,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
marginTop: 12,
textAlign: "center",
}}
numberOfLines={2}
>
{item.name}
</Text>
</Animated.View>
</Pressable>
);
};
interface TVJellyseerrMovieSectionProps {
title: string;
items: MovieResult[];
isFirstSection?: boolean;
onItemPress: (item: MovieResult) => void;
}
const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
title,
items,
isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
<TVJellyseerrPoster
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
/>
)}
/>
</View>
);
};
interface TVJellyseerrTvSectionProps {
title: string;
items: TvResult[];
isFirstSection?: boolean;
onItemPress: (item: TvResult) => void;
}
const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
title,
items,
isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
<TVJellyseerrPoster
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
/>
)}
/>
</View>
);
};
interface TVJellyseerrPersonSectionProps {
title: string;
items: PersonResult[];
isFirstSection?: boolean;
onItemPress: (item: PersonResult) => void;
}
const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
title,
items,
isFirstSection: _isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item }) => (
<TVJellyseerrPersonPoster
item={item}
onPress={() => onItemPress(item)}
/>
)}
/>
</View>
);
};
export interface TVJellyseerrSearchResultsProps {
movieResults: MovieResult[];
tvResults: TvResult[];
personResults: PersonResult[];
loading: boolean;
noResults: boolean;
searchQuery: string;
onMoviePress: (item: MovieResult) => void;
onTvPress: (item: TvResult) => void;
onPersonPress: (item: PersonResult) => void;
}
export const TVJellyseerrSearchResults: React.FC<
TVJellyseerrSearchResultsProps
> = ({
movieResults,
tvResults,
personResults,
loading,
noResults,
searchQuery,
onMoviePress,
onTvPress,
onPersonPress,
}) => {
const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) {
return null;
}
if (noResults && searchQuery.length > 0) {
return (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
"{searchQuery}"
</Text>
</View>
);
}
return (
<View>
<TVJellyseerrMovieSection
title={t("search.request_movies")}
items={movieResults}
isFirstSection={hasMovies}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={!hasMovies && hasTv}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={!hasMovies && !hasTv && hasPersons}
onItemPress={onPersonPress}
/>
</View>
);
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVSearchBadgeProps {
label: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
label,
onPress,
hasTVPreferredFocus = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ duration: 150 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,352 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } 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 { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TvSearchView } from "@/modules/tv-search";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { scaleSize } from "@/utils/scaleSize";
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
// here — not the whole keyboard. Tunable once seen on device.
const SEARCH_AREA_HEIGHT = 250;
const SECTION_GAP = 10;
const SCALE_PADDING = 20;
// Loading skeleton for TV.
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
// padding, heading typography, poster radius) so the placeholder lines up with
// the real content that replaces it.
const TVLoadingSkeleton: React.FC = () => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const itemWidth = sizes.posters.poster;
return (
<View style={{ overflow: "visible" }}>
{/* Section header placeholder — matches the heading typography + margins */}
<View
style={{
width: itemWidth,
height: typography.heading,
backgroundColor: "#262626",
borderRadius: 8,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
}}
/>
<View
style={{
flexDirection: "row",
gap: sizes.gaps.item,
paddingLeft: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: itemWidth }}>
<View
style={{
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: 10 / 15,
borderRadius: scaleSize(24),
marginBottom: scaleSize(8),
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
</View>
);
};
type SearchType = "Library" | "Discover";
interface TVSearchPageProps {
search: string;
setSearch: (text: string) => void;
debouncedSearch: string;
// Library search results
movies?: BaseItemDto[];
series?: BaseItemDto[];
episodes?: BaseItemDto[];
collections?: BaseItemDto[];
actors?: BaseItemDto[];
artists?: BaseItemDto[];
albums?: BaseItemDto[];
songs?: BaseItemDto[];
playlists?: BaseItemDto[];
loading: boolean;
noResults: boolean;
onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
// Jellyseerr/Discover props
searchType: SearchType;
setSearchType: (type: SearchType) => void;
showDiscover: boolean;
jellyseerrMovies?: MovieResult[];
jellyseerrTv?: TvResult[];
jellyseerrPersons?: PersonResult[];
jellyseerrLoading?: boolean;
jellyseerrNoResults?: boolean;
onJellyseerrMoviePress?: (item: MovieResult) => void;
onJellyseerrTvPress?: (item: TvResult) => void;
onJellyseerrPersonPress?: (item: PersonResult) => void;
// Discover sliders for empty state
discoverSliders?: DiscoverSlider[];
}
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
setSearch,
debouncedSearch,
movies,
series,
episodes,
collections,
actors,
artists,
albums,
songs,
playlists,
loading,
noResults,
onItemPress,
onItemLongPress,
searchType,
setSearchType,
showDiscover,
jellyseerrMovies = [],
jellyseerrTv = [],
jellyseerrPersons = [],
jellyseerrLoading = false,
jellyseerrNoResults = false,
onJellyseerrMoviePress,
onJellyseerrTvPress,
onJellyseerrPersonPress,
discoverSliders,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
// Image URL getter for music items
const getImageUrl = useMemo(() => {
return (item: BaseItemDto): string | undefined => {
if (!api) return undefined;
const url = getPrimaryImageUrl({ api, item });
return url ?? undefined;
};
}, [api]);
// Determine which section should have initial focus
const sections = useMemo(() => {
const allSections: {
key: string;
title: string;
items: BaseItemDto[] | undefined;
orientation?: "horizontal" | "vertical";
}[] = [
{ key: "movies", title: t("search.movies"), items: movies },
{ key: "series", title: t("search.series"), items: series },
{
key: "episodes",
title: t("search.episodes"),
items: episodes,
orientation: "horizontal" as const,
},
{
key: "collections",
title: t("search.collections"),
items: collections,
},
{ key: "actors", title: t("search.actors"), items: actors },
{ key: "artists", title: t("search.artists"), items: artists },
{ key: "albums", title: t("search.albums"), items: albums },
{ key: "songs", title: t("search.songs"), items: songs },
{ key: "playlists", title: t("search.playlists"), items: playlists },
];
return allSections.filter((s) => s.items && s.items.length > 0);
}, [
movies,
series,
episodes,
collections,
actors,
artists,
albums,
songs,
playlists,
t,
]);
const isLibraryMode = searchType === "Library";
const isDiscoverMode = searchType === "Discover";
const currentLoading = isLibraryMode ? loading : jellyseerrLoading;
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
return (
<View style={{ flex: 1 }}>
{/* Sticky header: search field stays pinned while results scroll below. */}
<View
style={{
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
</View>
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
/>
</View>
)}
{/* Loading State */}
{currentLoading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
/>
))}
</View>
)}
{/* Jellyseerr/Discover Search Results */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
<TVJellyseerrSearchResults
movieResults={jellyseerrMovies}
tvResults={jellyseerrTv}
personResults={jellyseerrPersons}
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}
/>
)}
{/* Discover Content (when no search query in Discover mode) */}
{isDiscoverMode &&
!jellyseerrLoading &&
debouncedSearch.length === 0 && (
<TVDiscover sliders={discoverSliders} />
)}
{/* No Results State */}
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>
)}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,311 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useCallback, useEffect, useRef, useState } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
const SCALE_PADDING = 20;
interface TVSearchSectionProps extends ViewProps {
title: string;
items: BaseItemDto[];
orientation?: "horizontal" | "vertical";
disabled?: boolean;
isFirstSection?: boolean;
onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** Override the horizontal edge padding (defaults to the scaled TV padding). */
horizontalPadding?: number;
}
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
title,
items,
orientation = "vertical",
disabled = false,
isFirstSection = false,
onItemPress,
onItemLongPress,
imageUrlGetter,
horizontalPadding,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// Track focus count for section
useEffect(() => {
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleItemFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
const itemWidth =
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth, ITEM_GAP],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
// Special handling for MusicArtist (circular avatar)
if (item.Type === "MusicArtist") {
const imageUrl = imageUrlGetter?.(item);
return (
<View style={{ marginRight: ITEM_GAP, width: 160 }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
<View
style={{
width: 160,
height: 160,
borderRadius: 80,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 48 }}>👤</Text>
</View>
)}
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
// Special handling for MusicAlbum, Audio, Playlist (square images)
if (
item.Type === "MusicAlbum" ||
item.Type === "Audio" ||
item.Type === "Playlist"
) {
const imageUrl = imageUrlGetter?.(item);
const icon =
item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵";
return (
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
<View
style={{
width: posterSizes.poster,
height: posterSizes.poster,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 64 }}>{icon}</Text>
</View>
)}
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
{item.Type === "MusicAlbum" && (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
)}
{item.Type === "Audio" && (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
)}
{item.Type === "Playlist" && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ChildCount} tracks
</Text>
)}
</View>
</View>
);
}
// Use TVPosterCard for all other item types
return (
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
width={itemWidth}
/>
</View>
);
},
[
orientation,
isFirstSection,
itemWidth,
onItemPress,
onItemLongPress,
handleItemFocus,
handleItemBlur,
disabled,
imageUrlGetter,
posterSizes.poster,
typography.callout,
ITEM_GAP,
],
);
if (!items || items.length === 0) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
{/* Section Header */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: edgePadding,
letterSpacing: 0.5,
}}
>
{title}
</Text>
<FlatList
ref={flatListRef}
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
}}
/>
</View>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
type SearchType = "Library" | "Discover";
interface TVSearchTabBadgeProps {
label: string;
isSelected: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
label,
isSelected,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ duration: 150 });
// Design language: white for focused/selected, transparent white for unfocused
const getBackgroundColor = () => {
if (focused) return "#fff";
if (isSelected) return "rgba(255,255,255,0.25)";
return "rgba(255,255,255,0.1)";
};
const getTextColor = () => {
if (focused) return "#000";
return "#fff";
};
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
backgroundColor: getBackgroundColor(),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: getTextColor(),
fontWeight: isSelected || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export interface TVSearchTabBadgesProps {
searchType: SearchType;
setSearchType: (type: SearchType) => void;
showDiscover: boolean;
disabled?: boolean;
}
export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
searchType,
setSearchType,
showDiscover,
disabled = false,
}) => {
if (!showDiscover) {
return null;
}
return (
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 24,
}}
>
<TVSearchTabBadge
label='Library'
isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")}
disabled={disabled}
/>
<TVSearchTabBadge
label='Discover'
isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")}
disabled={disabled}
/>
</View>
);
};

View File

@@ -0,0 +1,89 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useCallback } from "react";
import { ScrollView, View } from "react-native";
import { TVHorizontalList } from "@/components/tv/TVHorizontalList";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
interface TVEpisodeListProps {
episodes: BaseItemDto[];
/** Shows "Now Playing" badge on the episode matching this ID */
currentEpisodeId?: string;
/** Disable all cards (e.g., when modal is open) */
disabled?: boolean;
/** Handler when an episode is pressed */
onEpisodePress: (episode: BaseItemDto) => void;
/** Called when any episode is long-pressed */
onEpisodeLongPress?: (episode: BaseItemDto) => void;
/** Called when any episode gains focus */
onFocus?: () => void;
/** Called when any episode loses focus */
onBlur?: () => void;
/** Ref for programmatic scrolling */
scrollViewRef?: React.RefObject<ScrollView | null>;
/** Setter for the first episode ref (for focus guide destinations) */
firstEpisodeRefSetter?: (ref: View | null) => void;
/** Text to show when episodes array is empty */
emptyText?: string;
/** Horizontal padding for the list content */
horizontalPadding?: number;
}
export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
episodes,
currentEpisodeId,
disabled = false,
onEpisodePress,
onEpisodeLongPress,
onFocus,
onBlur,
scrollViewRef,
firstEpisodeRefSetter,
emptyText,
horizontalPadding,
}) => {
const renderItem = useCallback(
({ item: episode, index }: { item: BaseItemDto; index: number }) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVPosterCard
item={episode}
orientation='horizontal'
onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
},
[
currentEpisodeId,
disabled,
firstEpisodeRefSetter,
onBlur,
onEpisodeLongPress,
onEpisodePress,
onFocus,
],
);
const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []);
return (
<TVHorizontalList
data={episodes}
keyExtractor={keyExtractor}
renderItem={renderItem}
emptyText={emptyText}
scrollViewRef={scrollViewRef}
horizontalPadding={horizontalPadding}
/>
);
};

View File

@@ -0,0 +1,140 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo } from "react";
import { Dimensions, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
interface TVSeriesHeaderProps {
item: BaseItemDto;
}
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const logoUrl = useMemo(() => {
if (!api || !item) return null;
return getLogoImageUrlById({ api, item });
}, [api, item]);
const yearString = useMemo(() => {
const startYear = item.StartDate
? new Date(item.StartDate).getFullYear()
: item.ProductionYear;
const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null;
if (startYear && endYear) {
if (startYear === endYear) return String(startYear);
return `${startYear} - ${endYear}`;
}
if (startYear) return String(startYear);
return null;
}, [item.StartDate, item.EndDate, item.ProductionYear]);
return (
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
width: "80%",
marginBottom: 24,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
numberOfLines={2}
>
{item.Name}
</Text>
)}
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
marginBottom: 20,
}}
>
{yearString && (
<Text style={{ color: "white", fontSize: typography.body }}>
{yearString}
</Text>
)}
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating != null && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant='gray'
iconLeft={<Ionicons name='star' size={16} color='gold' />}
/>
)}
</View>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={item.Genres} />
</View>
)}
{/* Overview */}
{item.Overview && (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
maxWidth: SCREEN_WIDTH * 0.45,
alignSelf: "flex-start",
}}
>
<View
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text
style={{
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
</View>
</BlurView>
)}
</View>
);
};

View File

@@ -0,0 +1,635 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { LinearGradient } from "expo-linear-gradient";
import { useSegments } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import {
buildOfflineSeasons,
getDownloadedEpisodesForSeason,
} from "@/utils/downloads/offline-series";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const POSTER_WIDTH_PERCENT = 0.22;
const SCALE_PADDING = 20;
interface TVSeriesPageProps {
item: BaseItemDto;
allEpisodes?: BaseItemDto[];
isLoading?: boolean;
}
// Focusable button component for TV
const TVFocusableButton: React.FC<{
onPress: () => void;
children: React.ReactNode;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
variant?: "primary" | "secondary";
refSetter?: (ref: View | null) => void;
}> = ({
onPress,
children,
hasTVPreferredFocus,
disabled = false,
variant = "primary",
refSetter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isPrimary = variant === "primary";
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};
// Season selector button
const TVSeasonButton: React.FC<{
seasonName: string;
onPress: () => void;
disabled?: boolean;
}> = ({ seasonName, onPress, disabled = false }) => {
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
}}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
}}
>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "bold",
}}
>
{seasonName}
</Text>
<Ionicons
name='chevron-down'
size={28}
color={focused ? "#000" : "#FFFFFF"}
/>
</View>
</Animated.View>
</Pressable>
);
};
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
item,
allEpisodes = [],
isLoading: _isLoading,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const isOffline = useOfflineMode();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
const { showSeasonModal } = useTVSeriesSeasonModal();
const { showItemActions } = useTVItemActionModal();
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
const isSeasonModalVisible = seasonModalState !== null;
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item.Id);
// Season state
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const selectedSeasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""] ?? 1,
[item.Id, seasonIndexState],
);
// Focus guide refs (using useState to trigger re-renders when refs are set)
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// ScrollView ref for page scrolling
const mainScrollRef = useRef<ScrollView>(null);
// ScrollView ref for scrolling back
const episodeListRef = useRef<ScrollView>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// Track focus count for episode list
useEffect(() => {
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleEpisodeFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const handleEpisodeBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Fetch seasons
const { data: seasons = [] } = useQuery({
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
}
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
{
params: {
userId: user.Id,
itemId: item.Id,
Fields: "ItemCounts,PrimaryImageAspectRatio",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
return response.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
// Get selected season ID
const selectedSeasonId = useMemo(() => {
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.Id ?? null;
}, [seasons, selectedSeasonIndex]);
// Get selected season number for offline mode
const selectedSeasonNumber = useMemo(() => {
if (!isOffline) return null;
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.IndexNumber ?? null;
}, [isOffline, seasons, selectedSeasonIndex]);
// Fetch episodes for selected season
const { data: episodesForSeason = [] } = useQuery({
queryKey: [
"episodes",
item.Id,
isOffline ? selectedSeasonNumber : selectedSeasonId,
isOffline,
downloadedItems.length,
],
queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeason(
getDownloadedItems(),
item.Id!,
selectedSeasonNumber!,
);
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Find next unwatched episode
const nextUnwatchedEpisode = useMemo(() => {
// First check all episodes for a "next up" candidate
for (const ep of allEpisodes) {
if (!ep.UserData?.Played) {
// Check if it has progress (continue watching)
if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
return ep;
}
}
}
// Find first unwatched
return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
}, [allEpisodes]);
// Get season name for button
const selectedSeasonName = useMemo(() => {
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.Name || `Season ${selectedSeasonIndex}`;
}, [seasons, selectedSeasonIndex]);
// Handle episode press
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, from);
router.push(navigation as any);
},
[from, router],
);
// Handle play next episode
const handlePlayNextEpisode = useCallback(() => {
if (nextUnwatchedEpisode) {
const navigation = getItemNavigation(nextUnwatchedEpisode, from);
router.push(navigation as any);
}
}, [nextUnwatchedEpisode, from, router]);
// Handle season selection
const handleSeasonSelect = useCallback(
(seasonIdx: number) => {
if (!item.Id) return;
setSeasonIndexState((prev) => ({
...prev,
[item.Id!]: seasonIdx,
}));
},
[item.Id, setSeasonIndexState],
);
// Season options for the modal
const seasonOptions = useMemo(() => {
return seasons.map((season: BaseItemDto) => ({
label: season.Name || `Season ${season.IndexNumber}`,
value: season.IndexNumber ?? 0,
selected:
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex),
}));
}, [seasons, selectedSeasonIndex]);
// Open season modal
const handleOpenSeasonModal = useCallback(() => {
if (!item.Id) return;
showSeasonModal({
seasons: seasonOptions,
selectedSeasonIndex,
itemId: item.Id,
onSeasonSelect: handleSeasonSelect,
});
}, [
item.Id,
seasonOptions,
selectedSeasonIndex,
handleSeasonSelect,
showSeasonModal,
]);
// Get play button text
const playButtonText = useMemo(() => {
if (!nextUnwatchedEpisode) return t("common.play");
const season = nextUnwatchedEpisode.ParentIndexNumber;
const episode = nextUnwatchedEpisode.IndexNumber;
const hasProgress =
(nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
if (hasProgress) {
return `${t("home.continue")} S${season}:E${episode}`;
}
return `${t("common.play")} S${season}:E${episode}`;
}, [nextUnwatchedEpisode, t]);
if (!item) return null;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Full-screen backdrop */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<ItemImage
variant='Backdrop'
item={item}
style={{ width: "100%", height: "100%" }}
/>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content */}
<ScrollView
ref={mainScrollRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Content + Poster */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
<TVSeriesHeader item={item} />
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginTop: 32,
}}
>
<TVFocusableButton
onPress={handlePlayNextEpisode}
hasTVPreferredFocus={!isSeasonModalVisible}
disabled={isSeasonModalVisible}
variant='primary'
refSetter={setPlayButtonRef}
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{playButtonText}
</Text>
</TVFocusableButton>
{seasons.length > 1 && (
<TVSeasonButton
seasonName={selectedSeasonName}
onPress={handleOpenSeasonModal}
disabled={isSeasonModalVisible}
/>
)}
<TVFavoriteButton item={item} disabled={isSeasonModalVisible} />
</View>
</View>
{/* Right side - Poster */}
<View
style={{
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{ width: "100%", height: "100%" }}
/>
</View>
</View>
</View>
{/* Episodes section */}
<View style={{ marginTop: 40, overflow: "visible" }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
marginLeft: SCALE_PADDING,
}}
>
{selectedSeasonName}
</Text>
{/* Bidirectional focus guides - stacked together above the list */}
{/* Downward: Play button → first episode */}
{firstEpisodeRef && (
<TVFocusGuideView
destinations={[firstEpisodeRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: episodes → Play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<TVEpisodeList
episodes={episodesForSeason}
disabled={isSeasonModalVisible}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
scrollViewRef={episodeListRef}
firstEpisodeRefSetter={setFirstEpisodeRef}
emptyText={t("item_card.no_episodes_for_this_season")}
horizontalPadding={HORIZONTAL_PADDING}
/>
</View>
</ScrollView>
</View>
);
};

View File

@@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
>

View File

@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
{ key: "home.settings.buffer.cache_auto", value: "auto" },
{ key: "home.settings.buffer.cache_yes", value: "yes" },
{ key: "home.settings.buffer.cache_no", value: "no" },
];
export const MpvBufferSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const cacheModeOptions = useMemo(
() => [
{
options: CACHE_MODE_OPTIONS.map((option) => ({
type: "radio" as const,
label: t(option.key),
value: option.value,
selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
})),
},
],
[settings?.mpvCacheEnabled, t, updateSettings],
);
const currentCacheModeLabel = useMemo(() => {
const option = CACHE_MODE_OPTIONS.find(
(o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
);
return option ? t(option.key) : t("home.settings.buffer.cache_auto");
}, [settings?.mpvCacheEnabled, t]);
if (!settings) return null;
return (
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
<ListItem title={t("home.settings.buffer.cache_mode")}>
<PlatformDropdown
groups={cacheModeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheModeLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.buffer.cache_mode")}
/>
</ListItem>
<ListItem title={t("home.settings.buffer.buffer_duration")}>
<Stepper
value={settings.mpvCacheSeconds ?? 10}
step={5}
min={5}
max={120}
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
appendValue='s'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_cache_size")}>
<Stepper
value={settings.mpvDemuxerMaxBytes ?? 150}
step={25}
min={50}
max={500}
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
appendValue=' MB'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
<Stepper
value={settings.mpvDemuxerMaxBackBytes ?? 50}
step={25}
min={25}
max={200}
onUpdate={(value) =>
updateSettings({ mpvDemuxerMaxBackBytes: value })
}
appendValue=' MB'
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { Platform, View, type ViewProps } from "react-native";
import { Platform, Switch, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -55,7 +55,6 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return [{ options }];
}, [settings?.mpvSubtitleAlignY, updateSettings]);
if (isTv) return null;
if (!settings) return null;
return (
@@ -68,65 +67,83 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</Text>
}
>
<ListItem title='Subtitle Scale'>
<Stepper
value={settings.mpvSubtitleScale ?? 1.0}
step={0.1}
min={0.5}
max={2.0}
onUpdate={(value) =>
updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
{!isTv && (
<>
<ListItem title='Vertical Margin'>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
min={0}
max={100}
onUpdate={(value) =>
updateSettings({ mpvSubtitleMarginY: value })
}
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Horizontal Alignment'
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Vertical Alignment'
/>
</ListItem>
</>
)}
<ListItem title='Opaque Background'>
<Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) =>
updateSettings({ mpvSubtitleBackgroundEnabled: value })
}
/>
</ListItem>
<ListItem title='Vertical Margin'>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
min={0}
max={100}
onUpdate={(value) => updateSettings({ mpvSubtitleMarginY: value })}
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Horizontal Alignment'
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Vertical Alignment'
/>
</ListItem>
{settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'>
<Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5}
min={10}
max={100}
appendValue='%'
onUpdate={(value) =>
updateSettings({ mpvSubtitleBackgroundOpacity: value })
}
/>
</ListItem>
)}
</ListGroup>
</View>
);

View File

@@ -0,0 +1,66 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
{ key: "home.settings.vo_driver.gpu", value: "gpu" },
];
export const MpvVoSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const voDriverOptions = useMemo(
() => [
{
options: VO_DRIVER_OPTIONS.map((option) => ({
type: "radio" as const,
label: t(option.key),
value: option.value,
selected: option.value === (settings?.mpvVoDriver ?? "gpu-next"),
onPress: () => updateSettings({ mpvVoDriver: option.value }),
})),
},
],
[settings?.mpvVoDriver, t, updateSettings],
);
const currentVoDriverLabel = useMemo(() => {
const option = VO_DRIVER_OPTIONS.find(
(o) => o.value === (settings?.mpvVoDriver ?? "gpu-next"),
);
return option ? t(option.key) : t("home.settings.vo_driver.gpu_next");
}, [settings?.mpvVoDriver, t]);
// Only show on Android
if (Platform.OS !== "android") return null;
if (!settings) return null;
return (
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
<PlatformDropdown
groups={voDriverOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentVoDriverLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.vo_driver.vo_mode")}
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}

View File

@@ -1,9 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Input } from "@/components/common/Input";
import { Stepper } from "@/components/inputs/Stepper";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -23,6 +24,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures;
const { t } = useTranslation();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings?.openSubtitlesApiKey || "",
);
const subtitleModes = [
SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart,
@@ -160,17 +166,55 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
disabled={pluginSettings?.subtitleSize?.locked}
>
<Stepper
value={settings.subtitleSize / 100}
value={settings.mpvSubtitleScale ?? 1.0}
disabled={pluginSettings?.subtitleSize?.locked}
step={0.1}
min={0.3}
max={1.5}
min={0.1}
max={3.0}
onUpdate={(value) =>
updateSettings({ subtitleSize: Math.round(value * 100) })
updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
}
/>
</ListItem>
</ListGroup>
{/* OpenSubtitles API Key for client-side subtitle fetching */}
<ListGroup
title={
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.subtitles.opensubtitles_hint") ||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
</Text>
}
>
<View className='p-4'>
<Text className='text-xs text-gray-400 mb-2'>
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
</Text>
<Input
className='border border-neutral-800'
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
value={openSubtitlesApiKey}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => {
updateSettings({ openSubtitlesApiKey });
}}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
/>
<Text className='text-xs text-gray-500 mt-2'>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
</View>
</ListGroup>
</View>
);
};

View File

@@ -1,18 +1,13 @@
import type { ParamListBase, RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { Platform } from "react-native";
import { HeaderBackButton } from "../common/HeaderBackButton";
type ICommonScreenOptions =
| NativeStackNavigationOptions
| ((prop: {
route: RouteProp<ParamListBase, string>;
navigation: any;
}) => NativeStackNavigationOptions);
type ICommonScreenOptions = ComponentProps<typeof Stack.Screen>["options"];
export const commonScreenOptions: ICommonScreenOptions = {
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerBlurEffect: "none",

View File

@@ -0,0 +1,119 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVActorCardProps {
person: {
Id?: string | null;
Name?: string | null;
Role?: string | null;
};
apiBasePath?: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
const imageUrl = person.Id
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90`
: null;
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: scaleSize(160),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? scaleSize(16) : 0,
},
]}
>
<View
style={{
width: scaleSize(140),
height: scaleSize(140),
borderRadius: scaleSize(70),
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: scaleSize(14),
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='person'
size={scaleSize(56)}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: scaleSize(4),
}}
numberOfLines={2}
>
{person.Name}
</Text>
{person.Role && (
<Text
style={{
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
fontWeight: "500",
}}
numberOfLines={2}
>
{person.Role}
</Text>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,56 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { LinearGradient } from "expo-linear-gradient";
import React from "react";
import { View } from "react-native";
import { ItemImage } from "@/components/common/ItemImage";
export interface TVBackdropProps {
item: BaseItemDto;
}
export const TVBackdrop: React.FC<TVBackdropProps> = React.memo(({ item }) => {
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<ItemImage
variant='Backdrop'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
);
});

116
components/tv/TVButton.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React from "react";
import { Animated, Pressable, View, type ViewStyle } from "react-native";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVButtonProps {
onPress: () => void;
children: React.ReactNode;
variant?: "primary" | "secondary" | "glass";
hasTVPreferredFocus?: boolean;
disabled?: boolean;
style?: ViewStyle;
scaleAmount?: number;
square?: boolean;
refSetter?: (ref: View | null) => void;
nextFocusDown?: number;
nextFocusUp?: number;
}
const getButtonStyles = (
variant: "primary" | "secondary" | "glass",
focused: boolean,
) => {
switch (variant) {
case "glass":
return {
backgroundColor: focused
? "rgba(255, 255, 255, 0.25)"
: "rgba(255, 255, 255, 0.1)",
shadowColor: "#fff",
borderWidth: 1,
borderColor: focused
? "rgba(255, 255, 255, 0.4)"
: "rgba(255, 255, 255, 0.15)",
};
case "secondary":
return {
backgroundColor: focused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.15)",
shadowColor: "#fff",
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)",
};
default:
return {
backgroundColor: focused ? "#ffffff" : "rgba(255, 255, 255, 0.9)",
shadowColor: "#fff",
borderWidth: 1,
borderColor: "transparent",
};
}
};
export const TVButton: React.FC<TVButtonProps> = ({
onPress,
children,
variant = "primary",
hasTVPreferredFocus = false,
disabled = false,
style,
scaleAmount = 1.04,
square = false,
refSetter,
nextFocusDown,
nextFocusUp,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount });
const buttonStyles = getButtonStyles(variant, focused);
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
nextFocusDown={nextFocusDown}
nextFocusUp={nextFocusUp}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: buttonStyles.shadowColor,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 15 : 0,
},
style,
]}
>
<View
style={{
backgroundColor: buttonStyles.backgroundColor,
borderWidth: buttonStyles.borderWidth,
borderColor: buttonStyles.borderColor,
borderRadius: scaleSize(12),
paddingVertical: scaleSize(18),
paddingHorizontal: square ? scaleSize(18) : scaleSize(32),
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: square ? undefined : scaleSize(180),
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,63 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVCancelButtonProps {
onPress: () => void;
label?: string;
disabled?: boolean;
}
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
onPress,
label = "Cancel",
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: scaleSize(20),
paddingVertical: scaleSize(12),
borderRadius: scaleSize(10),
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
},
]}
>
<Ionicons
name='close'
size={scaleSize(20)}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,78 @@
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
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";
import { scaleSize } from "@/utils/scaleSize";
export interface TVCastCrewTextProps {
director?: BaseItemPerson | null;
cast?: BaseItemPerson[];
/** Hide the cast section (e.g., when visual cast section is shown) */
hideCast?: boolean;
}
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
({ director, cast, hideCast = false }) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
if (!director && (!cast || cast.length === 0)) {
return null;
}
return (
<View style={{ marginBottom: scaleSize(32) }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: scaleSize(16),
}}
>
{t("item_card.cast_and_crew")}
</Text>
<View style={{ flexDirection: "row", gap: scaleSize(40) }}>
{director && (
<View>
<Text
style={{
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: scaleSize(4),
}}
>
{t("item_card.director")}
</Text>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{director.Name}
</Text>
</View>
)}
{!hideCast && cast && cast.length > 0 && (
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: scaleSize(4),
}}
>
{t("item_card.cast")}
</Text>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{cast.map((c) => c.Name).join(", ")}
</Text>
</View>
)}
</View>
</View>
);
},
);

View File

@@ -0,0 +1,85 @@
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TVFocusGuideView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVActorCard } from "./TVActorCard";
export interface TVCastSectionProps {
cast: BaseItemPerson[];
apiBasePath?: string;
onActorPress: (personId: string) => void;
/** Setter function for the first actor card ref (for focus guide) */
firstActorRefSetter?: (ref: View | null) => void;
/** Ref to focus guide destination for upward navigation */
upwardFocusDestination?: View | null;
/** Custom horizontal padding (overrides default 80) */
horizontalPadding?: number;
}
export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
({
cast,
apiBasePath,
onActorPress,
firstActorRefSetter,
upwardFocusDestination,
horizontalPadding = 80,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
if (cast.length === 0) {
return null;
}
return (
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
}}
>
{t("item_card.cast")}
</Text>
{/* Focus guide to direct upward navigation from cast back to options */}
{upwardFocusDestination && (
<TVFocusGuideView
destinations={[upwardFocusDestination]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: horizontalPadding,
paddingVertical: 16,
gap: sizes.gaps.item,
}}
>
{cast.map((person, index) => (
<TVActorCard
key={person.Id || index}
ref={index === 0 ? firstActorRefSetter : undefined}
person={person}
apiBasePath={apiBasePath}
onPress={() => {
if (person.Id) {
onActorPress(person.Id);
}
}}
/>
))}
</ScrollView>
</View>
);
},
);

View File

@@ -0,0 +1,82 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import {
Pressable,
Animated as RNAnimated,
StyleSheet,
type View,
} from "react-native";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVControlButtonProps {
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
onLongPress?: () => void;
onPressOut?: () => void;
disabled?: boolean;
hasTVPreferredFocus?: boolean;
size?: number;
delayLongPress?: number;
/** Callback ref setter for focus guide destination pattern */
refSetter?: (ref: View | null) => void;
}
export const TVControlButton: FC<TVControlButtonProps> = ({
icon,
onPress,
onLongPress,
onPressOut,
disabled,
hasTVPreferredFocus,
size = 32,
delayLongPress = 300,
refSetter,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
return (
<Pressable
ref={refSetter}
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}
delayLongPress={delayLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
>
<RNAnimated.View
style={[
styles.button,
animatedStyle,
{
backgroundColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.2)",
opacity: disabled ? 0.3 : 1,
},
]}
>
<Ionicons name={icon} size={scaleSize(size)} color='#fff' />
</RNAnimated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
width: scaleSize(64),
height: scaleSize(64),
borderRadius: scaleSize(32),
borderWidth: scaleSize(2),
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -0,0 +1,32 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { useFavorite } from "@/hooks/useFavorite";
import { TVButton } from "./TVButton";
export interface TVFavoriteButtonProps {
item: BaseItemDto;
disabled?: boolean;
}
export const TVFavoriteButton: React.FC<TVFavoriteButtonProps> = ({
item,
disabled,
}) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
return (
<TVButton
onPress={toggleFavorite}
variant='glass'
square
disabled={disabled}
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={28}
color='#FFFFFF'
/>
</TVButton>
);
};

Some files were not shown because too many files have changed in this diff Show More