feat(autoplay): use AutoplayCountdown overlay in the native player

This commit is contained in:
Uruk
2026-05-22 15:13:19 +02:00
parent 58e2418120
commit 0ba3d19550
3 changed files with 81 additions and 108 deletions

View File

@@ -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<BottomControlsProps> = ({
skipIntro,
skipCredit,
nextItem,
api,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
handleControlsInteraction,
@@ -118,6 +122,71 @@ export const BottomControls: FC<BottomControlsProps> = ({
);
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<ReturnType<typeof setInterval> | 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 (
<View
style={[
@@ -186,22 +255,15 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={skipCredit}
buttonText={skipCreditButtonText}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
)}
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
<AutoplayCountdown
nextEpisode={nextItem}
posterUrl={nextEpisodePosterUrl}
secondsRemaining={secondsRemaining}
onPlayNow={handleNextEpisodeManual}
onCancel={() => setAutoplayCancelled(true)}
/>
)}
</View>
</View>
<View

View File

@@ -670,6 +670,7 @@ export const Controls: FC<Props> = ({
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
api={api}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
handleControlsInteraction={handleControlsInteraction}

View File

@@ -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<NextEpisodeCountDownButtonProps> = ({
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 (
<TouchableOpacity
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
{...props}
onPress={handlePress}
>
<Animated.View style={animatedStyle} />
<View className='px-3 py-3'>
<Text numberOfLines={1} className='text-center font-bold'>
{t("player.next_episode")}
</Text>
</View>
</TouchableOpacity>
);
};
export default NextEpisodeCountDownButton;