From ff3f88c53bc4a1da74f97825bedba5c6e326d546 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 15:59:26 +0100 Subject: [PATCH] wip --- CLAUDE.md | 51 +++ app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 183 +++++----- app/(auth)/(tabs)/(search)/index.tsx | 67 ++-- components/Button.tsx | 8 +- components/login/TVInput.tsx | 123 ++----- components/login/TVSaveAccountToggle.tsx | 16 +- components/login/TVServerCard.tsx | 2 +- components/search/TVSearchPage.tsx | 307 ++++++++++++++++ components/search/TVSearchSection.tsx | 344 ++++++++++++++++++ translations/en.json | 6 +- translations/sv.json | 6 +- 11 files changed, 885 insertions(+), 228 deletions(-) create mode 100644 components/search/TVSearchPage.tsx create mode 100644 components/search/TVSearchSection.tsx 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": {