diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 25a930508..f5b2bbae3 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -1,9 +1,10 @@ import { Ionicons } from "@expo/vector-icons"; +import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, ChapterInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { type FC, useMemo, useState } from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; @@ -12,9 +13,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { Text } from "@/components/common/Text"; +import { AutoplayCountdown } from "@/components/player/AutoplayCountdown"; import { useSettings } from "@/utils/atoms/settings"; import { chapterMarkers } from "@/utils/chapters"; -import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; import { TrickplayBubble } from "./TrickplayBubble"; @@ -38,6 +40,7 @@ interface BottomControlsProps { skipIntro: () => void; skipCredit: () => void; nextItem?: BaseItemDto | null; + api?: Api | null; handleNextEpisodeAutoPlay: () => void; handleNextEpisodeManual: () => void; handleControlsInteraction: () => void; @@ -90,6 +93,7 @@ export const BottomControls: FC = ({ skipIntro, skipCredit, nextItem, + api, handleNextEpisodeAutoPlay, handleNextEpisodeManual, handleControlsInteraction, @@ -118,6 +122,71 @@ export const BottomControls: FC = ({ ); const hasChapters = chapterMarkerList.length > 1; + // Autoplay overlay: shown under the same condition the old countdown button used. + const autoplayAllowed = + settings.autoPlayNextEpisode !== false && + (settings.maxAutoPlayEpisodeCount.value === -1 || + settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value); + + const showNextEpisodeCountdown = + autoplayAllowed && + (!nextItem + ? false + : // Show during credits if no content after, OR near end of video + (showSkipCreditButton && !hasContentAfterCredits) || + remainingTime < 10000); + + const [secondsRemaining, setSecondsRemaining] = useState( + settings.autoplayCountdownSeconds, + ); + const [autoplayCancelled, setAutoplayCancelled] = useState(false); + const intervalRef = useRef | null>(null); + // Keep a stable ref to the autoplay handler so the timer effect does not + // restart when the handler identity changes. + const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay); + autoPlayHandlerRef.current = handleNextEpisodeAutoPlay; + + useEffect(() => { + if (!showNextEpisodeCountdown) { + // Show-condition flipped off: clear the timer and reset for the next episode. + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setAutoplayCancelled(false); + setSecondsRemaining(settings.autoplayCountdownSeconds); + return; + } + + setSecondsRemaining(settings.autoplayCountdownSeconds); + intervalRef.current = setInterval(() => { + setSecondsRemaining((prev) => { + if (prev <= 1) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + autoPlayHandlerRef.current(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [showNextEpisodeCountdown, settings.autoplayCountdownSeconds]); + + const nextEpisodePosterUrl = useMemo( + () => + nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null, + [api, nextItem], + ); + return ( = ({ onPress={skipCredit} buttonText={skipCreditButtonText} /> - {settings.autoPlayNextEpisode !== false && - (settings.maxAutoPlayEpisodeCount.value === -1 || - settings.autoPlayEpisodeCount < - settings.maxAutoPlayEpisodeCount.value) && ( - - )} + {showNextEpisodeCountdown && !autoplayCancelled && nextItem && ( + setAutoplayCancelled(true)} + /> + )} = ({ skipIntro={skipIntro} skipCredit={skipCredit} nextItem={nextItem} + api={api} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeManual={handleNextEpisodeManual} handleControlsInteraction={handleControlsInteraction} diff --git a/components/video-player/controls/NextEpisodeCountDownButton.tsx b/components/video-player/controls/NextEpisodeCountDownButton.tsx deleted file mode 100644 index e769c2f57..000000000 --- a/components/video-player/controls/NextEpisodeCountDownButton.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type React from "react"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { - TouchableOpacity, - type TouchableOpacityProps, - View, -} from "react-native"; -import Animated, { - Easing, - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { Text } from "@/components/common/Text"; -import { Colors } from "@/constants/Colors"; - -interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps { - onFinish?: () => void; - onPress?: () => void; - show: boolean; -} - -const NextEpisodeCountDownButton: React.FC = ({ - onFinish, - onPress, - show, - ...props -}) => { - const progress = useSharedValue(0); - - useEffect(() => { - if (show) { - progress.value = 0; - progress.value = withTiming( - 1, - { - duration: 10000, // 10 seconds - easing: Easing.linear, - }, - (finished) => { - if (finished && onFinish) { - runOnJS(onFinish)(); - } - }, - ); - } - }, [show, onFinish]); - - const animatedStyle = useAnimatedStyle(() => { - return { - position: "absolute", - left: 0, - top: 0, - bottom: 0, - width: `${progress.value * 100}%`, - backgroundColor: Colors.primary, - }; - }); - - const handlePress = () => { - if (onPress) { - onPress(); - } - }; - - const { t } = useTranslation(); - - if (!show) { - return null; - } - - return ( - - - - - {t("player.next_episode")} - - - - ); -}; - -export default NextEpisodeCountDownButton;