This commit is contained in:
Fredrik Burmester
2026-01-16 15:59:26 +01:00
parent 3fd76b1356
commit ff3f88c53b
11 changed files with 885 additions and 228 deletions

View File

@@ -252,3 +252,54 @@ const TVFocusableButton: React.FC<{
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
### TV Focus Flickering Between Zones (Lists with Headers)
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
**Solutions:**
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
```typescript
{Platform.isTV ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
```
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
```typescript
// ✅ Good - only first filter button has preferred focus
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
```
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
```typescript
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
```
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.

View File

@@ -223,7 +223,7 @@ const TVFilterButton: React.FC<{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(147, 51, 234, 0.3)"
? "rgba(255, 255, 255, 0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
@@ -232,12 +232,14 @@ const TVFilterButton: React.FC<{
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(147, 51, 234, 0.5)",
borderColor: "rgba(255, 255, 255, 0.4)",
}}
>
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
{label ? (
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
) : null}
<Text
style={{
fontSize: 14,
@@ -260,28 +262,16 @@ const TVFilterSelector = <T,>({
options,
onSelect,
onClose,
multiSelect = false,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
multiSelect?: boolean;
}) => {
const [doneButtonFocused, setDoneButtonFocused] = useState(false);
const doneScale = useRef(new Animated.Value(1)).current;
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
const animateDone = (v: number) =>
Animated.timing(doneScale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
@@ -319,54 +309,17 @@ const TVFilterSelector = <T,>({
}}
>
<View style={{ paddingVertical: 24 }}>
<View
<Text
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
fontSize: 20,
fontWeight: "600",
color: "#fff",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
<Text style={{ fontSize: 20, fontWeight: "600", color: "#fff" }}>
{title}
</Text>
{multiSelect && (
<Pressable
onPress={onClose}
onFocus={() => {
setDoneButtonFocused(true);
animateDone(1.05);
}}
onBlur={() => {
setDoneButtonFocused(false);
animateDone(1);
}}
>
<Animated.View
style={{
transform: [{ scale: doneScale }],
backgroundColor: doneButtonFocused
? "#fff"
: "rgba(255,255,255,0.2)",
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 8,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: "500",
color: doneButtonFocused ? "#000" : "#fff",
}}
>
Done
</Text>
</Animated.View>
</Pressable>
)}
</View>
{title}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -385,9 +338,7 @@ const TVFilterSelector = <T,>({
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
if (!multiSelect) {
onClose();
}
onClose();
}}
/>
))}
@@ -435,6 +386,8 @@ const Page = () => {
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
const isFiltersDisabled = isFilterModalOpen;
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
@@ -729,7 +682,7 @@ const Page = () => {
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
@@ -743,11 +696,7 @@ const Page = () => {
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0 && !isFilterModalOpen}
disabled={isFilterModalOpen}
>
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
@@ -961,35 +910,53 @@ const Page = () => {
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvGenreOptions || []).map((genre) => ({
(): TVFilterOption<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
[tvGenreOptions, selectedGenres],
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvYearOptions || []).map((year) => ({
(): TVFilterOption<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
[tvYearOptions, selectedYears],
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvTagOptions || []).map((tag) => ({
(): TVFilterOption<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
[tvTagOptions, selectedTags],
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
@@ -1013,19 +980,27 @@ const Page = () => {
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<FilterByOption>[] =>
generalFilters.map((option) => ({
(): TVFilterOption<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: filterBy.length === 0,
},
...generalFilters.map((option) => ({
label: option.value,
value: option.key,
selected: filterBy.includes(option.key),
})),
[filterBy, generalFilters],
],
[filterBy, generalFilters, t],
);
// TV Filter handlers
const handleGenreSelect = useCallback(
(value: string) => {
if (selectedGenres.includes(value)) {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
@@ -1036,7 +1011,9 @@ const Page = () => {
const handleYearSelect = useCallback(
(value: string) => {
if (selectedYears.includes(value)) {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
@@ -1047,7 +1024,9 @@ const Page = () => {
const handleTagSelect = useCallback(
(value: string) => {
if (selectedTags.includes(value)) {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
@@ -1056,6 +1035,17 @@ const Page = () => {
[selectedTags, setSelectedTags],
);
const handleFilterBySelect = useCallback(
(value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
[setFilter, _setFilterBy],
);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
@@ -1126,7 +1116,7 @@ const Page = () => {
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 20,
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
@@ -1137,7 +1127,7 @@ const Page = () => {
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
hasActiveFilter
/>
)}
@@ -1150,7 +1140,7 @@ const Page = () => {
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
@@ -1161,7 +1151,7 @@ const Page = () => {
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
@@ -1172,14 +1162,14 @@ const Page = () => {
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
@@ -1187,7 +1177,7 @@ const Page = () => {
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
@@ -1197,7 +1187,7 @@ const Page = () => {
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFilterModalOpen}
disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
</View>
@@ -1229,7 +1219,7 @@ const Page = () => {
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 8,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
@@ -1249,7 +1239,6 @@ const Page = () => {
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "year"}
@@ -1257,7 +1246,6 @@ const Page = () => {
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
@@ -1265,7 +1253,6 @@ const Page = () => {
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
@@ -1285,7 +1272,7 @@ const Page = () => {
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={(value) => setFilter([value])}
onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
</View>

View File

@@ -7,7 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai";
import {
useCallback,
@@ -22,9 +22,11 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
@@ -36,6 +38,7 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -59,6 +62,8 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom);
@@ -438,6 +443,38 @@ export default function search() {
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
// TV item press handler
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
// Render TV search page
if (Platform.isTV) {
return (
<TVSearchPage
search={search}
setSearch={setSearch}
debouncedSearch={debouncedSearch}
movies={movies}
series={series}
episodes={episodes}
collections={collections}
actors={actors}
artists={artists}
albums={albums}
songs={songs}
playlists={playlists}
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
/>
);
}
return (
<ScrollView
keyboardDismissMode='on-drag'
@@ -448,30 +485,6 @@ export default function search() {
paddingBottom: 60,
}}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<View
style={{ paddingHorizontal: 48, paddingTop: 0, paddingBottom: 8 }}
>
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
</View>
)}
<View
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}

View File

@@ -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
}}
>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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,

View 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>
);
};

View 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>
);
};

View File

@@ -577,7 +577,11 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {

View File

@@ -574,7 +574,11 @@
"sort_by": "Sortera efter",
"filter_by": "Filtrera På",
"sort_order": "Sorteringsordning",
"tags": "Etiketter"
"tags": "Etiketter",
"all": "Alla",
"reset": "Återställ",
"asc": "Stigande",
"desc": "Fallande"
}
},
"favorites": {