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";