From 3fd76b1356c7965c7a01e5f7a97bc3f69bb80cc7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 15:29:12 +0100 Subject: [PATCH] wip --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 787 +++++++++++++++++- app/(auth)/(tabs)/(search)/index.tsx | 32 +- app/(auth)/player/direct-player.tsx | 70 ++ components/common/Input.tsx | 117 +-- components/tv/TVFocusablePoster.tsx | 8 +- .../video-player/controls/Controls.tv.tsx | 103 ++- .../controls/hooks/useRemoteControl.ts | 22 +- translations/en.json | 3 +- 8 files changed, 989 insertions(+), 153 deletions(-) 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",