From 29873e08d7dfce519ecc611379cf589408fac67e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 24 Jan 2026 10:31:03 +0100 Subject: [PATCH] feat(tv): add shared filter components and collections page support --- .claude/learned-facts.md | 4 +- .../collections/[collectionId].tsx | 526 +++++++++++-- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 721 +++++------------- components/tv/TVFilterButton.tsx | 78 ++ components/tv/TVItemCardText.tsx | 29 + components/tv/index.ts | 4 + 6 files changed, 764 insertions(+), 598 deletions(-) create mode 100644 components/tv/TVFilterButton.tsx create mode 100644 components/tv/TVItemCardText.tsx diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index 67a2243c..86183d47 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -30,4 +30,6 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_ -- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_ \ No newline at end of file +- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_ + +- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_ \ No newline at end of file diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index 1723fe4b..aca3b452 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -15,14 +15,29 @@ import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FlatList, View } from "react-native"; +import { FlatList, Platform, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { + getItemNavigation, + TouchableItemRouter, +} from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; +import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; +import MoviePoster, { + TV_POSTER_WIDTH, +} from "@/components/posters/MoviePoster.tv"; +import SeriesPoster from "@/components/posters/SeriesPoster.tv"; +import { + TVFilterButton, + TVFocusablePoster, + TVItemCardText, +} from "@/components/tv"; +import useRouter from "@/hooks/useAppRouter"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { @@ -36,6 +51,10 @@ import { tagsFilterAtom, yearFilterAtom, } from "@/utils/atoms/filters"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; + +const TV_ITEM_GAP = 16; +const TV_SCALE_PADDING = 20; const page: React.FC = () => { const searchParams = useLocalSearchParams(); @@ -44,11 +63,15 @@ const page: React.FC = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const navigation = useNavigation(); + const router = useRouter(); + const { showOptions } = useTVOptionModal(); + const { width: screenWidth } = useWindowDimensions(); const [orientation, _setOrientation] = useState( ScreenOrientation.Orientation.PORTRAIT_UP, ); const { t } = useTranslation(); + const insets = useSafeAreaInsets(); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); @@ -56,7 +79,7 @@ const page: React.FC = () => { const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - const { data: collection } = useQuery({ + const { data: collection, isLoading: isCollectionLoading } = useQuery({ queryKey: ["collection", collectionId], queryFn: async () => { if (!api) return null; @@ -71,6 +94,46 @@ const page: React.FC = () => { staleTime: 60 * 1000, }); + // TV Filter queries + const { data: tvGenreOptions } = useQuery({ + queryKey: ["filters", "Genres", "tvGenreFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + + const { data: tvYearOptions } = useQuery({ + queryKey: ["filters", "Years", "tvYearFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + + const { data: tvTagOptions } = useQuery({ + queryKey: ["filters", "Tags", "tvTagFilter", collectionId], + queryFn: async () => { + if (!api) return []; + const response = await getFilterApi(api).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }, + enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId, + }); + useEffect(() => { navigation.setOptions({ title: collection?.Name || "" }); setSortOrder([SortOrderOption.Ascending]); @@ -87,6 +150,18 @@ const page: React.FC = () => { setSortBy([sortByOption]); }, [navigation, collection]); + // Calculate columns for TV grid + const nrOfCols = useMemo(() => { + if (Platform.isTV) { + const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; + return Math.max( + 1, + Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), + ); + } + return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5; + }, [screenWidth, orientation]); + const fetchItems = useCallback( async ({ pageParam, @@ -98,7 +173,7 @@ const page: React.FC = () => { const response = await getItemsApi(api).getItems({ userId: user?.Id, parentId: collectionId, - limit: 18, + limit: Platform.isTV ? 36 : 18, startIndex: pageParam, // Set one ordering at a time. As collections do not work with correctly with multiple. sortBy: [sortBy[0]], @@ -123,6 +198,7 @@ const page: React.FC = () => { api, user?.Id, collection, + collectionId, selectedGenres, selectedYears, selectedTags, @@ -131,39 +207,40 @@ const page: React.FC = () => { ], ); - const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ - queryKey: [ - "collection-items", - collection, - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ], - queryFn: fetchItems, - getNextPageParam: (lastPage, pages) => { - if ( - !lastPage?.Items || - !lastPage?.TotalRecordCount || - lastPage?.TotalRecordCount === 0 - ) + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + useInfiniteQuery({ + queryKey: [ + "collection-items", + collectionId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0, + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } return undefined; - - const totalItems = lastPage.TotalRecordCount; - const accumulatedItems = pages.reduce( - (acc, curr) => acc + (curr?.Items?.length || 0), - 0, - ); - - if (accumulatedItems < totalItems) { - return lastPage?.Items?.length * pages.length; - } - return undefined; - }, - initialPageParam: 0, - enabled: !!api && !!user?.Id && !!collection, - }); + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); const flatData = useMemo(() => { return ( @@ -195,7 +272,6 @@ const page: React.FC = () => { }} > - {/* */} @@ -203,9 +279,38 @@ const page: React.FC = () => { [orientation], ); - const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + const renderTVItem = useCallback( + ({ item }: { item: BaseItemDto }) => { + const handlePress = () => { + const navTarget = getItemNavigation(item, "(home)"); + router.push(navTarget as any); + }; - const _insets = useSafeAreaInsets(); + return ( + + + {item.Type === "Movie" && } + {(item.Type === "Series" || item.Type === "Episode") && ( + + )} + {item.Type !== "Movie" && + item.Type !== "Series" && + item.Type !== "Episode" && } + + + + ); + }, + [router], + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const ListHeaderComponent = useCallback( () => ( @@ -372,48 +477,315 @@ const page: React.FC = () => { ], ); + // TV Filter options - with "All" option for clearable filters + const tvGenreFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedGenres.length === 0, + }, + ...(tvGenreOptions || []).map((genre) => ({ + label: genre, + value: genre, + selected: selectedGenres.includes(genre), + })), + ], + [tvGenreOptions, selectedGenres, t], + ); + + const tvYearFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + 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, t], + ); + + const tvTagFilterOptions = useMemo( + (): TVOptionItem[] => [ + { + label: t("library.filters.all"), + value: "__all__", + selected: selectedTags.length === 0, + }, + ...(tvTagOptions || []).map((tag) => ({ + label: tag, + value: tag, + selected: selectedTags.includes(tag), + })), + ], + [tvTagOptions, selectedTags, t], + ); + + const tvSortByOptions = useMemo( + (): TVOptionItem[] => + sortOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortBy[0] === option.key, + })), + [sortBy], + ); + + const tvSortOrderOptions = useMemo( + (): TVOptionItem[] => + sortOrderOptions.map((option) => ({ + label: option.value, + value: option.key, + selected: sortOrder[0] === option.key, + })), + [sortOrder], + ); + + // TV Filter handlers using navigation-based modal + const handleShowGenreFilter = useCallback(() => { + showOptions({ + title: t("library.filters.genres"), + options: tvGenreFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedGenres([]); + } else if (selectedGenres.includes(value)) { + setSelectedGenres(selectedGenres.filter((g) => g !== value)); + } else { + setSelectedGenres([...selectedGenres, value]); + } + }, + }); + }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); + + const handleShowYearFilter = useCallback(() => { + showOptions({ + title: t("library.filters.years"), + options: tvYearFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedYears([]); + } else if (selectedYears.includes(value)) { + setSelectedYears(selectedYears.filter((y) => y !== value)); + } else { + setSelectedYears([...selectedYears, value]); + } + }, + }); + }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); + + const handleShowTagFilter = useCallback(() => { + showOptions({ + title: t("library.filters.tags"), + options: tvTagFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedTags([]); + } else if (selectedTags.includes(value)) { + setSelectedTags(selectedTags.filter((tag) => tag !== value)); + } else { + setSelectedTags([...selectedTags, value]); + } + }, + }); + }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); + + const handleShowSortByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_by"), + options: tvSortByOptions, + onSelect: (value: SortByOption) => { + setSortBy([value]); + }, + }); + }, [showOptions, t, tvSortByOptions, setSortBy]); + + const handleShowSortOrderFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_order"), + options: tvSortOrderOptions, + onSelect: (value: SortOrderOption) => { + setSortOrder([value]); + }, + }); + }, [showOptions, t, tvSortOrderOptions, setSortOrder]); + + // TV filter bar state + const hasActiveFilters = + selectedGenres.length > 0 || + selectedYears.length > 0 || + selectedTags.length > 0; + + const resetAllFilters = useCallback(() => { + setSelectedGenres([]); + setSelectedYears([]); + setSelectedTags([]); + }, [setSelectedGenres, setSelectedYears, setSelectedTags]); + + if (isLoading || isCollectionLoading) { + return ( + + + + ); + } + if (!collection) return null; - return ( - - - {t("search.no_results")} - - - } - extraData={[ - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ]} - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderItem} - keyExtractor={keyExtractor} - numColumns={ - orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 - } - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); + // Mobile return + if (!Platform.isTV) { + return ( + + + {t("search.no_results")} + + } - }} - onEndReachedThreshold={0.5} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} - ItemSeparatorComponent={() => ( - { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> + ); + } + + // TV return with filter bar + return ( + + {/* Filter bar */} + + {hasActiveFilters && ( + + )} + 0 + ? `${selectedGenres.length} selected` + : t("library.filters.all") + } + onPress={handleShowGenreFilter} + hasTVPreferredFocus={!hasActiveFilters} + hasActiveFilter={selectedGenres.length > 0} /> - )} - /> + 0 + ? `${selectedYears.length} selected` + : t("library.filters.all") + } + onPress={handleShowYearFilter} + hasActiveFilter={selectedYears.length > 0} + /> + 0 + ? `${selectedTags.length} selected` + : t("library.filters.all") + } + onPress={handleShowTagFilter} + hasActiveFilter={selectedTags.length > 0} + /> + o.key === sortBy[0])?.value || ""} + onPress={handleShowSortByFilter} + /> + o.key === sortOrder[0])?.value || "" + } + onPress={handleShowSortOrderFilter} + /> + + + {/* Grid */} + + + {t("search.no_results")} + + + } + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderTVItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + removeClippedSubviews={false} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: TV_SCALE_PADDING, + paddingRight: TV_SCALE_PADDING, + paddingTop: 20, + }} + ItemSeparatorComponent={() => ( + + )} + /> + ); }; diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 5d7f63e9..3f0734fa 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -1,4 +1,3 @@ -import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, BaseItemDtoQueryResult, @@ -12,27 +11,11 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { BlurView } from "expo-blur"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - Animated, - Easing, - FlatList, - Platform, - Pressable, - ScrollView, - useWindowDimensions, - View, -} from "react-native"; +import { FlatList, Platform, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { @@ -48,9 +31,14 @@ import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { + TVFilterButton, + TVFocusablePoster, + TVItemCardText, +} from "@/components/tv"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { @@ -74,281 +62,11 @@ import { yearFilterAtom, } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; +import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; const TV_ITEM_GAP = 16; const TV_SCALE_PADDING = 20; -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( - - - {item.Name} - - - {item.ProductionYear} - - -); - -// TV Filter Types and Components -type TVFilterModalType = - | "genre" - | "year" - | "tags" - | "sortBy" - | "sortOrder" - | "filterBy" - | null; - -interface TVFilterOption { - label: string; - value: T; - selected: boolean; -} - -const TVFilterOptionCard: React.FC<{ - label: string; - selected: boolean; - hasTVPreferredFocus?: boolean; - onPress: () => void; -}> = ({ label, selected, hasTVPreferredFocus, onPress }) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {label} - - {selected && !focused && ( - - - - )} - - - ); -}; - -const TVFilterButton: React.FC<{ - label: string; - value: string; - onPress: () => void; - hasTVPreferredFocus?: boolean; - disabled?: boolean; - hasActiveFilter?: boolean; -}> = ({ - label, - value, - onPress, - hasTVPreferredFocus, - disabled, - hasActiveFilter, -}) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 120, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - return ( - { - setFocused(true); - animateTo(1.04); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus && !disabled} - disabled={disabled} - focusable={!disabled} - > - - - {label ? ( - - {label} - - ) : null} - - {value} - - - - - ); -}; - -const TVFilterSelector = ({ - visible, - title, - options, - onSelect, - onClose, -}: { - visible: boolean; - title: string; - options: TVFilterOption[]; - onSelect: (value: T) => void; - onClose: () => void; -}) => { - // Track initial focus index - only set once when modal opens - const initialFocusIndexRef = useRef(null); - - // Calculate initial focus index only once when visible becomes true - if (visible && initialFocusIndexRef.current === null) { - const idx = options.findIndex((o) => o.selected); - initialFocusIndexRef.current = idx >= 0 ? idx : 0; - } - - // Reset when modal closes - if (!visible) { - initialFocusIndexRef.current = null; - return null; - } - - const initialFocusIndex = initialFocusIndexRef.current ?? 0; - - return ( - - - - - {title} - - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - - - - ); -}; - const Page = () => { const searchParams = useLocalSearchParams() as { libraryId: string; @@ -380,13 +98,7 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); - - // TV Filter modal state - const [openFilterModal, setOpenFilterModal] = - useState(null); - const isFilterModalOpen = openFilterModal !== null; - - const isFiltersDisabled = isFilterModalOpen; + const { showOptions } = useTVOptionModal(); // TV Filter queries const { data: tvGenreOptions } = useQuery({ @@ -696,7 +408,7 @@ const Page = () => { width: TV_POSTER_WIDTH, }} > - + {item.Type === "Movie" && } {(item.Type === "Series" || item.Type === "Episode") && ( @@ -709,7 +421,7 @@ const Page = () => { ); }, - [router, isFilterModalOpen], + [router], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); @@ -912,7 +624,7 @@ const Page = () => { // TV Filter options - with "All" option for clearable filters const tvGenreFilterOptions = useMemo( - (): TVFilterOption[] => [ + (): TVOptionItem[] => [ { label: t("library.filters.all"), value: "__all__", @@ -928,7 +640,7 @@ const Page = () => { ); const tvYearFilterOptions = useMemo( - (): TVFilterOption[] => [ + (): TVOptionItem[] => [ { label: t("library.filters.all"), value: "__all__", @@ -944,7 +656,7 @@ const Page = () => { ); const tvTagFilterOptions = useMemo( - (): TVFilterOption[] => [ + (): TVOptionItem[] => [ { label: t("library.filters.all"), value: "__all__", @@ -960,7 +672,7 @@ const Page = () => { ); const tvSortByOptions = useMemo( - (): TVFilterOption[] => + (): TVOptionItem[] => sortOptions.map((option) => ({ label: option.value, value: option.key, @@ -970,7 +682,7 @@ const Page = () => { ); const tvSortOrderOptions = useMemo( - (): TVFilterOption[] => + (): TVOptionItem[] => sortOrderOptions.map((option) => ({ label: option.value, value: option.key, @@ -980,7 +692,7 @@ const Page = () => { ); const tvFilterByOptions = useMemo( - (): TVFilterOption[] => [ + (): TVOptionItem[] => [ { label: t("library.filters.all"), value: "__all__", @@ -995,56 +707,88 @@ const Page = () => { [filterBy, generalFilters, t], ); - // TV Filter handlers - const handleGenreSelect = useCallback( - (value: string) => { - if (value === "__all__") { - setSelectedGenres([]); - } else if (selectedGenres.includes(value)) { - setSelectedGenres(selectedGenres.filter((g) => g !== value)); - } else { - setSelectedGenres([...selectedGenres, value]); - } - }, - [selectedGenres, setSelectedGenres], - ); + // TV Filter handlers using navigation-based modal + const handleShowGenreFilter = useCallback(() => { + showOptions({ + title: t("library.filters.genres"), + options: tvGenreFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedGenres([]); + } else if (selectedGenres.includes(value)) { + setSelectedGenres(selectedGenres.filter((g) => g !== value)); + } else { + setSelectedGenres([...selectedGenres, value]); + } + }, + }); + }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); - const handleYearSelect = useCallback( - (value: string) => { - if (value === "__all__") { - setSelectedYears([]); - } else if (selectedYears.includes(value)) { - setSelectedYears(selectedYears.filter((y) => y !== value)); - } else { - setSelectedYears([...selectedYears, value]); - } - }, - [selectedYears, setSelectedYears], - ); + const handleShowYearFilter = useCallback(() => { + showOptions({ + title: t("library.filters.years"), + options: tvYearFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedYears([]); + } else if (selectedYears.includes(value)) { + setSelectedYears(selectedYears.filter((y) => y !== value)); + } else { + setSelectedYears([...selectedYears, value]); + } + }, + }); + }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); - const handleTagSelect = useCallback( - (value: string) => { - if (value === "__all__") { - setSelectedTags([]); - } else if (selectedTags.includes(value)) { - setSelectedTags(selectedTags.filter((t) => t !== value)); - } else { - setSelectedTags([...selectedTags, value]); - } - }, - [selectedTags, setSelectedTags], - ); + const handleShowTagFilter = useCallback(() => { + showOptions({ + title: t("library.filters.tags"), + options: tvTagFilterOptions, + onSelect: (value: string) => { + if (value === "__all__") { + setSelectedTags([]); + } else if (selectedTags.includes(value)) { + setSelectedTags(selectedTags.filter((tag) => tag !== value)); + } else { + setSelectedTags([...selectedTags, value]); + } + }, + }); + }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); - const handleFilterBySelect = useCallback( - (value: string) => { - if (value === "__all__") { - _setFilterBy([]); - } else { - setFilter([value as FilterByOption]); - } - }, - [setFilter, _setFilterBy], - ); + const handleShowSortByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_by"), + options: tvSortByOptions, + onSelect: (value: SortByOption) => { + setSortBy([value]); + }, + }); + }, [showOptions, t, tvSortByOptions, setSortBy]); + + const handleShowSortOrderFilter = useCallback(() => { + showOptions({ + title: t("library.filters.sort_order"), + options: tvSortOrderOptions, + onSelect: (value: SortOrderOption) => { + setSortOrder([value]); + }, + }); + }, [showOptions, t, tvSortOrderOptions, setSortOrder]); + + const handleShowFilterByFilter = useCallback(() => { + showOptions({ + title: t("library.filters.filter_by"), + options: tvFilterByOptions, + onSelect: (value: string) => { + if (value === "__all__") { + _setFilterBy([]); + } else { + setFilter([value as FilterByOption]); + } + }, + }); + }, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]); const insets = useSafeAreaInsets(); @@ -1097,183 +841,120 @@ const Page = () => { ); } - // TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues + // TV return with filter bar return ( - {/* Background content - disabled when modal is open */} + {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */} - {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */} - - {hasActiveFilters && ( - - )} + {hasActiveFilters && ( 0 - ? `${selectedGenres.length} selected` - : t("library.filters.all") - } - onPress={() => setOpenFilterModal("genre")} - hasTVPreferredFocus={!hasActiveFilters} - disabled={isFiltersDisabled} - hasActiveFilter={selectedGenres.length > 0} + label='' + value={t("library.filters.reset")} + onPress={resetAllFilters} + hasActiveFilter /> - 0 - ? `${selectedYears.length} selected` - : t("library.filters.all") - } - onPress={() => setOpenFilterModal("year")} - disabled={isFiltersDisabled} - hasActiveFilter={selectedYears.length > 0} - /> - 0 - ? `${selectedTags.length} selected` - : t("library.filters.all") - } - onPress={() => setOpenFilterModal("tags")} - disabled={isFiltersDisabled} - hasActiveFilter={selectedTags.length > 0} - /> - o.key === sortBy[0])?.value || ""} - onPress={() => setOpenFilterModal("sortBy")} - disabled={isFiltersDisabled} - /> - o.key === sortOrder[0])?.value || "" - } - onPress={() => setOpenFilterModal("sortOrder")} - disabled={isFiltersDisabled} - /> - 0 - ? generalFilters.find((o) => o.key === filterBy[0])?.value || "" - : t("library.filters.all") - } - onPress={() => setOpenFilterModal("filterBy")} - disabled={isFiltersDisabled} - hasActiveFilter={filterBy.length > 0} - /> - - - {/* Grid - using FlatList instead of FlashList to fix focus issues */} - - - {t("library.no_results")} - - + )} + 0 + ? `${selectedGenres.length} selected` + : t("library.filters.all") } - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderTVItem} - extraData={[orientation, nrOfCols, isFilterModalOpen]} - keyExtractor={keyExtractor} - numColumns={nrOfCols} - removeClippedSubviews={false} - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); - } - }} - onEndReachedThreshold={1} - contentContainerStyle={{ - paddingBottom: 24, - paddingLeft: TV_SCALE_PADDING, - paddingRight: TV_SCALE_PADDING, - paddingTop: 20, - }} - ItemSeparatorComponent={() => ( - - )} + onPress={handleShowGenreFilter} + hasTVPreferredFocus={!hasActiveFilters} + hasActiveFilter={selectedGenres.length > 0} + /> + 0 + ? `${selectedYears.length} selected` + : t("library.filters.all") + } + onPress={handleShowYearFilter} + hasActiveFilter={selectedYears.length > 0} + /> + 0 + ? `${selectedTags.length} selected` + : t("library.filters.all") + } + onPress={handleShowTagFilter} + hasActiveFilter={selectedTags.length > 0} + /> + o.key === sortBy[0])?.value || ""} + onPress={handleShowSortByFilter} + /> + o.key === sortOrder[0])?.value || "" + } + onPress={handleShowSortOrderFilter} + /> + 0 + ? generalFilters.find((o) => o.key === filterBy[0])?.value || "" + : t("library.filters.all") + } + onPress={handleShowFilterByFilter} + hasActiveFilter={filterBy.length > 0} /> - {/* TV Filter Overlays */} - setOpenFilterModal(null)} - /> - setOpenFilterModal(null)} - /> - setOpenFilterModal(null)} - /> - setSortBy([value])} - onClose={() => setOpenFilterModal(null)} - /> - setSortOrder([value])} - onClose={() => setOpenFilterModal(null)} - /> - setOpenFilterModal(null)} + {/* Grid - using FlatList instead of FlashList to fix focus issues */} + + + {t("library.no_results")} + + + } + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderTVItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + removeClippedSubviews={false} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: TV_SCALE_PADDING, + paddingRight: TV_SCALE_PADDING, + paddingTop: 20, + }} + ItemSeparatorComponent={() => ( + + )} /> ); diff --git a/components/tv/TVFilterButton.tsx b/components/tv/TVFilterButton.tsx new file mode 100644 index 00000000..25a3188a --- /dev/null +++ b/components/tv/TVFilterButton.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVFilterButtonProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; + disabled?: boolean; + hasActiveFilter?: boolean; +} + +export const TVFilterButton: React.FC = ({ + label, + value, + onPress, + hasTVPreferredFocus = false, + disabled = false, + hasActiveFilter = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 }); + + return ( + + + + {label ? ( + + {label} + + ) : null} + + {value} + + + + + ); +}; diff --git a/components/tv/TVItemCardText.tsx b/components/tv/TVItemCardText.tsx new file mode 100644 index 00000000..6c86e00b --- /dev/null +++ b/components/tv/TVItemCardText.tsx @@ -0,0 +1,29 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React from "react"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVTypography } from "@/constants/TVTypography"; + +export interface TVItemCardTextProps { + item: BaseItemDto; +} + +export const TVItemCardText: React.FC = ({ item }) => ( + + + {item.Name} + + + {item.ProductionYear} + + +); diff --git a/components/tv/index.ts b/components/tv/index.ts index 3620945d..14e71b2d 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -25,8 +25,12 @@ export type { TVControlButtonProps } from "./TVControlButton"; export { TVControlButton } from "./TVControlButton"; export type { TVFavoriteButtonProps } from "./TVFavoriteButton"; export { TVFavoriteButton } from "./TVFavoriteButton"; +export type { TVFilterButtonProps } from "./TVFilterButton"; +export { TVFilterButton } from "./TVFilterButton"; export type { TVFocusablePosterProps } from "./TVFocusablePoster"; export { TVFocusablePoster } from "./TVFocusablePoster"; +export type { TVItemCardTextProps } from "./TVItemCardText"; +export { TVItemCardText } from "./TVItemCardText"; export type { TVLanguageCardProps } from "./TVLanguageCard"; export { TVLanguageCard } from "./TVLanguageCard"; export type { TVMetadataBadgesProps } from "./TVMetadataBadges";