mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-20 07:44:42 +01:00
wip
This commit is contained in:
@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
animateTo(1.03);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#a855f7",
|
||||
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.9 : 0,
|
||||
shadowRadius: focused ? 18 : 0,
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 10 : 0,
|
||||
elevation: focused ? 12 : 0, // Android glow
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
Pressable,
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface TVInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
@@ -16,6 +14,7 @@ interface TVInputProps extends TextInputProps {
|
||||
|
||||
export const TVInput: React.FC<TVInputProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
hasTVPreferredFocus,
|
||||
style,
|
||||
...props
|
||||
@@ -43,94 +42,40 @@ export const TVInput: React.FC<TVInputProps> = ({
|
||||
animateFocus(false);
|
||||
};
|
||||
|
||||
const displayPlaceholder = placeholder || label;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{label && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: isFocused ? "#FFFFFF" : "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
}}
|
||||
>
|
||||
{/* Outer glow layer - only visible when focused */}
|
||||
{isFocused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
backgroundColor: "#9334E9",
|
||||
borderRadius: 20,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main input container */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isFocused ? "#3a3a3a" : "#1a1a1a",
|
||||
borderWidth: 3,
|
||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Inner highlight bar when focused */}
|
||||
{isFocused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
backgroundColor: "#9334E9",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
allowFontScaling={false}
|
||||
placeholderTextColor={isFocused ? "#AAAAAA" : "#666666"}
|
||||
style={[
|
||||
{
|
||||
height: 68,
|
||||
fontSize: 26,
|
||||
fontWeight: "500",
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: isFocused ? 6 : 0,
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={displayPlaceholder}
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
{
|
||||
height: 68,
|
||||
fontSize: 24,
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.03 : 1,
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
@@ -87,12 +87,14 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<View pointerEvents='none'>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.05 : 1,
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
|
||||
307
components/search/TVSearchPage.tsx
Normal file
307
components/search/TVSearchPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { TVSearchSection } from "./TVSearchSection";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
const SECTION_GAP = 10;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// Loading skeleton for TV
|
||||
const TVLoadingSkeleton: React.FC = () => {
|
||||
const itemWidth = 210;
|
||||
return (
|
||||
<View style={{ overflow: "visible" }}>
|
||||
<View
|
||||
style={{
|
||||
width: 200,
|
||||
height: 28,
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
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: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
marginBottom: 4,
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "#262626",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 6,
|
||||
fontSize: 16,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Placeholder text here
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Example search suggestions for TV
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
interface TVSearchPageProps {
|
||||
search: string;
|
||||
setSearch: (text: string) => void;
|
||||
debouncedSearch: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
movies,
|
||||
series,
|
||||
episodes,
|
||||
collections,
|
||||
actors,
|
||||
artists,
|
||||
albums,
|
||||
songs,
|
||||
playlists,
|
||||
loading,
|
||||
noResults,
|
||||
onItemPress,
|
||||
}) => {
|
||||
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,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 && sections.length === 0
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{!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}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!loading && noResults && debouncedSearch.length > 0 && (
|
||||
<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: "#9334E9" }}>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Example Searches (when no search query) */}
|
||||
{!loading && debouncedSearch.length === 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.search")}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{exampleSearches.map((example) => (
|
||||
<Pressable
|
||||
key={example}
|
||||
onPress={() => setSearch(example)}
|
||||
style={({ focused }) => ({
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
backgroundColor: focused
|
||||
? "#9334E9"
|
||||
: "rgba(255, 255, 255, 0.1)",
|
||||
transform: [{ scale: focused ? 1.05 : 1 }],
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{example}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
344
components/search/TVSearchSection.tsx
Normal file
344
components/search/TVSearchSection.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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 ContinueWatchingPoster, {
|
||||
TV_LANDSCAPE_WIDTH,
|
||||
} from "@/components/ContinueWatchingPoster.tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// TV-specific ItemCardText with larger fonts
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "MusicArtist" ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : item.Type === "MusicAlbum" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Audio" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Playlist" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Person" ? (
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVSearchSectionProps extends ViewProps {
|
||||
title: string;
|
||||
items: BaseItemDto[];
|
||||
orientation?: "horizontal" | "vertical";
|
||||
disabled?: boolean;
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: BaseItemDto) => void;
|
||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||
}
|
||||
|
||||
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
orientation = "vertical",
|
||||
disabled = false,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
imageUrlGetter,
|
||||
...props
|
||||
}) => {
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
|
||||
// When section loses all focus, scroll back to start
|
||||
useEffect(() => {
|
||||
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}
|
||||
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" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: itemWidth + ITEM_GAP,
|
||||
offset: (itemWidth + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[itemWidth],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
const isFirstItem = isFirstSection && index === 0;
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
||||
const renderPoster = () => {
|
||||
// Music Artist - circular avatar
|
||||
if (item.Type === "MusicArtist") {
|
||||
const imageUrl = imageUrlGetter?.(item);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Music Album, 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={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
height: TV_POSTER_WIDTH,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Person (Actor)
|
||||
if (item.Type === "Person") {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
|
||||
// Episode rendering
|
||||
if (item.Type === "Episode" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Episode" && !isHorizontal) {
|
||||
return <SeriesPoster item={item} />;
|
||||
}
|
||||
|
||||
// Movie rendering
|
||||
if (item.Type === "Movie" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Movie" && !isHorizontal) {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
|
||||
// Series rendering
|
||||
if (item.Type === "Series" && !isHorizontal) {
|
||||
return <SeriesPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Series" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
|
||||
// BoxSet (Collection)
|
||||
if (item.Type === "BoxSet" && !isHorizontal) {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
if (item.Type === "BoxSet" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return isHorizontal ? (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
) : (
|
||||
<MoviePoster item={item} />
|
||||
);
|
||||
};
|
||||
|
||||
// Special width for music artists (circular)
|
||||
const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
|
||||
|
||||
return (
|
||||
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
|
||||
<TVFocusablePoster
|
||||
onPress={() => onItemPress(item)}
|
||||
hasTVPreferredFocus={isFirstItem && !disabled}
|
||||
onFocus={handleItemFocus}
|
||||
onBlur={handleItemBlur}
|
||||
disabled={disabled}
|
||||
>
|
||||
{renderPoster()}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
orientation,
|
||||
isFirstSection,
|
||||
itemWidth,
|
||||
onItemPress,
|
||||
handleItemFocus,
|
||||
handleItemBlur,
|
||||
disabled,
|
||||
imageUrlGetter,
|
||||
],
|
||||
);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ overflow: "visible" }} {...props}>
|
||||
{/* Section Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{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" }}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user