From a86df6c46b041d26ff94348db9d6237433caa30e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 14:48:08 +0100 Subject: [PATCH] wip --- .gitignore | 5 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 25 +- components/Badge.tsx | 36 +- components/ItemContent.tv.tsx | 12 +- components/login/TVLogin.tsx | 78 ++- components/login/TVPreviousServersList.tsx | 216 +++--- .../video-player/controls/Controls.tv.tsx | 623 +++++++++++++++++- .../controls/hooks/useRemoteControl.ts | 31 +- translations/en.json | 3 +- 9 files changed, 885 insertions(+), 144 deletions(-) diff --git a/.gitignore b/.gitignore index 2b3bd6ed..b8c7526a 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,6 @@ npm-debug.* .idea/ .ruby-lsp .cursor/ -.claude/ -CLAUDE.md # Environment and Configuration expo-env.d.ts @@ -72,4 +70,5 @@ modules/background-downloader/android/build/* /modules/mpv-player/android/build # ios:unsigned-build Artifacts -build/ \ No newline at end of file +build/ +.claude/settings.local.json diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index d96bf5b5..e80a47a6 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -15,7 +15,7 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { FlatList, useWindowDimensions, View } from "react-native"; +import { FlatList, Platform, 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"; @@ -24,6 +24,7 @@ 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 { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -49,6 +50,20 @@ import { } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; +const TV_ITEM_GAP = 16; +const TV_SCALE_PADDING = 20; + +const _TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( + + + {item.Name} + + + {item.ProductionYear} + + +); + const Page = () => { const searchParams = useLocalSearchParams() as { libraryId: string; @@ -162,6 +177,14 @@ const Page = () => { ); const nrOfCols = useMemo(() => { + if (Platform.isTV) { + // Calculate columns based on TV poster width + gap + const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; + return Math.max( + 1, + Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), + ); + } if (screenWidth < 300) return 2; if (screenWidth < 500) return 3; if (screenWidth < 800) return 5; diff --git a/components/Badge.tsx b/components/Badge.tsx index a694e3a5..c5806c8d 100644 --- a/components/Badge.tsx +++ b/components/Badge.tsx @@ -38,21 +38,37 @@ export const Badge: React.FC = ({ ); } + // On TV, use transparent backgrounds for a cleaner look + const isTV = Platform.isTV; + return ( - {iconLeft && {iconLeft}} + {iconLeft && {iconLeft}} {text} diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 2476fbff..e3047c62 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -505,12 +505,10 @@ const TVOptionButton: React.FC<{ > {label} @@ -527,7 +525,7 @@ const TVOptionButton: React.FC<{ = React.memo( // Subtitle options for selector (with "None" option) const subtitleOptions = useMemo(() => { const noneOption = { - label: t("subtitles.none") || "None", + label: t("item_card.subtitles.none"), value: -1, selected: selectedOptions?.subtitleIndex === -1, }; @@ -729,7 +727,7 @@ export const ItemContentTV: React.FC = React.memo( const selectedSubtitleLabel = useMemo(() => { if (selectedOptions?.subtitleIndex === -1) - return t("subtitles.none") || "None"; + return t("item_card.subtitles.none"); const track = subtitleTracks.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx index 20b9cb05..52fed37b 100644 --- a/components/login/TVLogin.tsx +++ b/components/login/TVLogin.tsx @@ -17,7 +17,10 @@ import { z } from "zod"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { TVInput } from "@/components/login/TVInput"; -import { TVPreviousServersList } from "@/components/login/TVPreviousServersList"; +import { + TVPreviousServersList, + TVServerActionSheet, +} from "@/components/login/TVPreviousServersList"; import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle"; import { TVServerCard } from "@/components/login/TVServerCard"; import { PasswordEntryModal } from "@/components/PasswordEntryModal"; @@ -26,10 +29,11 @@ import { SaveAccountModal } from "@/components/SaveAccountModal"; import { Colors } from "@/constants/Colors"; import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; -import type { - AccountSecurityType, - SavedServer, - SavedServerAccount, +import { + type AccountSecurityType, + removeServerFromList, + type SavedServer, + type SavedServerAccount, } from "@/utils/secureCredentials"; const CredentialsSchema = z.object({ @@ -84,6 +88,14 @@ export const TVLogin: React.FC = () => { const [selectedAccount, setSelectedAccount] = useState(null); + // Server action sheet state + const [showServerActionSheet, setShowServerActionSheet] = useState(false); + const [actionSheetServer, setActionSheetServer] = + useState(null); + const [loginTriggerServer, setLoginTriggerServer] = + useState(null); + const [actionSheetKey, setActionSheetKey] = useState(0); + // Server discovery const { servers: discoveredServers, @@ -243,6 +255,50 @@ export const TVLogin: React.FC = () => { } }; + // Server action sheet handlers + const handleServerAction = (server: SavedServer) => { + setActionSheetServer(server); + setActionSheetKey((k) => k + 1); // Force remount to reset focus + setShowServerActionSheet(true); + }; + + const handleServerActionLogin = () => { + setShowServerActionSheet(false); + if (actionSheetServer) { + // Trigger the login flow in TVPreviousServersList + setLoginTriggerServer(actionSheetServer); + // Reset the trigger after a tick to allow re-triggering the same server + setTimeout(() => setLoginTriggerServer(null), 0); + } + }; + + const handleServerActionDelete = () => { + if (!actionSheetServer) return; + + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: actionSheetServer.name || actionSheetServer.address, + }), + [ + { + text: t("common.cancel"), + style: "cancel", + onPress: () => setShowServerActionSheet(false), + }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + await removeServerFromList(actionSheetServer.address); + setShowServerActionSheet(false); + setActionSheetServer(null); + }, + }, + ], + ); + }; + const checkUrl = useCallback(async (url: string) => { setLoadingServerCheck(true); const baseUrl = url.replace(/^https?:\/\//i, ""); @@ -593,6 +649,8 @@ export const TVLogin: React.FC = () => { onAddAccount={handleAddAccount} onPinRequired={handlePinRequired} onPasswordRequired={handlePasswordRequired} + onServerAction={handleServerAction} + loginServerOverride={loginTriggerServer} /> @@ -637,6 +695,16 @@ export const TVLogin: React.FC = () => { onSubmit={handlePasswordSubmit} username={selectedAccount?.username || ""} /> + + {/* Server Action Sheet */} + setShowServerActionSheet(false)} + /> ); }; diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index a2afce43..8105db0e 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -1,7 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import type React from "react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, @@ -18,7 +18,6 @@ import { Text } from "@/components/common/Text"; import { deleteAccountCredential, getPreviousServers, - removeServerFromList, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; @@ -123,79 +122,86 @@ const TVServerActionSheet: React.FC<{ }> = ({ visible, server, onLogin, onDelete, onClose }) => { const { t } = useTranslation(); - if (!visible || !server) return null; + if (!server) return null; return ( - - - - {/* Title */} - - {server.name || server.address} - + {/* Title */} + + {server.name || server.address} + - {/* Horizontal options */} - - - - - - - + {/* Horizontal options */} + + + + + + + + + ); }; @@ -213,14 +219,23 @@ interface TVPreviousServersListProps { server: SavedServer, account: SavedServerAccount, ) => void; + // Called when server is pressed to show action sheet (handled by parent) + onServerAction?: (server: SavedServer) => void; + // Called by parent when "Login" is selected from action sheet + loginServerOverride?: SavedServer | null; } +// Export the action sheet for use in parent components +export { TVServerActionSheet }; + export const TVPreviousServersList: React.FC = ({ onServerSelect, onQuickLogin, onAddAccount, onPinRequired, onPasswordRequired, + onServerAction, + loginServerOverride, }) => { const { t } = useTranslation(); const [_previousServers, setPreviousServers] = @@ -230,12 +245,30 @@ export const TVPreviousServersList: React.FC = ({ null, ); const [showAccountsModal, setShowAccountsModal] = useState(false); - const [showActionSheet, setShowActionSheet] = useState(false); const previousServers = useMemo(() => { return JSON.parse(_previousServers || "[]") as SavedServer[]; }, [_previousServers]); + // When parent triggers login via loginServerOverride, execute the login flow + useEffect(() => { + if (loginServerOverride) { + const accountCount = loginServerOverride.accounts?.length || 0; + + if (accountCount === 0) { + onServerSelect(loginServerOverride); + } else if (accountCount === 1) { + handleAccountLogin( + loginServerOverride, + loginServerOverride.accounts[0], + ); + } else { + setSelectedServer(loginServerOverride); + setShowAccountsModal(true); + } + } + }, [loginServerOverride]); + const refreshServers = () => { const servers = getPreviousServers(); setPreviousServers(JSON.stringify(servers)); @@ -281,53 +314,25 @@ export const TVPreviousServersList: React.FC = ({ const handleServerPress = (server: SavedServer) => { if (loadingServer) return; - setSelectedServer(server); - setShowActionSheet(true); - }; - const handleServerLoginAction = () => { - if (!selectedServer) return; - setShowActionSheet(false); - - const accountCount = selectedServer.accounts?.length || 0; + // If onServerAction is provided, delegate to parent for action sheet handling + if (onServerAction) { + onServerAction(server); + return; + } + // Fallback: direct login flow (for backwards compatibility) + const accountCount = server.accounts?.length || 0; if (accountCount === 0) { - onServerSelect(selectedServer); + onServerSelect(server); } else if (accountCount === 1) { - handleAccountLogin(selectedServer, selectedServer.accounts[0]); + handleAccountLogin(server, server.accounts[0]); } else { + setSelectedServer(server); setShowAccountsModal(true); } }; - const handleServerDeleteAction = () => { - if (!selectedServer) return; - - Alert.alert( - t("server.remove_server"), - t("server.remove_server_description", { - server: selectedServer.name || selectedServer.address, - }), - [ - { - text: t("common.cancel"), - style: "cancel", - onPress: () => setShowActionSheet(false), - }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - await removeServerFromList(selectedServer.address); - refreshServers(); - setShowActionSheet(false); - setSelectedServer(null); - }, - }, - ], - ); - }; - const getServerSubtitle = (server: SavedServer): string | undefined => { const accountCount = server.accounts?.length || 0; @@ -498,15 +503,6 @@ export const TVPreviousServersList: React.FC = ({ - - {/* TV Server Action Sheet */} - setShowActionSheet(false)} - /> ); }; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index a5a25a55..1684734e 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -3,8 +3,24 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { type FC, useCallback, useEffect } from "react"; -import { StyleSheet, View } from "react-native"; +import { BlurView } from "expo-blur"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + Animated as RNAnimated, + Easing as RNEasing, + ScrollView, + StyleSheet, + View, +} from "react-native"; import { Slider } from "react-native-awesome-slider"; import Animated, { Easing, @@ -39,11 +55,430 @@ interface Props { seek: (ticks: number) => void; play: () => void; pause: () => void; + audioIndex?: number; + subtitleIndex?: number; + onAudioIndexChange?: (index: number) => void; + onSubtitleIndexChange?: (index: number) => void; } const TV_SEEKBAR_HEIGHT = 16; const TV_AUTO_HIDE_TIMEOUT = 5000; +// Option item type for TV selector +type TVOptionItem = { + label: string; + value: T; + selected: boolean; +}; + +// TV Option Selector - Bottom sheet with horizontal scrolling +const TVOptionSelector = ({ + visible, + title, + options, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + onClose: () => void; +}) => { + const initialSelectedIndex = useMemo(() => { + const idx = options.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [options]); + + if (!visible) return null; + + return ( + + + + {title} + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + + ); +}; + +// Option card for horizontal selector +const TVOptionCard: FC<{ + label: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, selected, hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 150, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + {selected && !focused && ( + + + + )} + + + ); +}; + +// Settings panel with tabs for Audio and Subtitles +const TVSettingsPanel: FC<{ + visible: boolean; + audioOptions: TVOptionItem[]; + subtitleOptions: TVOptionItem[]; + onAudioSelect: (value: number) => void; + onSubtitleSelect: (value: number) => void; + onClose: () => void; + t: (key: string) => string; +}> = ({ + visible, + audioOptions, + subtitleOptions, + onAudioSelect, + onSubtitleSelect, + onClose, + t, +}) => { + const [activeTab, setActiveTab] = useState<"audio" | "subtitle">("audio"); + + const currentOptions = activeTab === "audio" ? audioOptions : subtitleOptions; + const currentOnSelect = + activeTab === "audio" ? onAudioSelect : onSubtitleSelect; + + const initialSelectedIndex = useMemo(() => { + const idx = currentOptions.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [currentOptions]); + + if (!visible) return null; + + return ( + + + + {/* Tab buttons - no preferred focus, navigate here via up from options */} + + {audioOptions.length > 0 && ( + setActiveTab("audio")} + /> + )} + {subtitleOptions.length > 0 && ( + setActiveTab("subtitle")} + /> + )} + + + {/* Options - first selected option gets preferred focus */} + + {currentOptions.map((option, index) => ( + { + currentOnSelect(option.value); + onClose(); + }} + /> + ))} + + + + + ); +}; + +// Tab button for settings panel +const TVSettingsTab: FC<{ + label: string; + active: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ label, active, onPress, hasTVPreferredFocus }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 120, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; + +// Button to open option selector (kept for potential future use) +const _TVControlButton: FC<{ + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; + disabled?: boolean; + onFocusChange?: (focused: boolean) => void; +}> = ({ icon, label, onPress, disabled, onFocusChange }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 120, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.08); + onFocusChange?.(true); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + onFocusChange?.(false); + }} + disabled={disabled} + focusable={!disabled} + > + + + + {label} + + + + ); +}; + +const selectorStyles = StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: 18, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 10, + gap: 12, + }, + card: { + width: 180, + height: 80, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + cardText: { + fontSize: 16, + textAlign: "center", + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, + controlButton: { + borderRadius: 10, + paddingVertical: 10, + paddingHorizontal: 16, + borderWidth: 2, + flexDirection: "row", + alignItems: "center", + }, + controlButtonText: { + fontSize: 14, + fontWeight: "500", + }, + tabRow: { + flexDirection: "row", + paddingHorizontal: 48, + marginBottom: 16, + gap: 24, + }, + tabButton: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + borderBottomWidth: 2, + }, + tabText: { + fontSize: 18, + }, +}); + export const Controls: FC = ({ item, seek, @@ -56,8 +491,92 @@ export const Controls: FC = ({ cacheProgress, showControls, setShowControls, + mediaSource, + audioIndex, + subtitleIndex, + onAudioIndexChange, + onSubtitleIndexChange, }) => { const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + + // Modal state for option selectors + // "settings" shows the settings panel, "audio"/"subtitle" for direct selection + type ModalType = "settings" | "audio" | "subtitle" | null; + const [openModal, setOpenModal] = useState(null); + const isModalOpen = openModal !== null; + + // Handle swipe up to open settings panel + const handleSwipeUp = useCallback(() => { + if (!isModalOpen) { + setOpenModal("settings"); + } + }, [isModalOpen]); + + // Get available audio tracks + const audioTracks = useMemo(() => { + return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; + }, [mediaSource]); + + // Get available subtitle tracks + const subtitleTracks = useMemo(() => { + return ( + mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [] + ); + }, [mediaSource]); + + // Audio options for selector + const audioOptions = useMemo(() => { + return audioTracks.map((track) => ({ + label: + track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, + value: track.Index!, + selected: track.Index === audioIndex, + })); + }, [audioTracks, audioIndex]); + + // Subtitle options for selector (with "None" option) + const subtitleOptions = useMemo(() => { + const noneOption = { + label: t("item_card.subtitles.none"), + value: -1, + selected: subtitleIndex === -1, + }; + const trackOptions = subtitleTracks.map((track) => ({ + label: + track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, + value: track.Index!, + selected: track.Index === subtitleIndex, + })); + return [noneOption, ...trackOptions]; + }, [subtitleTracks, subtitleIndex, t]); + + // Get display labels for buttons + const _selectedAudioLabel = useMemo(() => { + const track = audioTracks.find((t) => t.Index === audioIndex); + return track?.DisplayTitle || track?.Language || t("item_card.audio"); + }, [audioTracks, audioIndex, t]); + + const _selectedSubtitleLabel = useMemo(() => { + if (subtitleIndex === -1) return t("item_card.subtitles.none"); + const track = subtitleTracks.find((t) => t.Index === subtitleIndex); + return track?.DisplayTitle || track?.Language || t("item_card.subtitles"); + }, [subtitleTracks, subtitleIndex, t]); + + // Handlers for option changes + const handleAudioChange = useCallback( + (index: number) => { + onAudioIndexChange?.(index); + }, + [onAudioIndexChange], + ); + + const handleSubtitleChange = useCallback( + (index: number) => { + onSubtitleIndexChange?.(index); + }, + [onSubtitleIndexChange], + ); const { trickPlayUrl, @@ -139,7 +658,6 @@ export const Controls: FC = ({ isRemoteScrubbing, showRemoteBubble, isSliding: isRemoteSliding, - time: remoteTime, } = useRemoteControl({ progress, min, @@ -153,6 +671,8 @@ export const Controls: FC = ({ calculateTrickplayUrl, handleSeekForward, handleSeekBackward, + disableSeeking: isModalOpen, + onSwipeUp: handleSwipeUp, }); // Slider hook @@ -217,6 +737,12 @@ export const Controls: FC = ({ disabled: false, }); + // Check if we have any settings to show + const hasSettings = + audioTracks.length > 0 || + subtitleTracks.length > 0 || + subtitleIndex !== undefined; + return ( {/* Center Play Button - shown when paused */} @@ -228,9 +754,39 @@ export const Controls: FC = ({ )} + {/* Top hint - swipe up for settings */} + {showControls && hasSettings && !isModalOpen && ( + + + + + + {t("player.swipe_up_settings")} + + + + + )} + = ({ /> - {/* Time Display - TV sized */} + {/* Time Display */} {formatTimeString(currentTime, "ms")} @@ -305,6 +861,34 @@ export const Controls: FC = ({ + + {/* Settings panel - shows audio and subtitle options */} + setOpenModal(null)} + t={t} + /> + + {/* Direct option selector modals (for future use) */} + setOpenModal(null)} + /> + + setOpenModal(null)} + /> ); }; @@ -335,6 +919,17 @@ const styles = StyleSheet.create({ alignItems: "center", paddingLeft: 8, }, + topContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, + topInner: { + flexDirection: "row", + justifyContent: "flex-end", + }, bottomContainer: { position: "absolute", bottom: 0, @@ -368,10 +963,28 @@ const styles = StyleSheet.create({ timeContainer: { flexDirection: "row", justifyContent: "space-between", + alignItems: "center", marginTop: 12, }, timeText: { color: "rgba(255,255,255,0.7)", fontSize: 22, }, + settingsRow: { + flexDirection: "row", + gap: 12, + }, + settingsHint: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: "rgba(0,0,0,0.3)", + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 20, + }, + settingsHintText: { + color: "rgba(255,255,255,0.5)", + fontSize: 14, + }, }); diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index f1a7822f..b3e71886 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -31,6 +31,10 @@ interface UseRemoteControlProps { calculateTrickplayUrl: (progressInTicks: number) => void; handleSeekForward: (seconds: number) => void; 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; } /** @@ -50,6 +54,8 @@ export function useRemoteControl({ calculateTrickplayUrl, handleSeekForward, handleSeekBackward, + disableSeeking = false, + onSwipeUp, }: UseRemoteControlProps) { const remoteScrubProgress = useSharedValue(null); const isRemoteScrubbing = useSharedValue(false); @@ -63,6 +69,15 @@ export function useRemoteControl({ const longPressTimeoutRef = useRef | null>( null, ); + + // Use ref to track disableSeeking so the callback always has current value + const disableSeekingRef = useRef(disableSeeking); + disableSeekingRef.current = disableSeeking; + + // Use ref for onSwipeUp callback + const onSwipeUpRef = useRef(onSwipeUp); + onSwipeUpRef.current = onSwipeUp; + // MPV uses ms const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS; @@ -82,15 +97,21 @@ export function useRemoteControl({ switch (evt.eventType) { case "longLeft": { + if (disableSeekingRef.current) break; setLongPressScrubMode((prev) => (!prev ? "RW" : null)); break; } case "longRight": { + if (disableSeekingRef.current) break; setLongPressScrubMode((prev) => (!prev ? "FF" : null)); break; } case "left": case "right": { + // Skip seeking if disabled (e.g., when settings modal is open) + if (disableSeekingRef.current) { + break; + } isRemoteScrubbing.value = true; setShowRemoteBubble(true); @@ -127,12 +148,18 @@ export function useRemoteControl({ break; } case "down": - case "up": - // cancel scrubbing on other directions + // cancel scrubbing on down isRemoteScrubbing.value = false; remoteScrubProgress.value = null; setShowRemoteBubble(false); break; + case "up": + // cancel scrubbing and trigger swipe up callback (for settings) + isRemoteScrubbing.value = false; + remoteScrubProgress.value = null; + setShowRemoteBubble(false); + onSwipeUpRef.current?.(); + break; default: break; } diff --git a/translations/en.json b/translations/en.json index 9a55dd80..fa44df98 100644 --- a/translations/en.json +++ b/translations/en.json @@ -611,7 +611,8 @@ "downloaded_file_message": "Do you want to play the downloaded file?", "downloaded_file_yes": "Yes", "downloaded_file_no": "No", - "downloaded_file_cancel": "Cancel" + "downloaded_file_cancel": "Cancel", + "swipe_up_settings": "Swipe up for settings" }, "item_card": { "next_up": "Next Up",