This commit is contained in:
Fredrik Burmester
2026-01-16 15:29:12 +01:00
parent a86df6c46b
commit 3fd76b1356
8 changed files with 989 additions and 153 deletions

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -11,20 +12,44 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, Platform, useWindowDimensions, View } from "react-native"; import {
Animated,
Easing,
FlatList,
Platform,
Pressable,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; 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 { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TV_POSTER_WIDTH } from "@/components/posters/MoviePoster.tv"; import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -53,7 +78,7 @@ import { useSettings } from "@/utils/atoms/settings";
const TV_ITEM_GAP = 16; const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20; const TV_SCALE_PADDING = 20;
const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}> <View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}> <Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name} {item.Name}
@@ -64,6 +89,315 @@ const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
</View> </View>
); );
// TV Filter Types and Components
type TVFilterModalType =
| "genre"
| "year"
| "tags"
| "sortBy"
| "sortOrder"
| "filterBy"
| null;
interface TVFilterOption<T> {
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={{ position: "absolute", top: 8, right: 8 }}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<View
style={{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(147, 51, 234, 0.3)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(147, 51, 234, 0.5)",
}}
>
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
const TVFilterSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
multiSelect = false,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
multiSelect?: boolean;
}) => {
const [doneButtonFocused, setDoneButtonFocused] = useState(false);
const doneScale = useRef(new Animated.Value(1)).current;
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
const animateDone = (v: number) =>
Animated.timing(doneScale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
}
// Reset when modal closes
if (!visible) {
initialFocusIndexRef.current = null;
return null;
}
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View style={{ paddingVertical: 24 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
<Text style={{ fontSize: 20, fontWeight: "600", color: "#fff" }}>
{title}
</Text>
{multiSelect && (
<Pressable
onPress={onClose}
onFocus={() => {
setDoneButtonFocused(true);
animateDone(1.05);
}}
onBlur={() => {
setDoneButtonFocused(false);
animateDone(1);
}}
>
<Animated.View
style={{
transform: [{ scale: doneScale }],
backgroundColor: doneButtonFocused
? "#fff"
: "rgba(255,255,255,0.2)",
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 8,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: "500",
color: doneButtonFocused ? "#000" : "#fff",
}}
>
Done
</Text>
</Animated.View>
</Pressable>
)}
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVFilterOptionCard
key={String(option.value)}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
if (!multiSelect) {
onClose();
}
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams() as { const searchParams = useLocalSearchParams() as {
libraryId: string; libraryId: string;
@@ -94,6 +428,52 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
// TV Filter modal state
const [openFilterModal, setOpenFilterModal] =
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
useEffect(() => { useEffect(() => {
// Check for URL params first (from "See All" navigation) // Check for URL params first (from "See All" navigation)
@@ -345,7 +725,42 @@ const Page = () => {
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
), ),
[orientation], [orientation, nrOfCols],
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0 && !isFilterModalOpen}
disabled={isFilterModalOpen}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router, isFilterModalOpen],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -532,6 +947,115 @@ const Page = () => {
], ],
); );
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options
const tvGenreFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
[tvGenreOptions, selectedGenres],
);
const tvYearFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
[tvYearOptions, selectedYears],
);
const tvTagFilterOptions = useMemo(
(): TVFilterOption<string>[] =>
(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
[tvTagOptions, selectedTags],
);
const tvSortByOptions = useMemo(
(): TVFilterOption<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVFilterOption<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<FilterByOption>[] =>
generalFilters.map((option) => ({
label: option.value,
value: option.key,
selected: filterBy.includes(option.key),
})),
[filterBy, generalFilters],
);
// TV Filter handlers
const handleGenreSelect = useCallback(
(value: string) => {
if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
[selectedGenres, setSelectedGenres],
);
const handleYearSelect = useCallback(
(value: string) => {
if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
[selectedYears, setSelectedYears],
);
const handleTagSelect = useCallback(
(value: string) => {
if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
[selectedTags, setSelectedTags],
);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading) if (isLoading || isLibraryLoading)
@@ -541,43 +1065,230 @@ const Page = () => {
</View> </View>
); );
return ( // Mobile return
<FlashList if (!Platform.isTV) {
key={orientation} return (
ListEmptyComponent={ <FlashList
<View className='flex flex-col items-center justify-center h-full'> key={orientation}
<Text className='font-bold text-xl text-neutral-500'> ListEmptyComponent={
{t("library.no_results")} <View className='flex flex-col items-center justify-center h-full'>
</Text> <Text className='font-bold text-xl text-neutral-500'>
</View> {t("library.no_results")}
} </Text>
contentInsetAdjustmentBehavior='automatic' </View>
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
} }
}} contentInsetAdjustmentBehavior='automatic'
onEndReachedThreshold={1} data={flatData}
ListHeaderComponent={ListHeaderComponent} renderItem={renderItem}
contentContainerStyle={{ extraData={[orientation, nrOfCols]}
paddingBottom: 24, keyExtractor={keyExtractor}
paddingLeft: insets.left, numColumns={nrOfCols}
paddingRight: insets.right, onEndReached={() => {
}} if (hasNextPage) {
ItemSeparatorComponent={() => ( fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
return (
<View style={{ flex: 1 }}>
{/* Background content - disabled when modal is open */}
<View
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
focusable={!isFilterModalOpen}
isTVSelectable={!isFilterModalOpen}
pointerEvents={isFilterModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isFilterModalOpen}
importantForAccessibility={
isFilterModalOpen ? "no-hide-descendants" : "auto"
}
>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<View <View
style={{ style={{
width: 10, flexDirection: "row",
height: 10, flexWrap: "nowrap",
marginTop: insets.top + 20,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}} }}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFilterModalOpen}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFilterModalOpen}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFilterModalOpen}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFilterModalOpen}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFilterModalOpen}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFilterModalOpen}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFilterModalOpen}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
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: 8,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/> />
)} </View>
/>
{/* TV Filter Overlays */}
<TVFilterSelector
visible={openFilterModal === "genre"}
title={t("library.filters.genres")}
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "year"}
title={t("library.filters.years")}
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
title={t("library.filters.tags")}
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
multiSelect
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
title={t("library.filters.sort_by")}
options={tvSortByOptions}
onSelect={(value) => setSortBy([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortOrder"}
title={t("library.filters.sort_order")}
options={tvSortOrderOptions}
onSelect={(value) => setSortOrder([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={(value) => setFilter([value])}
onClose={() => setOpenFilterModal(null)}
/>
</View>
); );
}; };

View File

@@ -199,9 +199,7 @@ export default function search() {
return []; return [];
} }
const url = `${ const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
@@ -457,18 +455,22 @@ export default function search() {
}} }}
> */} > */}
{Platform.isTV && ( {Platform.isTV && (
<Input <View
placeholder={t("search.search")} style={{ paddingHorizontal: 48, paddingTop: 0, paddingBottom: 8 }}
onChangeText={(text) => { >
router.setParams({ q: "" }); <Input
setSearch(text); placeholder={t("search.search")}
}} onChangeText={(text) => {
keyboardType='default' router.setParams({ q: "" });
returnKeyType='done' setSearch(text);
autoCapitalize='none' }}
clearButtonMode='while-editing' keyboardType='default'
maxLength={500} returnKeyType='done'
/> autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
</View>
)} )}
<View <View
className='flex flex-col' className='flex flex-col'

View File

@@ -85,6 +85,12 @@ export default function page() {
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false); const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
// TV audio/subtitle selection state (tracks current selection for dynamic changes)
const [currentAudioIndex, setCurrentAudioIndex] = useState<
number | undefined
>(undefined);
const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState<number>(-1);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
@@ -161,6 +167,17 @@ export default function page() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params
useEffect(() => {
if (audioIndex !== undefined) {
setCurrentAudioIndex(audioIndex);
}
}, [audioIndex]);
useEffect(() => {
setCurrentSubtitleIndex(subtitleIndex);
}, [subtitleIndex]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -732,6 +749,55 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000); videoRef.current?.seekTo?.(position / 1000);
}, []); }, []);
// TV audio track change handler
const handleAudioIndexChange = useCallback(
async (index: number) => {
setCurrentAudioIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined) {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
);
// TV subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
},
[stream?.mediaSource],
);
// Technical info toggle handler // Technical info toggle handler
const handleToggleTechnicalInfo = useCallback(() => { const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev); setShowTechnicalInfo((prev) => !prev);
@@ -977,6 +1043,10 @@ export default function page() {
play={play} play={play}
pause={pause} pause={pause}
seek={seek} seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
/> />
) : ( ) : (
<Controls <Controls

View File

@@ -1,3 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import {
Animated, Animated,
@@ -39,6 +41,49 @@ export function Input(props: InputProps) {
}; };
if (Platform.isTV) { if (Platform.isTV) {
const containerStyle = {
height: 48,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)",
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft: 16,
};
const inputElement = (
<>
<Ionicons
name='search'
size={20}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: 12 }}
/>
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor='#666'
style={[
{
flex: 1,
height: 48,
fontSize: 18,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</>
);
return ( return (
<Pressable <Pressable
onPress={() => inputRef.current?.focus()} onPress={() => inputRef.current?.focus()}
@@ -50,66 +95,28 @@ export function Input(props: InputProps) {
transform: [{ scale }], transform: [{ scale }],
}} }}
> >
{/* Outer glow when focused */} {Platform.OS === "ios" ? (
{isFocused && ( <BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : (
<View <View
style={{
position: "absolute",
top: -4,
left: -4,
right: -4,
bottom: -4,
backgroundColor: "#9334E9",
borderRadius: 18,
opacity: 0.5,
}}
/>
)}
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
borderRadius: 14,
overflow: "hidden",
}}
>
{/* Purple accent bar at top when focused */}
{isFocused && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "#9334E9",
}}
/>
)}
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor={isFocused ? "#AAAAAA" : "#666666"}
style={[ style={[
containerStyle,
{ {
height: 60, backgroundColor: isFocused
fontSize: 22, ? "rgba(255, 255, 255, 0.12)"
fontWeight: "500", : "rgba(255, 255, 255, 0.08)",
paddingHorizontal: 20,
paddingTop: isFocused ? 4 : 0,
color: "#FFFFFF",
backgroundColor: "transparent",
}, },
style,
]} ]}
onFocus={handleFocus} >
onBlur={handleBlur} {inputElement}
{...otherProps} </View>
/> )}
</View>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
); );

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, type ViewStyle } from "react-native"; import { Animated, Easing, Pressable, type ViewStyle } from "react-native";
interface TVFocusablePosterProps { export interface TVFocusablePosterProps {
children: React.ReactNode; children: React.ReactNode;
onPress: () => void; onPress: () => void;
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
@@ -10,6 +10,7 @@ interface TVFocusablePosterProps {
style?: ViewStyle; style?: ViewStyle;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
disabled?: boolean;
} }
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
@@ -21,6 +22,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
style, style,
onFocus: onFocusProp, onFocus: onFocusProp,
onBlur: onBlurProp, onBlur: onBlurProp,
disabled = false,
}) => { }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
@@ -48,7 +50,9 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
animateTo(1); animateTo(1);
onBlurProp?.(); onBlurProp?.();
}} }}
hasTVPreferredFocus={hasTVPreferredFocus} hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
> >
<Animated.View <Animated.View
style={[ style={[

View File

@@ -225,20 +225,20 @@ const TVSettingsPanel: FC<{
<View style={selectorStyles.overlay}> <View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}> <BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<View style={selectorStyles.content}> <View style={selectorStyles.content}>
{/* Tab buttons - no preferred focus, navigate here via up from options */} {/* Tab buttons - switch automatically on focus */}
<View style={selectorStyles.tabRow}> <View style={selectorStyles.tabRow}>
{audioOptions.length > 0 && ( {audioOptions.length > 0 && (
<TVSettingsTab <TVSettingsTab
label={t("item_card.audio")} label={t("item_card.audio")}
active={activeTab === "audio"} active={activeTab === "audio"}
onPress={() => setActiveTab("audio")} onSelect={() => setActiveTab("audio")}
/> />
)} )}
{subtitleOptions.length > 0 && ( {subtitleOptions.length > 0 && (
<TVSettingsTab <TVSettingsTab
label={t("item_card.subtitles")} label={t("item_card.subtitles")}
active={activeTab === "subtitle"} active={activeTab === "subtitle"}
onPress={() => setActiveTab("subtitle")} onSelect={() => setActiveTab("subtitle")}
/> />
)} )}
</View> </View>
@@ -269,13 +269,13 @@ const TVSettingsPanel: FC<{
); );
}; };
// Tab button for settings panel // Tab button for settings panel - switches on focus, no click needed
const TVSettingsTab: FC<{ const TVSettingsTab: FC<{
label: string; label: string;
active: boolean; active: boolean;
onPress: () => void; onSelect: () => void;
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
}> = ({ label, active, onPress, hasTVPreferredFocus }) => { }> = ({ label, active, onSelect, hasTVPreferredFocus }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current; const scale = useRef(new RNAnimated.Value(1)).current;
@@ -289,10 +289,11 @@ const TVSettingsTab: FC<{
return ( return (
<Pressable <Pressable
onPress={onPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
animateTo(1.05); animateTo(1.05);
// Switch tab automatically on focus
onSelect();
}} }}
onBlur={() => { onBlur={() => {
setFocused(false); setFocused(false);
@@ -506,8 +507,8 @@ export const Controls: FC<Props> = ({
const [openModal, setOpenModal] = useState<ModalType>(null); const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null; const isModalOpen = openModal !== null;
// Handle swipe up to open settings panel // Handle swipe down to open settings panel
const handleSwipeUp = useCallback(() => { const handleSwipeDown = useCallback(() => {
if (!isModalOpen) { if (!isModalOpen) {
setOpenModal("settings"); setOpenModal("settings");
} }
@@ -629,6 +630,16 @@ export const Controls: FC<Props> = ({
isSeeking, isSeeking,
}); });
const getFinishTime = () => {
const now = new Date();
const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const toggleControls = useCallback(() => { const toggleControls = useCallback(() => {
setShowControls(!showControls); setShowControls(!showControls);
}, [showControls, setShowControls]); }, [showControls, setShowControls]);
@@ -672,7 +683,7 @@ export const Controls: FC<Props> = ({
handleSeekForward, handleSeekForward,
handleSeekBackward, handleSeekBackward,
disableSeeking: isModalOpen, disableSeeking: isModalOpen,
onSwipeUp: handleSwipeUp, onSwipeDown: handleSwipeDown,
}); });
// Slider hook // Slider hook
@@ -748,9 +759,16 @@ export const Controls: FC<Props> = ({
{/* Center Play Button - shown when paused */} {/* Center Play Button - shown when paused */}
{!isPlaying && showControls && ( {!isPlaying && showControls && (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<View style={styles.playButtonContainer}> <BlurView intensity={40} tint='dark' style={styles.playButtonBlur}>
<Ionicons name='play' size={80} color='white' /> <View style={styles.playButtonInner}>
</View> <Ionicons
name='play'
size={44}
color='white'
style={styles.playIcon}
/>
</View>
</BlurView>
</View> </View>
)} )}
@@ -771,14 +789,14 @@ export const Controls: FC<Props> = ({
]} ]}
> >
<View style={styles.settingsHint}> <View style={styles.settingsHint}>
<Text style={styles.settingsHintText}>
{t("player.swipe_down_settings")}
</Text>
<Ionicons <Ionicons
name='chevron-up' name='chevron-down'
size={16} size={16}
color='rgba(255,255,255,0.5)' color='rgba(255,255,255,0.5)'
/> />
<Text style={styles.settingsHintText}>
{t("player.swipe_up_settings")}
</Text>
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
@@ -855,9 +873,14 @@ export const Controls: FC<Props> = ({
<Text style={styles.timeText}> <Text style={styles.timeText}>
{formatTimeString(currentTime, "ms")} {formatTimeString(currentTime, "ms")}
</Text> </Text>
<Text style={styles.timeText}> <View style={styles.timeRight}>
-{formatTimeString(remainingTime, "ms")} <Text style={styles.timeText}>
</Text> -{formatTimeString(remainingTime, "ms")}
</Text>
<Text style={styles.endsAtText}>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
@@ -910,14 +933,23 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
playButtonContainer: { playButtonBlur: {
width: 120, width: 80,
height: 120, height: 80,
borderRadius: 60, borderRadius: 40,
backgroundColor: "rgba(0,0,0,0.5)", overflow: "hidden",
},
playButtonInner: {
flex: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
paddingLeft: 8, backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.2)",
borderRadius: 40,
},
playIcon: {
marginLeft: 4,
}, },
topContainer: { topContainer: {
position: "absolute", position: "absolute",
@@ -928,7 +960,7 @@ const styles = StyleSheet.create({
}, },
topInner: { topInner: {
flexDirection: "row", flexDirection: "row",
justifyContent: "flex-end", justifyContent: "center",
}, },
bottomContainer: { bottomContainer: {
position: "absolute", position: "absolute",
@@ -970,18 +1002,23 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.7)", color: "rgba(255,255,255,0.7)",
fontSize: 22, fontSize: 22,
}, },
timeRight: {
flexDirection: "column",
alignItems: "flex-end",
},
endsAtText: {
color: "rgba(255,255,255,0.5)",
fontSize: 16,
marginTop: 2,
},
settingsRow: { settingsRow: {
flexDirection: "row", flexDirection: "row",
gap: 12, gap: 12,
}, },
settingsHint: { settingsHint: {
flexDirection: "row", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: 6, gap: 4,
backgroundColor: "rgba(0,0,0,0.3)",
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 20,
}, },
settingsHintText: { settingsHintText: {
color: "rgba(255,255,255,0.5)", color: "rgba(255,255,255,0.5)",

View File

@@ -33,8 +33,8 @@ interface UseRemoteControlProps {
handleSeekBackward: (seconds: number) => void; handleSeekBackward: (seconds: number) => void;
/** When true, disables left/right seeking (e.g., when settings modal is open) */ /** When true, disables left/right seeking (e.g., when settings modal is open) */
disableSeeking?: boolean; disableSeeking?: boolean;
/** Callback when swipe up is detected - used to open settings */ /** Callback when swipe down is detected - used to open settings */
onSwipeUp?: () => void; onSwipeDown?: () => void;
} }
/** /**
@@ -55,7 +55,7 @@ export function useRemoteControl({
handleSeekForward, handleSeekForward,
handleSeekBackward, handleSeekBackward,
disableSeeking = false, disableSeeking = false,
onSwipeUp, onSwipeDown,
}: UseRemoteControlProps) { }: UseRemoteControlProps) {
const remoteScrubProgress = useSharedValue<number | null>(null); const remoteScrubProgress = useSharedValue<number | null>(null);
const isRemoteScrubbing = useSharedValue(false); const isRemoteScrubbing = useSharedValue(false);
@@ -74,9 +74,9 @@ export function useRemoteControl({
const disableSeekingRef = useRef(disableSeeking); const disableSeekingRef = useRef(disableSeeking);
disableSeekingRef.current = disableSeeking; disableSeekingRef.current = disableSeeking;
// Use ref for onSwipeUp callback // Use ref for onSwipeDown callback
const onSwipeUpRef = useRef(onSwipeUp); const onSwipeDownRef = useRef(onSwipeDown);
onSwipeUpRef.current = onSwipeUp; onSwipeDownRef.current = onSwipeDown;
// MPV uses ms // MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS; const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
@@ -130,6 +130,10 @@ export function useRemoteControl({
} }
case "playPause": case "playPause":
case "select": { case "select": {
// Skip play/pause when modal is open (let native focus handle selection)
if (disableSeekingRef.current) {
break;
}
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value; progress.value = remoteScrubProgress.value;
@@ -148,17 +152,17 @@ export function useRemoteControl({
break; break;
} }
case "down": case "down":
// cancel scrubbing on down // cancel scrubbing and trigger swipe down callback (for settings)
isRemoteScrubbing.value = false; isRemoteScrubbing.value = false;
remoteScrubProgress.value = null; remoteScrubProgress.value = null;
setShowRemoteBubble(false); setShowRemoteBubble(false);
onSwipeDownRef.current?.();
break; break;
case "up": case "up":
// cancel scrubbing and trigger swipe up callback (for settings) // cancel scrubbing on up
isRemoteScrubbing.value = false; isRemoteScrubbing.value = false;
remoteScrubProgress.value = null; remoteScrubProgress.value = null;
setShowRemoteBubble(false); setShowRemoteBubble(false);
onSwipeUpRef.current?.();
break; break;
default: default:
break; break;

View File

@@ -612,7 +612,8 @@
"downloaded_file_yes": "Yes", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel", "downloaded_file_cancel": "Cancel",
"swipe_up_settings": "Swipe up for settings" "swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at"
}, },
"item_card": { "item_card": {
"next_up": "Next Up", "next_up": "Next Up",