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