diff --git a/CLAUDE.md b/CLAUDE.md
index f8652623..5007cbc9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 ? (
+
+) : (
+
+)}
+```
+
+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
+
+ // No hasTVPreferredFocus
+
+// ❌ Bad - both compete for focus
+
+
+```
+
+4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
+```typescript
+
+ {/* Filter bar - separate from list */}
+
+
+
+
+
+ {/* Grid */}
+
+
+```
+
+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.
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 14d63349..5d7f63e9 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -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)",
}}
>
-
- {label}
-
+ {label ? (
+
+ {label}
+
+ ) : null}
({
options,
onSelect,
onClose,
- multiSelect = false,
}: {
visible: boolean;
title: string;
options: TVFilterOption[];
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(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 = ({
}}
>
-
-
- {title}
-
- {multiSelect && (
- {
- setDoneButtonFocused(true);
- animateDone(1.05);
- }}
- onBlur={() => {
- setDoneButtonFocused(false);
- animateDone(1);
- }}
- >
-
-
- Done
-
-
-
- )}
-
+ {title}
+
({
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
- if (!multiSelect) {
- onClose();
- }
+ onClose();
}}
/>
))}
@@ -435,6 +386,8 @@ const Page = () => {
useState(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,
}}
>
-
+
{item.Type === "Movie" && }
{(item.Type === "Series" || item.Type === "Episode") && (
@@ -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[] =>
- (tvGenreOptions || []).map((genre) => ({
+ (): TVFilterOption[] => [
+ {
+ 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[] =>
- (tvYearOptions || []).map((year) => ({
+ (): TVFilterOption[] => [
+ {
+ 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[] =>
- (tvTagOptions || []).map((tag) => ({
+ (): TVFilterOption[] => [
+ {
+ 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[] =>
- generalFilters.map((option) => ({
+ (): TVFilterOption[] => [
+ {
+ 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}
/>
{
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
- disabled={isFilterModalOpen}
+ disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
{
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
- disabled={isFilterModalOpen}
+ disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
- disabled={isFilterModalOpen}
+ disabled={isFiltersDisabled}
/>
{
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
- disabled={isFilterModalOpen}
+ disabled={isFiltersDisabled}
/>
{
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
- disabled={isFilterModalOpen}
+ disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
@@ -1229,7 +1219,7 @@ const Page = () => {
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
- paddingTop: 8,
+ paddingTop: 20,
}}
ItemSeparatorComponent={() => (
{
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
- multiSelect
/>
{
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
- multiSelect
/>
{
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
- multiSelect
/>
{
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
- onSelect={(value) => setFilter([value])}
+ onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 24ecd00a..43a90c4e 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -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 (
+
+ );
+ }
+
return (
- {/* */}
- {Platform.isTV && (
-
- {
- router.setParams({ q: "" });
- setSearch(text);
- }}
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- clearButtonMode='while-editing'
- maxLength={500}
- />
-
- )}
> = ({
onPress={onPress}
onFocus={() => {
setFocused(true);
- animateTo(1.08);
+ animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
@@ -132,10 +132,10 @@ export const Button: React.FC> = ({
diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx
index 7ecd57f4..6704e6e3 100644
--- a/components/login/TVInput.tsx
+++ b/components/login/TVInput.tsx
@@ -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 = ({
label,
+ placeholder,
hasTVPreferredFocus,
style,
...props
@@ -43,94 +42,40 @@ export const TVInput: React.FC = ({
animateFocus(false);
};
+ const displayPlaceholder = placeholder || label;
+
return (
-
- {label && (
-
- {label}
-
- )}
- inputRef.current?.focus()}
- onFocus={handleFocus}
- onBlur={handleBlur}
- hasTVPreferredFocus={hasTVPreferredFocus}
+ inputRef.current?.focus()}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
-
- {/* Outer glow layer - only visible when focused */}
- {isFocused && (
-
- )}
-
- {/* Main input container */}
-
- {/* Inner highlight bar when focused */}
- {isFocused && (
-
- )}
-
-
-
-
-
-
+
+
+
);
};
diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx
index 5d07bb8d..d120c386 100644
--- a/components/login/TVSaveAccountToggle.tsx
+++ b/components/login/TVSaveAccountToggle.tsx
@@ -23,7 +23,7 @@ export const TVSaveAccountToggle: React.FC = ({
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 = ({
>
{label}
-
+
+
+
diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx
index 7178e869..007cbc6a 100644
--- a/components/login/TVServerCard.tsx
+++ b/components/login/TVServerCard.tsx
@@ -34,7 +34,7 @@ export const TVServerCard: React.FC = ({
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,
diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx
new file mode 100644
index 00000000..5563f7b6
--- /dev/null
+++ b/components/search/TVSearchPage.tsx
@@ -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 (
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ Placeholder text here
+
+
+
+ ))}
+
+
+ );
+};
+
+// 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 = ({
+ 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 (
+
+ {/* Search Input */}
+
+
+
+
+ {/* Loading State */}
+ {loading && (
+
+
+
+
+ )}
+
+ {/* Search Results */}
+ {!loading && (
+
+ {sections.map((section, index) => (
+
+ ))}
+
+ )}
+
+ {/* No Results State */}
+ {!loading && noResults && debouncedSearch.length > 0 && (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{debouncedSearch}"
+
+
+ )}
+
+ {/* Example Searches (when no search query) */}
+ {!loading && debouncedSearch.length === 0 && (
+
+
+ {t("search.search")}
+
+
+ {exampleSearches.map((example) => (
+ 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 }],
+ })}
+ >
+
+ {example}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx
new file mode 100644
index 00000000..9f2152c5
--- /dev/null
+++ b/components/search/TVSearchSection.tsx
@@ -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 (
+
+ {item.Type === "Episode" ? (
+ <>
+
+ {item.Name}
+
+
+ {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
+ {" - "}
+ {item.SeriesName}
+
+ >
+ ) : item.Type === "MusicArtist" ? (
+
+ {item.Name}
+
+ ) : item.Type === "MusicAlbum" ? (
+ <>
+
+ {item.Name}
+
+
+ {item.AlbumArtist || item.Artists?.join(", ")}
+
+ >
+ ) : item.Type === "Audio" ? (
+ <>
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+ >
+ ) : item.Type === "Playlist" ? (
+ <>
+
+ {item.Name}
+
+
+ {item.ChildCount} tracks
+
+ >
+ ) : item.Type === "Person" ? (
+
+ {item.Name}
+
+ ) : (
+ <>
+
+ {item.Name}
+
+
+ {item.ProductionYear}
+
+ >
+ )}
+
+ );
+};
+
+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 = ({
+ title,
+ items,
+ orientation = "vertical",
+ disabled = false,
+ isFirstSection = false,
+ onItemPress,
+ imageUrlGetter,
+ ...props
+}) => {
+ const flatListRef = useRef>(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 | 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 (
+
+ {imageUrl ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+ );
+ }
+
+ // 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 (
+
+ {imageUrl ? (
+
+ ) : (
+
+ {icon}
+
+ )}
+
+ );
+ }
+
+ // Person (Actor)
+ if (item.Type === "Person") {
+ return ;
+ }
+
+ // Episode rendering
+ if (item.Type === "Episode" && isHorizontal) {
+ return ;
+ }
+ if (item.Type === "Episode" && !isHorizontal) {
+ return ;
+ }
+
+ // Movie rendering
+ if (item.Type === "Movie" && isHorizontal) {
+ return ;
+ }
+ if (item.Type === "Movie" && !isHorizontal) {
+ return ;
+ }
+
+ // Series rendering
+ if (item.Type === "Series" && !isHorizontal) {
+ return ;
+ }
+ if (item.Type === "Series" && isHorizontal) {
+ return ;
+ }
+
+ // BoxSet (Collection)
+ if (item.Type === "BoxSet" && !isHorizontal) {
+ return ;
+ }
+ if (item.Type === "BoxSet" && isHorizontal) {
+ return ;
+ }
+
+ // Default fallback
+ return isHorizontal ? (
+
+ ) : (
+
+ );
+ };
+
+ // Special width for music artists (circular)
+ const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
+
+ return (
+
+ onItemPress(item)}
+ hasTVPreferredFocus={isFirstItem && !disabled}
+ onFocus={handleItemFocus}
+ onBlur={handleItemBlur}
+ disabled={disabled}
+ >
+ {renderPoster()}
+
+
+
+ );
+ },
+ [
+ orientation,
+ isFirstSection,
+ itemWidth,
+ onItemPress,
+ handleItemFocus,
+ handleItemBlur,
+ disabled,
+ imageUrlGetter,
+ ],
+ );
+
+ if (!items || items.length === 0) return null;
+
+ return (
+
+ {/* Section Header */}
+
+ {title}
+
+
+ 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,
+ }}
+ />
+
+ );
+};
diff --git a/translations/en.json b/translations/en.json
index 08b8a676..d9c08461 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -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": {
diff --git a/translations/sv.json b/translations/sv.json
index 6614d142..e5c370db 100644
--- a/translations/sv.json
+++ b/translations/sv.json
@@ -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": {