mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-17 18:03:01 +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 { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} 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 { useTranslation } from "react-i18next";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
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 { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
import { chapterMarkers } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
@@ -38,6 +40,7 @@ interface BottomControlsProps {
|
|||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
skipCredit: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
|
api?: Api | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
handleControlsInteraction: () => void;
|
handleControlsInteraction: () => void;
|
||||||
@@ -90,6 +93,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
|
api,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
handleControlsInteraction,
|
handleControlsInteraction,
|
||||||
@@ -118,6 +122,71 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
);
|
);
|
||||||
const hasChapters = chapterMarkerList.length > 1;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -186,22 +255,15 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={skipCreditButtonText}
|
buttonText={skipCreditButtonText}
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
<AutoplayCountdown
|
||||||
settings.autoPlayEpisodeCount <
|
nextEpisode={nextItem}
|
||||||
settings.maxAutoPlayEpisodeCount.value) && (
|
posterUrl={nextEpisodePosterUrl}
|
||||||
<NextEpisodeCountDownButton
|
secondsRemaining={secondsRemaining}
|
||||||
show={
|
onPlayNow={handleNextEpisodeManual}
|
||||||
!nextItem
|
onCancel={() => setAutoplayCancelled(true)}
|
||||||
? false
|
/>
|
||||||
: // Show during credits if no content after, OR near end of video
|
)}
|
||||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
|
||||||
remainingTime < 10000
|
|
||||||
}
|
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
|
||||||
onPress={handleNextEpisodeManual}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -670,6 +670,7 @@ export const Controls: FC<Props> = ({
|
|||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
skipCredit={skipCredit}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
|
api={api}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
handleControlsInteraction={handleControlsInteraction}
|
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