mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
Merge branch 'develop' into sync-subtitle/audio-data
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
967
components/ItemContent.tv.tsx
Normal file
967
components/ItemContent.tv.tsx
Normal 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;
|
||||
163
components/ItemContentSkeleton.tv.tsx
Normal file
163
components/ItemContentSkeleton.tv.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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={
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
196
components/chapters/ChapterList.tsx
Normal file
196
components/chapters/ChapterList.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
87
components/chapters/ChapterTicks.tsx
Normal file
87
components/chapters/ChapterTicks.tsx
Normal 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);
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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! },
|
||||
|
||||
532
components/companion/CompanionLoginScreen.tsx
Normal file
532
components/companion/CompanionLoginScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
231
components/home/Favorites.tv.tsx
Normal file
231
components/home/Favorites.tv.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
|
||||
388
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal file
388
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
382
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal file
382
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal 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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
278
components/home/StreamystatsRecommendations.tv.tsx
Normal file
278
components/home/StreamystatsRecommendations.tv.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
672
components/home/TVHeroCarousel.tsx
Normal file
672
components/home/TVHeroCarousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
141
components/inputs/TVPinInput.tsx
Normal file
141
components/inputs/TVPinInput.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
47
components/jellyseerr/discover/TVDiscover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
270
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
270
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
877
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
877
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
519
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
519
components/jellyseerr/tv/TVRequestModal.tsx
Normal 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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
86
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
86
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
117
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
components/jellyseerr/tv/index.ts
Normal file
4
components/jellyseerr/tv/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TVJellyseerrPage } from "./TVJellyseerrPage";
|
||||
export { TVRequestModal } from "./TVRequestModal";
|
||||
export { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||
export { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||
109
components/library/Libraries.tsx
Normal file
109
components/library/Libraries.tsx
Normal 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' />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
406
components/library/TVLibraries.tsx
Normal file
406
components/library/TVLibraries.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
176
components/library/TVLibraryCard.tsx
Normal file
176
components/library/TVLibraryCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
183
components/livetv/TVChannelCard.tsx
Normal file
183
components/livetv/TVChannelCard.tsx
Normal 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 };
|
||||
136
components/livetv/TVChannelsGrid.tsx
Normal file
136
components/livetv/TVChannelsGrid.tsx
Normal 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)",
|
||||
},
|
||||
});
|
||||
146
components/livetv/TVGuideChannelRow.tsx
Normal file
146
components/livetv/TVGuideChannelRow.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
154
components/livetv/TVGuidePageNavigation.tsx
Normal file
154
components/livetv/TVGuidePageNavigation.tsx
Normal 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)",
|
||||
},
|
||||
});
|
||||
148
components/livetv/TVGuideProgramCell.tsx
Normal file
148
components/livetv/TVGuideProgramCell.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
64
components/livetv/TVGuideTimeHeader.tsx
Normal file
64
components/livetv/TVGuideTimeHeader.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
433
components/livetv/TVLiveTVGuide.tsx
Normal file
433
components/livetv/TVLiveTVGuide.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
265
components/livetv/TVLiveTVPage.tsx
Normal file
265
components/livetv/TVLiveTVPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
46
components/livetv/TVLiveTVPlaceholder.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface TVLiveTVPlaceholderProps {
|
||||
tabName: string;
|
||||
}
|
||||
|
||||
export const TVLiveTVPlaceholder: React.FC<TVLiveTVPlaceholderProps> = ({
|
||||
tabName,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.title,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{tabName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
}}
|
||||
>
|
||||
{t("live_tv.coming_soon")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
458
components/login/Login.tsx
Normal file
458
components/login/Login.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
155
components/login/TVAccountCard.tsx
Normal file
155
components/login/TVAccountCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
components/login/TVAddIcon.tsx
Normal file
83
components/login/TVAddIcon.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
123
components/login/TVAddServerForm.tsx
Normal file
123
components/login/TVAddServerForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
185
components/login/TVAddUserForm.tsx
Normal file
185
components/login/TVAddUserForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
components/login/TVBackIcon.tsx
Normal file
83
components/login/TVBackIcon.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
91
components/login/TVInput.tsx
Normal file
91
components/login/TVInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
809
components/login/TVLogin.tsx
Normal file
809
components/login/TVLogin.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
488
components/login/TVPINEntryModal.tsx
Normal file
488
components/login/TVPINEntryModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
352
components/login/TVPasswordEntryModal.tsx
Normal file
352
components/login/TVPasswordEntryModal.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
115
components/login/TVQRCodeDisplay.tsx
Normal file
115
components/login/TVQRCodeDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
440
components/login/TVSaveAccountModal.tsx
Normal file
440
components/login/TVSaveAccountModal.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
119
components/login/TVSaveAccountToggle.tsx
Normal file
119
components/login/TVSaveAccountToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
212
components/login/TVServerIcon.tsx
Normal file
212
components/login/TVServerIcon.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
138
components/login/TVServerSelectionScreen.tsx
Normal file
138
components/login/TVServerSelectionScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
167
components/login/TVUserIcon.tsx
Normal file
167
components/login/TVUserIcon.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
143
components/login/TVUserSelectionScreen.tsx
Normal file
143
components/login/TVUserSelectionScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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={{
|
||||
|
||||
570
components/persons/TVActorPage.tsx
Normal file
570
components/persons/TVActorPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
454
components/search/TVJellyseerrSearchResults.tsx
Normal file
454
components/search/TVJellyseerrSearchResults.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
components/search/TVSearchBadge.tsx
Normal file
56
components/search/TVSearchBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
352
components/search/TVSearchPage.tsx
Normal file
352
components/search/TVSearchPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
311
components/search/TVSearchSection.tsx
Normal file
311
components/search/TVSearchSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
components/search/TVSearchTabBadges.tsx
Normal file
117
components/search/TVSearchTabBadges.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
components/series/TVEpisodeList.tsx
Normal file
89
components/series/TVEpisodeList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
140
components/series/TVSeriesHeader.tsx
Normal file
140
components/series/TVSeriesHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
635
components/series/TVSeriesPage.tsx
Normal file
635
components/series/TVSeriesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
100
components/settings/MpvBufferSettings.tsx
Normal file
100
components/settings/MpvBufferSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
66
components/settings/MpvVoSettings.tsx
Normal file
66
components/settings/MpvVoSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
119
components/tv/TVActorCard.tsx
Normal file
119
components/tv/TVActorCard.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
56
components/tv/TVBackdrop.tsx
Normal file
56
components/tv/TVBackdrop.tsx
Normal 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
116
components/tv/TVButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
components/tv/TVCancelButton.tsx
Normal file
63
components/tv/TVCancelButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
components/tv/TVCastCrewText.tsx
Normal file
78
components/tv/TVCastCrewText.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
85
components/tv/TVCastSection.tsx
Normal file
85
components/tv/TVCastSection.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
82
components/tv/TVControlButton.tsx
Normal file
82
components/tv/TVControlButton.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
32
components/tv/TVFavoriteButton.tsx
Normal file
32
components/tv/TVFavoriteButton.tsx
Normal 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
Reference in New Issue
Block a user