From 0ebacd4bd35cac560fb1d848ee42f9f9cacf97ed Mon Sep 17 00:00:00 2001 From: ryan0204 Date: Wed, 8 Jan 2025 11:29:49 +0800 Subject: [PATCH 1/4] Auto hide control after 5 seconds --- components/video-player/controls/Controls.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 1f436f7f..3927587a 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -280,8 +280,38 @@ export const Controls: React.FC = ({ useEffect(() => { prefetchAllTrickplayImages(); }, []); + + const CONTROLS_TIMEOUT = 5000; + const controlsTimeoutRef = useRef(); + + useEffect(() => { + const resetControlsTimeout = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + if (showControls && !isSliding && !EpisodeView) { + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + setShowAudioSlider(false); + }, CONTROLS_TIMEOUT); + } + }; + + resetControlsTimeout(); + + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, [showControls, isSliding, EpisodeView]); + const toggleControls = () => { if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } setShowAudioSlider(false); setShowControls(false); } else { @@ -289,6 +319,18 @@ export const Controls: React.FC = ({ } }; + const handleControlsInteraction = () => { + if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + setShowAudioSlider(false); + }, CONTROLS_TIMEOUT); + } + }; + const handleSliderStart = useCallback(() => { if (showControls === false) return; @@ -731,6 +773,7 @@ export const Controls: React.FC = ({ }, ]} className={`flex flex-col p-4`} + onTouchStart={handleControlsInteraction} > Date: Sat, 11 Jan 2025 16:41:41 +0800 Subject: [PATCH 2/4] prevent opening control when user swipe on screen --- components/video-player/controls/Controls.tsx | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 3927587a..85cc5e62 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -40,6 +40,7 @@ import { TouchableOpacity, useWindowDimensions, View, + GestureResponderEvent, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { @@ -531,6 +532,36 @@ export const Controls: React.FC = ({ // Used when user changes audio through audio button on device. const [showAudioSlider, setShowAudioSlider] = useState(false); + // Prevent opening controls when user swipes on the screen. + const touchStartTime = useRef(0); + const touchStartPosition = useRef({ x: 0, y: 0 }); + + const handleTouchStart = (event: GestureResponderEvent) => { + touchStartTime.current = Date.now(); + touchStartPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + }; + + const handleTouchEnd = (event: GestureResponderEvent) => { + const touchEndTime = Date.now(); + const touchEndPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + const touchDuration = touchEndTime - touchStartTime.current; + const touchDistance = Math.sqrt( + Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + + Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) + ); + + if (touchDuration < 200 && touchDistance < 10) { + toggleControls(); + } + }; + return ( = ({ ) : ( <> { - toggleControls(); - }} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} style={{ position: "absolute", width: screenWidth, @@ -560,7 +590,7 @@ export const Controls: React.FC = ({ bottom: 0, opacity: showControls ? 0.5 : 0, }} - > + /> Date: Sun, 12 Jan 2025 10:07:49 +0100 Subject: [PATCH 3/4] chore: refactor --- components/home/LargeMovieCarousel.tsx | 11 +- components/video-player/controls/Controls.tsx | 263 +++++++----------- .../controls/VideoTouchOverlay.tsx | 38 +++ .../controls/useControlsTimeout.ts | 56 ++++ .../video-player/controls/useTapDetection.tsx | 48 ++++ 5 files changed, 246 insertions(+), 170 deletions(-) create mode 100644 components/video-player/controls/VideoTouchOverlay.tsx create mode 100644 components/video-player/controls/useControlsTimeout.ts create mode 100644 components/video-player/controls/useTapDetection.tsx diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 00767621..6f9d3a1e 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -1,3 +1,4 @@ +import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; +import { useRouter, useSegments } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; -import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native"; +import { Dimensions, View, ViewProps } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { runOnJS, useSharedValue, @@ -18,11 +21,7 @@ import Carousel, { ICarouselInstance, Pagination, } from "react-native-reanimated-carousel"; -import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter"; -import { Loader } from "../Loader"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import { useRouter, useSegments } from "expo-router"; -import { useHaptic } from "@/hooks/useHaptic"; +import { itemRouter } from "../common/TouchableItemRouter"; interface Props extends ViewProps {} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 85cc5e62..ea53a2d3 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -36,11 +36,11 @@ import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { + GestureResponderEvent, Pressable, TouchableOpacity, useWindowDimensions, View, - GestureResponderEvent, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { @@ -60,6 +60,9 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; +import { useTapDetection } from "./useTapDetection"; +import { VideoTouchOverlay } from "./VideoTouchOverlay"; +import { useControlsTimeout } from "./useControlsTimeout"; interface Props { item: BaseItemDto; @@ -90,6 +93,8 @@ interface Props { isVlc?: boolean; } +const CONTROLS_TIMEOUT = 4000; + export const Controls: React.FC = ({ item, seek, @@ -122,6 +127,12 @@ export const Controls: React.FC = ({ const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); + const [episodeView, setEpisodeView] = useState(false); + const [isSliding, setIsSliding] = useState(false); + + // Used when user changes audio through audio button on device. + const [showAudioSlider, setShowAudioSlider] = useState(false); + const { height: screenHeight, width: screenWidth } = useWindowDimensions(); const { previousItem, nextItem } = useAdjacentItems({ item }); const { @@ -140,6 +151,23 @@ export const Controls: React.FC = ({ const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); + const lightHapticFeedback = useHaptic("light"); + + useEffect(() => { + prefetchAllTrickplayImages(); + }, []); + + useEffect(() => { + if (item) { + progress.value = isVlc + ? ticksToMs(item?.UserData?.PlaybackPositionTicks) + : item?.UserData?.PlaybackPositionTicks || 0; + max.value = isVlc + ? ticksToMs(item.RunTimeTicks || 0) + : item.RunTimeTicks || 0; + } + }, [item, isVlc]); + const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ bitrateValue: string; audioIndex: string; @@ -162,8 +190,6 @@ export const Controls: React.FC = ({ isVlc ); - const lightHapticFeedback = useHaptic("light"); - const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; @@ -267,52 +293,21 @@ export const Controls: React.FC = ({ [updateTimes] ); - useEffect(() => { - if (item) { - progress.value = isVlc - ? ticksToMs(item?.UserData?.PlaybackPositionTicks) - : item?.UserData?.PlaybackPositionTicks || 0; - max.value = isVlc - ? ticksToMs(item.RunTimeTicks || 0) - : item.RunTimeTicks || 0; - } - }, [item, isVlc]); - - useEffect(() => { - prefetchAllTrickplayImages(); + const hideControls = useCallback(() => { + setShowControls(false); + setShowAudioSlider(false); }, []); - const CONTROLS_TIMEOUT = 5000; - const controlsTimeoutRef = useRef(); - - useEffect(() => { - const resetControlsTimeout = () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - - if (showControls && !isSliding && !EpisodeView) { - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - setShowAudioSlider(false); - }, CONTROLS_TIMEOUT); - } - }; - - resetControlsTimeout(); - - return () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }; - }, [showControls, isSliding, EpisodeView]); + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding, + episodeView, + onHideControls: hideControls, + timeout: CONTROLS_TIMEOUT, + }); const toggleControls = () => { if (showControls) { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } setShowAudioSlider(false); setShowControls(false); } else { @@ -320,18 +315,6 @@ export const Controls: React.FC = ({ } }; - const handleControlsInteraction = () => { - if (showControls) { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - setShowAudioSlider(false); - }, CONTROLS_TIMEOUT); - } - }; - const handleSliderStart = useCallback(() => { if (showControls === false) return; @@ -343,16 +326,13 @@ export const Controls: React.FC = ({ isSeeking.value = true; }, [showControls, isPlaying]); - const [isSliding, setIsSliding] = useState(false); const handleSliderComplete = useCallback( async (value: number) => { isSeeking.value = false; progress.value = value; setIsSliding(false); - await seek( - Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) - ); + seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); if (wasPlayingRef.current === true) play(); }, [isVlc] @@ -382,7 +362,7 @@ export const Controls: React.FC = ({ const newTime = isVlc ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); - await seek(newTime); + seek(newTime); if (wasPlayingRef.current === true) play(); } } catch (error) { @@ -400,7 +380,7 @@ export const Controls: React.FC = ({ const newTime = isVlc ? curr + secondsToMs(settings.forwardSkipTime) : ticksToSeconds(curr) + settings.forwardSkipTime; - await seek(Math.max(0, newTime)); + seek(Math.max(0, newTime)); if (wasPlayingRef.current === true) play(); } } catch (error) { @@ -408,11 +388,62 @@ export const Controls: React.FC = ({ } }, [settings, isPlaying, isVlc]); + const goToItem = useCallback( + async (itemId: string) => { + try { + const gotoItem = await getItemById(api, itemId); + if (!settings || !gotoItem) return; + + lightHapticFeedback(); + + const previousIndexes: previousIndexes = { + subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, + audioIndex: audioIndex ? parseInt(audioIndex) : undefined, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( + gotoItem, + settings, + previousIndexes, + mediaSource ?? undefined + ); + + const queryParams = new URLSearchParams({ + itemId: gotoItem.Id ?? "", // Ensure itemId is a string + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrateValue.toString(), + }).toString(); + + if (!bitrateValue) { + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + return; + } + // @ts-expect-error + router.replace(`player/transcoding-player?${queryParams}`); + } catch (error) { + console.error("Error in gotoEpisode:", error); + } + }, + [settings, subtitleIndex, audioIndex] + ); + const toggleIgnoreSafeAreas = useCallback(() => { setIgnoreSafeAreas((prev) => !prev); lightHapticFeedback(); }, []); + const switchOnEpisodeMode = useCallback(() => { + setEpisodeView(true); + if (isPlaying) togglePlay(); + }, [isPlaying, togglePlay]); + const memoizedRenderBubble = useCallback(() => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -476,99 +507,13 @@ export const Controls: React.FC = ({ ); }, [trickPlayUrl, trickplayInfo, time]); - const [EpisodeView, setEpisodeView] = useState(false); - - const switchOnEpisodeMode = () => { - setEpisodeView(true); - if (isPlaying) togglePlay(); - }; - - const goToItem = useCallback( - async (itemId: string) => { - try { - const gotoItem = await getItemById(api, itemId); - if (!settings || !gotoItem) return; - - lightHapticFeedback(); - - const previousIndexes: previousIndexes = { - subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, - audioIndex: audioIndex ? parseInt(audioIndex) : undefined, - }; - - const { - mediaSource: newMediaSource, - audioIndex: defaultAudioIndex, - subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings( - gotoItem, - settings, - previousIndexes, - mediaSource ?? undefined - ); - - const queryParams = new URLSearchParams({ - itemId: gotoItem.Id ?? "", // Ensure itemId is a string - audioIndex: defaultAudioIndex?.toString() ?? "", - subtitleIndex: defaultSubtitleIndex?.toString() ?? "", - mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string - bitrateValue: bitrateValue.toString(), - }).toString(); - - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } - // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); - } catch (error) { - console.error("Error in gotoEpisode:", error); - } - }, - [settings, subtitleIndex, audioIndex] - ); - - // Used when user changes audio through audio button on device. - const [showAudioSlider, setShowAudioSlider] = useState(false); - - // Prevent opening controls when user swipes on the screen. - const touchStartTime = useRef(0); - const touchStartPosition = useRef({ x: 0, y: 0 }); - - const handleTouchStart = (event: GestureResponderEvent) => { - touchStartTime.current = Date.now(); - touchStartPosition.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - }; - - const handleTouchEnd = (event: GestureResponderEvent) => { - const touchEndTime = Date.now(); - const touchEndPosition = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - - const touchDuration = touchEndTime - touchStartTime.current; - const touchDistance = Math.sqrt( - Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + - Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) - ); - - if (touchDuration < 200 && touchDistance < 10) { - toggleControls(); - } - }; - return ( - {EpisodeView ? ( + {episodeView ? ( setEpisodeView(false)} @@ -576,22 +521,12 @@ export const Controls: React.FC = ({ /> ) : ( <> - - void; +} + +export const VideoTouchOverlay = ({ + screenWidth, + screenHeight, + showControls, + onToggleControls, +}: Props) => { + const { handleTouchStart, handleTouchEnd } = useTapDetection({ + onValidTap: onToggleControls, + }); + + return ( + + ); +}; diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts new file mode 100644 index 00000000..ac10fff3 --- /dev/null +++ b/components/video-player/controls/useControlsTimeout.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; + +interface UseControlsTimeoutProps { + showControls: boolean; + isSliding: boolean; + episodeView: boolean; + onHideControls: () => void; + timeout?: number; +} + +export const useControlsTimeout = ({ + showControls, + isSliding, + episodeView, + onHideControls, + timeout = 4000, +}: UseControlsTimeoutProps) => { + const controlsTimeoutRef = useRef(); + + useEffect(() => { + const resetControlsTimeout = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + if (showControls && !isSliding && !episodeView) { + controlsTimeoutRef.current = setTimeout(() => { + onHideControls(); + }, timeout); + } + }; + + resetControlsTimeout(); + + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, [showControls, isSliding, episodeView, timeout, onHideControls]); + + const handleControlsInteraction = () => { + if (showControls) { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + controlsTimeoutRef.current = setTimeout(() => { + onHideControls(); + }, timeout); + } + }; + + return { + handleControlsInteraction, + }; +}; diff --git a/components/video-player/controls/useTapDetection.tsx b/components/video-player/controls/useTapDetection.tsx new file mode 100644 index 00000000..041e6d39 --- /dev/null +++ b/components/video-player/controls/useTapDetection.tsx @@ -0,0 +1,48 @@ +import { useRef } from "react"; +import { GestureResponderEvent } from "react-native"; + +interface TapDetectionOptions { + maxDuration?: number; + maxDistance?: number; + onValidTap?: () => void; +} + +export const useTapDetection = ({ + maxDuration = 200, + maxDistance = 10, + onValidTap, +}: TapDetectionOptions = {}) => { + const touchStartTime = useRef(0); + const touchStartPosition = useRef({ x: 0, y: 0 }); + + const handleTouchStart = (event: GestureResponderEvent) => { + touchStartTime.current = Date.now(); + touchStartPosition.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + }; + + const handleTouchEnd = (event: GestureResponderEvent) => { + const touchEndTime = Date.now(); + const touchEndPosition = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + + const touchDuration = touchEndTime - touchStartTime.current; + const touchDistance = Math.sqrt( + Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) + + Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2) + ); + + if (touchDuration < maxDuration && touchDistance < maxDistance) { + onValidTap?.(); + } + }; + + return { + handleTouchStart, + handleTouchEnd, + }; +}; From 7832ea4d0a241a3a88102b3539709e2379cac1f7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 12 Jan 2025 10:10:18 +0100 Subject: [PATCH 4/4] chore: deps --- components/video-player/controls/Controls.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index ea53a2d3..6a5f3caa 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -35,13 +35,7 @@ import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - GestureResponderEvent, - Pressable, - TouchableOpacity, - useWindowDimensions, - View, -} from "react-native"; +import { TouchableOpacity, useWindowDimensions, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, @@ -60,9 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; -import { useTapDetection } from "./useTapDetection"; -import { VideoTouchOverlay } from "./VideoTouchOverlay"; import { useControlsTimeout } from "./useControlsTimeout"; +import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { item: BaseItemDto;