mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 02:28:08 +00:00
fix: sheet
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
|
||||
export default function favorites() {
|
||||
@@ -15,6 +16,10 @@ export default function favorites() {
|
||||
}, []);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (Platform.isTV) {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
|
||||
@@ -17,10 +17,13 @@ import React, {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
BackHandler,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -308,11 +311,33 @@ const TVOptionSelector = <T,>({
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Programmatic focus fallback
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
@@ -337,7 +362,12 @@ const TVOptionSelector = <T,>({
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
@@ -360,42 +390,50 @@ const TVOptionSelector = <T,>({
|
||||
</Text>
|
||||
|
||||
{/* Horizontal options */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Option card for horizontal selector (Apple TV style)
|
||||
const TVOptionCard: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
// Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus
|
||||
const TVOptionCard = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
@@ -409,6 +447,7 @@ const TVOptionCard: React.FC<{
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
@@ -465,7 +504,7 @@ const TVOptionCard: React.FC<{
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// Button to open option selector
|
||||
const TVOptionButton: React.FC<{
|
||||
@@ -606,6 +645,21 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
// Modal state for option selectors
|
||||
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
|
||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
|
||||
// Android TV BackHandler for closing modals
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && isModalOpen) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
() => {
|
||||
setOpenModal(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return () => backHandler.remove();
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Get available audio tracks
|
||||
const audioTracks = useMemo(() => {
|
||||
|
||||
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 { 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 { 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: 32,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: 18,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,235 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
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, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { Animated, Easing, FlatList, Pressable, 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 {
|
||||
TV_LIBRARY_CARD_WIDTH,
|
||||
TVLibraryCard,
|
||||
} from "@/components/library/TVLibraryCard";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
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 = 60;
|
||||
const ITEM_GAP = 24;
|
||||
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 [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 === "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: 32,
|
||||
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: 18,
|
||||
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);
|
||||
@@ -29,85 +237,105 @@ export const TVLibraries: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
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 || null;
|
||||
return response.data.Items || [];
|
||||
},
|
||||
staleTime: 60,
|
||||
staleTime: 60 * 1000,
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const libraries = useMemo(
|
||||
() =>
|
||||
data
|
||||
userViews
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[data, settings?.hiddenLibraries],
|
||||
[userViews, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
// Scroll back to start when section loses focus
|
||||
useEffect(() => {
|
||||
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}
|
||||
prevFocusedCount.current = focusedCount;
|
||||
}, [focusedCount]);
|
||||
// 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";
|
||||
|
||||
const handleItemFocus = useCallback(() => {
|
||||
setFocusedCount((c) => c + 1);
|
||||
}, []);
|
||||
// Fetch count
|
||||
const countResponse = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: library.Id,
|
||||
recursive: true,
|
||||
limit: 0,
|
||||
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||
});
|
||||
|
||||
const handleItemBlur = useCallback(() => {
|
||||
setFocusedCount((c) => Math.max(0, c - 1));
|
||||
}, []);
|
||||
// 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"],
|
||||
});
|
||||
|
||||
const handleItemPress = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(libraries)");
|
||||
router.push(navigation as any);
|
||||
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 === "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 getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: TV_LIBRARY_CARD_WIDTH + ITEM_GAP,
|
||||
offset: (TV_LIBRARY_CARD_WIDTH + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
const isFirstItem = index === 0;
|
||||
|
||||
return (
|
||||
<View style={{ marginRight: ITEM_GAP }}>
|
||||
<TVFocusablePoster
|
||||
onPress={() => handleItemPress(item)}
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
onFocus={handleItemFocus}
|
||||
onBlur={handleItemBlur}
|
||||
>
|
||||
<TVLibraryCard library={item} />
|
||||
</TVFocusablePoster>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleItemPress, handleItemFocus, handleItemBlur],
|
||||
({ item, index }: { item: LibraryWithPreview; index: number }) => (
|
||||
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
|
||||
<TVLibraryRow
|
||||
library={item}
|
||||
isFirst={index === 0}
|
||||
onPress={() => handleLibraryPress(item)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleLibraryPress],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
const isLoading = viewsLoading || dataLoading;
|
||||
const displayLibraries = librariesWithData || libraries;
|
||||
|
||||
if (isLoading && libraries.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -121,7 +349,7 @@ export const TVLibraries: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!libraries || libraries.length === 0) {
|
||||
if (!displayLibraries || displayLibraries.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -141,23 +369,20 @@ export const TVLibraries: React.FC = () => {
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
paddingTop: insets.top + 80,
|
||||
paddingBottom: insets.bottom + 40,
|
||||
}}
|
||||
>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
horizontal
|
||||
data={libraries}
|
||||
data={displayLibraries}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible", flexGrow: 0 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={false}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
import React, {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -17,12 +17,15 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BackHandler,
|
||||
Image,
|
||||
Platform,
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
Easing as RNEasing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
@@ -99,50 +102,87 @@ const TVOptionSelector = <T,>({
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Programmatic focus fallback
|
||||
useEffect(() => {
|
||||
if (isReady && firstCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={selectorStyles.overlay}>
|
||||
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
|
||||
<View style={selectorStyles.content}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={selectorStyles.content}
|
||||
>
|
||||
<Text style={selectorStyles.title}>{title}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={selectorStyles.scrollView}
|
||||
contentContainerStyle={selectorStyles.scrollContent}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={selectorStyles.scrollView}
|
||||
contentContainerStyle={selectorStyles.scrollContent}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Option card for horizontal selector
|
||||
const TVOptionCard: FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
// Option card for horizontal selector (with forwardRef for programmatic focus)
|
||||
const TVOptionCard = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
@@ -156,6 +196,7 @@ const TVOptionCard: FC<{
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
@@ -202,7 +243,7 @@ const TVOptionCard: FC<{
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// Settings panel with tabs for Audio and Subtitles
|
||||
const _TVSettingsPanel: FC<{
|
||||
@@ -782,6 +823,20 @@ export const Controls: FC<Props> = ({
|
||||
// Track which button last opened a modal (for returning focus)
|
||||
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(null);
|
||||
|
||||
// Android TV BackHandler for closing modals
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && isModalOpen) {
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
() => {
|
||||
setOpenModal(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return () => backHandler.remove();
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Get available audio tracks
|
||||
const audioTracks = useMemo(() => {
|
||||
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||
@@ -951,6 +1006,7 @@ export const Controls: FC<Props> = ({
|
||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||
showControls,
|
||||
toggleControls,
|
||||
togglePlay,
|
||||
onBack: handleBack,
|
||||
});
|
||||
|
||||
|
||||
@@ -42,10 +42,12 @@ interface UseRemoteControlProps {
|
||||
* Simplified version - D-pad navigation is handled by native focus system.
|
||||
* This hook handles:
|
||||
* - Showing controls on any button press
|
||||
* - Play/pause button on TV remote
|
||||
*/
|
||||
export function useRemoteControl({
|
||||
showControls,
|
||||
toggleControls,
|
||||
togglePlay,
|
||||
onBack,
|
||||
}: UseRemoteControlProps) {
|
||||
// Keep these for backward compatibility with the component
|
||||
@@ -67,6 +69,14 @@ export function useRemoteControl({
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle play/pause button press on TV remote
|
||||
if (evt.eventType === "playPause") {
|
||||
if (togglePlay) {
|
||||
togglePlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show controls on any D-pad press
|
||||
if (!showControls) {
|
||||
toggleControls();
|
||||
|
||||
Reference in New Issue
Block a user