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 {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -11,20 +12,44 @@ 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 } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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 { 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 { 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 * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -53,7 +78,7 @@ import { useSettings } from "@/utils/atoms/settings";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
@@ -64,6 +89,315 @@ const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
</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 searchParams = useLocalSearchParams() as {
libraryId: string;
@@ -94,6 +428,52 @@ const Page = () => {
const { orientation } = useOrientation();
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(() => {
// Check for URL params first (from "See All" navigation)
@@ -345,7 +725,42 @@ const Page = () => {
</View>
</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 || "", []);
@@ -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();
if (isLoading || isLibraryLoading)
@@ -541,43 +1065,230 @@ const Page = () => {
</View>
);
return (
<FlashList
key={orientation}
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={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
key={orientation}
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>
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
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
style={{
width: 10,
height: 10,
flexDirection: "row",
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 [];
}
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
@@ -457,18 +455,22 @@ export default function search() {
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
<View
style={{ paddingHorizontal: 48, paddingTop: 0, paddingBottom: 8 }}
>
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
</View>
)}
<View
className='flex flex-col'

View File

@@ -85,6 +85,12 @@ export default function page() {
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
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 isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
@@ -161,6 +167,17 @@ export default function page() {
return undefined;
}, [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
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item,
@@ -732,6 +749,55 @@ export default function page() {
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
const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev);
@@ -977,6 +1043,10 @@ export default function page() {
play={play}
pause={pause}
seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
/>
) : (
<Controls

View File

@@ -1,3 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRef, useState } from "react";
import {
Animated,
@@ -39,6 +41,49 @@ export function Input(props: InputProps) {
};
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 (
<Pressable
onPress={() => inputRef.current?.focus()}
@@ -50,66 +95,28 @@ export function Input(props: InputProps) {
transform: [{ scale }],
}}
>
{/* Outer glow when focused */}
{isFocused && (
{Platform.OS === "ios" ? (
<BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : (
<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={[
containerStyle,
{
height: 60,
fontSize: 22,
fontWeight: "500",
paddingHorizontal: 20,
paddingTop: isFocused ? 4 : 0,
color: "#FFFFFF",
backgroundColor: "transparent",
backgroundColor: isFocused
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.08)",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...otherProps}
/>
</View>
>
{inputElement}
</View>
)}
</Animated.View>
</Pressable>
);

View File

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

View File

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

View File

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

View File

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