diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 1e64f16a..928fa6c9 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -4,7 +4,7 @@ import type { MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; import { useLocalSearchParams } from "expo-router"; -import { type FC, useCallback, useEffect, useState } from "react"; +import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import Animated, { Easing, @@ -30,6 +30,7 @@ import { ticksToMs } from "@/utils/time"; import { BottomControls } from "./BottomControls"; import { CenterControls } from "./CenterControls"; import { CONTROLS_CONSTANTS } from "./constants"; +import { ControlsProvider } from "./contexts/ControlsContext"; import { EpisodeList } from "./EpisodeList"; import { GestureOverlay } from "./GestureOverlay"; import { HeaderControls } from "./HeaderControls"; @@ -110,6 +111,12 @@ export const Controls: FC = ({ const [episodeView, setEpisodeView] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false); + // Pause the controls auto-hide while the player settings popover is open so + // it can't be dismissed out from under the user (notably the iOS popover, + // which lives inside the controls and closes when they fade out). The popover + // reports its open state through ControlsContext rather than prop drilling. + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); + const controlsContextValue = useMemo(() => ({ setSettingsMenuOpen }), []); const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { previousItem, nextItem } = usePlaybackManager({ @@ -467,7 +474,7 @@ export const Controls: FC = ({ episodeView, onHideControls: hideControls, timeout: CONTROLS_CONSTANTS.TIMEOUT, - disabled: true, + disabled: settingsMenuOpen, }); const switchOnEpisodeMode = useCallback(() => { @@ -478,122 +485,124 @@ export const Controls: FC = ({ }, [isPlaying, togglePlay]); return ( - - {episodeView ? ( - setEpisodeView(false)} - goToItem={goToItemCommon} - /> - ) : ( - <> - + + {episodeView ? ( + setEpisodeView(false)} + goToItem={goToItemCommon} /> - {/* Technical Info Overlay - rendered outside animated views to stay visible */} - {getTechnicalInfo && ( - + - )} - - - - - - - - - - - )} - {settings.maxAutoPlayEpisodeCount.value !== -1 && ( - - )} - + {/* Technical Info Overlay - rendered outside animated views to stay visible */} + {getTechnicalInfo && ( + + )} + + + + + + + + + + + )} + {settings.maxAutoPlayEpisodeCount.value !== -1 && ( + + )} + + ); }; diff --git a/components/video-player/controls/contexts/ControlsContext.tsx b/components/video-player/controls/contexts/ControlsContext.tsx new file mode 100644 index 00000000..6cdd5fa9 --- /dev/null +++ b/components/video-player/controls/contexts/ControlsContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from "react"; + +interface ControlsContextProps { + /** + * Lets descendants (e.g. the settings popover) pause the controls auto-hide + * while an interactive menu is open, so it can't be dismissed out from under + * the user. Mirrors the `settingsMenuOpen` state owned by `Controls`. + */ + setSettingsMenuOpen: (open: boolean) => void; +} + +const ControlsContext = createContext( + undefined, +); + +export const ControlsProvider = ControlsContext.Provider; + +export const useControlsContext = () => { + const ctx = useContext(ControlsContext); + if (!ctx) { + throw new Error( + "useControlsContext must be used within a ControlsProvider", + ); + } + return ctx; +}; diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 9246ae9d..a4d5c333 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -7,6 +7,7 @@ import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import useRouter from "@/hooks/useAppRouter"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { useControlsContext } from "../contexts/ControlsContext"; import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; @@ -30,6 +31,8 @@ const DropdownView = ({ }: DropdownViewProps) => { const { subtitleTracks, audioTracks } = useVideoContext(); const { item, mediaSource } = usePlayerContext(); + // Report popover open/close so Controls can pause auto-hide while it's open. + const { setSettingsMenuOpen } = useControlsContext(); const { settings, updateSettings } = useSettings(); const router = useRouter(); const isOffline = useOfflineMode(); @@ -223,6 +226,7 @@ const DropdownView = ({ groups={optionGroups} trigger={trigger} expoUIConfig={{}} + onOpenChange={setSettingsMenuOpen} bottomSheetConfig={{ enablePanDownToClose: true, }}