mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
feat(autoplay): use AutoplayCountdown overlay in the native player
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -670,6 +670,7 @@ export const Controls: FC<Props> = ({
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
handleControlsInteraction={handleControlsInteraction}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user