mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-14 01:40:23 +01:00
wip
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user