mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Merge pull request #394 from Ryan0204/enhancement/autohidecontrol
enhancement: auto hide control after 5 seconds
This commit is contained in:
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal file
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
56
components/video-player/controls/useControlsTimeout.ts
Normal file
56
components/video-player/controls/useControlsTimeout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
48
components/video-player/controls/useTapDetection.tsx
Normal file
48
components/video-player/controls/useTapDetection.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user