Merge pull request #394 from Ryan0204/enhancement/autohidecontrol

enhancement: auto hide control after 5 seconds
This commit is contained in:
Fredrik Burmester
2025-01-12 10:16:55 +01:00
committed by GitHub
5 changed files with 249 additions and 107 deletions

View File

@@ -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 {}

View File

@@ -35,12 +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 {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -59,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -89,6 +86,8 @@ interface Props {
isVlc?: boolean;
}
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
@@ -121,6 +120,12 @@ export const Controls: React.FC<Props> = ({
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 {
@@ -139,6 +144,23 @@ export const Controls: React.FC<Props> = ({
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(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;
@@ -161,8 +183,6 @@ export const Controls: React.FC<Props> = ({
isVlc
);
const lightHapticFeedback = useHaptic("light");
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
@@ -266,20 +286,19 @@ export const Controls: React.FC<Props> = ({
[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 { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding,
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_TIMEOUT,
});
const toggleControls = () => {
if (showControls) {
setShowAudioSlider(false);
@@ -300,16 +319,13 @@ export const Controls: React.FC<Props> = ({
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]
@@ -339,7 +355,7 @@ export const Controls: React.FC<Props> = ({
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) {
@@ -357,7 +373,7 @@ export const Controls: React.FC<Props> = ({
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) {
@@ -365,11 +381,62 @@ export const Controls: React.FC<Props> = ({
}
}, [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;
@@ -433,69 +500,13 @@ export const Controls: React.FC<Props> = ({
);
}, [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);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
{EpisodeView ? (
{episodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
@@ -503,23 +514,12 @@ export const Controls: React.FC<Props> = ({
/>
) : (
<>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.5 : 0,
}}
></Pressable>
<VideoTouchOverlay
screenWidth={screenWidth}
screenHeight={screenHeight}
showControls={showControls}
onToggleControls={toggleControls}
/>
<View
style={[
{
@@ -731,6 +731,7 @@ export const Controls: React.FC<Props> = ({
},
]}
className={`flex flex-col p-4`}
onTouchStart={handleControlsInteraction}
>
<View
className="shrink flex flex-col justify-center h-full mb-2"

View File

@@ -0,0 +1,38 @@
import { Pressable } from "react-native";
import { useTapDetection } from "./useTapDetection";
interface Props {
screenWidth: number;
screenHeight: number;
showControls: boolean;
onToggleControls: () => void;
}
export const VideoTouchOverlay = ({
screenWidth,
screenHeight,
showControls,
onToggleControls,
}: Props) => {
const { handleTouchStart, handleTouchEnd } = useTapDetection({
onValidTap: onToggleControls,
});
return (
<Pressable
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.5 : 0,
}}
/>
);
};

View File

@@ -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<NodeJS.Timeout>();
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,
};
};

View File

@@ -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,
};
};