diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index e80a47a6..14d63349 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -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 }) => (
{item.Name}
@@ -64,6 +89,315 @@ const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
);
+// TV Filter Types and Components
+type TVFilterModalType =
+ | "genre"
+ | "year"
+ | "tags"
+ | "sortBy"
+ | "sortOrder"
+ | "filterBy"
+ | null;
+
+interface TVFilterOption {
+ label: string;
+ value: T;
+ selected: boolean;
+}
+
+const TVFilterOptionCard: React.FC<{
+ label: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+ {label}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+};
+
+const TVFilterButton: React.FC<{
+ label: string;
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ hasActiveFilter?: boolean;
+}> = ({
+ label,
+ value,
+ onPress,
+ hasTVPreferredFocus,
+ disabled,
+ hasActiveFilter,
+}) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 120,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.04);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+};
+
+const TVFilterSelector = ({
+ visible,
+ title,
+ options,
+ onSelect,
+ onClose,
+ multiSelect = false,
+}: {
+ visible: boolean;
+ title: string;
+ options: TVFilterOption[];
+ 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(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 (
+
+
+
+
+
+ {title}
+
+ {multiSelect && (
+ {
+ setDoneButtonFocused(true);
+ animateDone(1.05);
+ }}
+ onBlur={() => {
+ setDoneButtonFocused(false);
+ animateDone(1);
+ }}
+ >
+
+
+ Done
+
+
+
+ )}
+
+
+ {options.map((option, index) => (
+ {
+ onSelect(option.value);
+ if (!multiSelect) {
+ onClose();
+ }
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
+
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(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 = () => {
),
- [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 (
+
+
+ {item.Type === "Movie" && }
+ {(item.Type === "Series" || item.Type === "Episode") && (
+
+ )}
+ {item.Type !== "Movie" &&
+ item.Type !== "Series" &&
+ item.Type !== "Episode" && }
+
+
+
+ );
+ },
+ [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[] =>
+ (tvGenreOptions || []).map((genre) => ({
+ label: genre,
+ value: genre,
+ selected: selectedGenres.includes(genre),
+ })),
+ [tvGenreOptions, selectedGenres],
+ );
+
+ const tvYearFilterOptions = useMemo(
+ (): TVFilterOption[] =>
+ (tvYearOptions || []).map((year) => ({
+ label: String(year),
+ value: String(year),
+ selected: selectedYears.includes(String(year)),
+ })),
+ [tvYearOptions, selectedYears],
+ );
+
+ const tvTagFilterOptions = useMemo(
+ (): TVFilterOption[] =>
+ (tvTagOptions || []).map((tag) => ({
+ label: tag,
+ value: tag,
+ selected: selectedTags.includes(tag),
+ })),
+ [tvTagOptions, selectedTags],
+ );
+
+ const tvSortByOptions = useMemo(
+ (): TVFilterOption[] =>
+ sortOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortBy[0] === option.key,
+ })),
+ [sortBy],
+ );
+
+ const tvSortOrderOptions = useMemo(
+ (): TVFilterOption[] =>
+ sortOrderOptions.map((option) => ({
+ label: option.value,
+ value: option.key,
+ selected: sortOrder[0] === option.key,
+ })),
+ [sortOrder],
+ );
+
+ const tvFilterByOptions = useMemo(
+ (): TVFilterOption[] =>
+ 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 = () => {
);
- return (
-
-
- {t("library.no_results")}
-
-
- }
- contentInsetAdjustmentBehavior='automatic'
- data={flatData}
- renderItem={renderItem}
- extraData={[orientation, nrOfCols]}
- keyExtractor={keyExtractor}
- numColumns={nrOfCols}
- onEndReached={() => {
- if (hasNextPage) {
- fetchNextPage();
+ // Mobile return
+ if (!Platform.isTV) {
+ return (
+
+
+ {t("library.no_results")}
+
+
}
- }}
- 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={() => (
+
+ )}
+ />
+ );
+ }
+
+ // TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
+ return (
+
+ {/* Background content - disabled when modal is open */}
+
+ {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
+ {hasActiveFilters && (
+
+ )}
+ 0
+ ? `${selectedGenres.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={() => setOpenFilterModal("genre")}
+ hasTVPreferredFocus={!hasActiveFilters}
+ disabled={isFilterModalOpen}
+ hasActiveFilter={selectedGenres.length > 0}
+ />
+ 0
+ ? `${selectedYears.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={() => setOpenFilterModal("year")}
+ disabled={isFilterModalOpen}
+ hasActiveFilter={selectedYears.length > 0}
+ />
+ 0
+ ? `${selectedTags.length} selected`
+ : t("library.filters.all")
+ }
+ onPress={() => setOpenFilterModal("tags")}
+ disabled={isFilterModalOpen}
+ hasActiveFilter={selectedTags.length > 0}
+ />
+ o.key === sortBy[0])?.value || ""}
+ onPress={() => setOpenFilterModal("sortBy")}
+ disabled={isFilterModalOpen}
+ />
+ o.key === sortOrder[0])?.value || ""
+ }
+ onPress={() => setOpenFilterModal("sortOrder")}
+ disabled={isFilterModalOpen}
+ />
+ 0
+ ? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
+ : t("library.filters.all")
+ }
+ onPress={() => setOpenFilterModal("filterBy")}
+ disabled={isFilterModalOpen}
+ hasActiveFilter={filterBy.length > 0}
+ />
+
+
+ {/* Grid - using FlatList instead of FlashList to fix focus issues */}
+
+
+ {t("library.no_results")}
+
+
+ }
+ 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={() => (
+
+ )}
/>
- )}
- />
+
+
+ {/* TV Filter Overlays */}
+ setOpenFilterModal(null)}
+ multiSelect
+ />
+ setOpenFilterModal(null)}
+ multiSelect
+ />
+ setOpenFilterModal(null)}
+ multiSelect
+ />
+ setSortBy([value])}
+ onClose={() => setOpenFilterModal(null)}
+ />
+ setSortOrder([value])}
+ onClose={() => setOpenFilterModal(null)}
+ />
+ setFilter([value])}
+ onClose={() => setOpenFilterModal(null)}
+ />
+
);
};
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 751b1df1..24ecd00a 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -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 && (
- {
- router.setParams({ q: "" });
- setSearch(text);
- }}
- keyboardType='default'
- returnKeyType='done'
- autoCapitalize='none'
- clearButtonMode='while-editing'
- maxLength={500}
- />
+
+ {
+ router.setParams({ q: "" });
+ setSearch(text);
+ }}
+ keyboardType='default'
+ returnKeyType='done'
+ autoCapitalize='none'
+ clearButtonMode='while-editing'
+ maxLength={500}
+ />
+
)}
(undefined);
+ const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState(-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}
/>
) : (
+
+
+ >
+ );
+
return (
inputRef.current?.focus()}
@@ -50,66 +95,28 @@ export function Input(props: InputProps) {
transform: [{ scale }],
}}
>
- {/* Outer glow when focused */}
- {isFocused && (
+ {Platform.OS === "ios" ? (
+
+ {inputElement}
+
+ ) : (
- )}
-
-
- {/* Purple accent bar at top when focused */}
- {isFocused && (
-
- )}
-
-
-
+ >
+ {inputElement}
+
+ )}
);
diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx
index fc89b70f..ddce728f 100644
--- a/components/tv/TVFocusablePoster.tsx
+++ b/components/tv/TVFocusablePoster.tsx
@@ -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 = ({
@@ -21,6 +22,7 @@ export const TVFocusablePoster: React.FC = ({
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 = ({
animateTo(1);
onBlurProp?.();
}}
- hasTVPreferredFocus={hasTVPreferredFocus}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
>
- {/* Tab buttons - no preferred focus, navigate here via up from options */}
+ {/* Tab buttons - switch automatically on focus */}
{audioOptions.length > 0 && (
setActiveTab("audio")}
+ onSelect={() => setActiveTab("audio")}
/>
)}
{subtitleOptions.length > 0 && (
setActiveTab("subtitle")}
+ onSelect={() => setActiveTab("subtitle")}
/>
)}
@@ -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 (
{
setFocused(true);
animateTo(1.05);
+ // Switch tab automatically on focus
+ onSelect();
}}
onBlur={() => {
setFocused(false);
@@ -506,8 +507,8 @@ export const Controls: FC = ({
const [openModal, setOpenModal] = useState(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 = ({
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 = ({
handleSeekForward,
handleSeekBackward,
disableSeeking: isModalOpen,
- onSwipeUp: handleSwipeUp,
+ onSwipeDown: handleSwipeDown,
});
// Slider hook
@@ -748,9 +759,16 @@ export const Controls: FC = ({
{/* Center Play Button - shown when paused */}
{!isPlaying && showControls && (
-
-
-
+
+
+
+
+
)}
@@ -771,14 +789,14 @@ export const Controls: FC = ({
]}
>
+
+ {t("player.swipe_down_settings")}
+
-
- {t("player.swipe_up_settings")}
-
@@ -855,9 +873,14 @@ export const Controls: FC = ({
{formatTimeString(currentTime, "ms")}
-
- -{formatTimeString(remainingTime, "ms")}
-
+
+
+ -{formatTimeString(remainingTime, "ms")}
+
+
+ {t("player.ends_at")} {getFinishTime()}
+
+
@@ -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)",
diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts
index b3e71886..2920495e 100644
--- a/components/video-player/controls/hooks/useRemoteControl.ts
+++ b/components/video-player/controls/hooks/useRemoteControl.ts
@@ -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(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;
diff --git a/translations/en.json b/translations/en.json
index fa44df98..08b8a676 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -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",