mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 02:58:28 +01:00
Fixed a race condition where the upnext countdown started and a user cancelled/stop the current playback that they would exit the player but the timer would still be running and then start playing the next episode and you wouldn't be able to press back or exit out of it Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
271 lines
7.2 KiB
TypeScript
271 lines
7.2 KiB
TypeScript
import type { Api } from "@jellyfin/sdk";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
import { BlurView } from "expo-blur";
|
|
import { type FC, useEffect, useMemo, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Image,
|
|
Pressable,
|
|
Animated as RNAnimated,
|
|
type View as RNView,
|
|
StyleSheet,
|
|
TVFocusGuideView,
|
|
View,
|
|
} from "react-native";
|
|
import Animated, {
|
|
cancelAnimation,
|
|
Easing,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import { Text } from "@/components/common/Text";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import { scaleSize } from "@/utils/scaleSize";
|
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
|
|
|
export interface TVNextEpisodeCountdownProps {
|
|
nextItem: BaseItemDto;
|
|
api: Api | null;
|
|
show: boolean;
|
|
isPlaying: boolean;
|
|
onFinish: () => void;
|
|
/** Called when user presses the card to skip to next episode */
|
|
onPlayNext?: () => void;
|
|
/** Whether controls are visible - affects card position */
|
|
controlsVisible?: boolean;
|
|
/** Callback ref setter for focus guide destination pattern */
|
|
refSetter?: (ref: RNView | null) => void;
|
|
/** Whether this component should receive initial focus */
|
|
hasTVPreferredFocus?: boolean;
|
|
/** Destination used when moving down from this card */
|
|
playButtonRef?: RNView | null;
|
|
}
|
|
|
|
// Position constants
|
|
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
|
|
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|
nextItem,
|
|
api,
|
|
show,
|
|
isPlaying,
|
|
onFinish,
|
|
onPlayNext,
|
|
controlsVisible = false,
|
|
refSetter,
|
|
hasTVPreferredFocus = true,
|
|
playButtonRef: downDestination,
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const { t } = useTranslation();
|
|
const progress = useSharedValue(0);
|
|
const cancelled = useSharedValue(false);
|
|
const onFinishRef = useRef(onFinish);
|
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({
|
|
scaleAmount: 1.05,
|
|
duration: 120,
|
|
});
|
|
|
|
onFinishRef.current = onFinish;
|
|
|
|
const imageUrl = getPrimaryImageUrl({
|
|
api,
|
|
item: nextItem,
|
|
width: scaleSize(360),
|
|
quality: 80,
|
|
});
|
|
|
|
// Animated position based on controls visibility
|
|
const bottomPosition = useSharedValue(
|
|
controlsVisible ? BOTTOM_WITH_CONTROLS : BOTTOM_WITHOUT_CONTROLS,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const target = controlsVisible
|
|
? BOTTOM_WITH_CONTROLS
|
|
: BOTTOM_WITHOUT_CONTROLS;
|
|
bottomPosition.value = withTiming(target, {
|
|
duration: 300,
|
|
easing: Easing.out(Easing.quad),
|
|
});
|
|
}, [controlsVisible, bottomPosition]);
|
|
|
|
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
|
bottom: bottomPosition.value,
|
|
}));
|
|
|
|
// Progress animation - pause/resume without resetting
|
|
const prevShowRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
const justStartedShowing = show && !prevShowRef.current;
|
|
prevShowRef.current = show;
|
|
|
|
if (!show) {
|
|
cancelAnimation(progress);
|
|
progress.value = 0;
|
|
return;
|
|
}
|
|
|
|
if (justStartedShowing) {
|
|
progress.value = 0;
|
|
}
|
|
|
|
if (!isPlaying) {
|
|
cancelAnimation(progress);
|
|
return;
|
|
}
|
|
|
|
cancelled.value = false;
|
|
|
|
// Resume from current position
|
|
const remainingDuration = (1 - progress.value) * 8000;
|
|
progress.value = withTiming(
|
|
1,
|
|
{ duration: remainingDuration, easing: Easing.linear },
|
|
(finished) => {
|
|
if (finished && !cancelled.value) {
|
|
runOnJS(onFinishRef.current)();
|
|
}
|
|
},
|
|
);
|
|
|
|
// Cancel animation on unmount to prevent onFinish from firing after exit
|
|
return () => {
|
|
cancelled.value = true;
|
|
cancelAnimation(progress);
|
|
};
|
|
}, [show, isPlaying, progress, cancelled]);
|
|
|
|
const progressStyle = useAnimatedStyle(() => ({
|
|
width: `${progress.value * 100}%`,
|
|
}));
|
|
|
|
const styles = useMemo(() => createStyles(typography), [typography]);
|
|
|
|
if (!show) return null;
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[styles.container, containerAnimatedStyle]}
|
|
pointerEvents='box-none'
|
|
>
|
|
<Pressable
|
|
ref={refSetter}
|
|
onPress={onPlayNext}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
focusable={true}
|
|
>
|
|
<RNAnimated.View style={[animatedStyle, focused && styles.focusedCard]}>
|
|
<BlurView intensity={80} tint='dark' style={styles.blur}>
|
|
<View style={styles.innerContainer}>
|
|
{imageUrl && (
|
|
<Image
|
|
source={{ uri: imageUrl }}
|
|
style={styles.thumbnail}
|
|
resizeMode='cover'
|
|
/>
|
|
)}
|
|
|
|
<View style={styles.content}>
|
|
<Text style={styles.label}>{t("player.next_episode")}</Text>
|
|
|
|
<Text style={styles.seriesName} numberOfLines={1}>
|
|
{nextItem.SeriesName}
|
|
</Text>
|
|
|
|
<Text style={styles.episodeInfo} numberOfLines={1}>
|
|
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
|
|
{nextItem.Name}
|
|
</Text>
|
|
|
|
<View style={styles.progressContainer}>
|
|
<Animated.View style={[styles.progressBar, progressStyle]} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</BlurView>
|
|
</RNAnimated.View>
|
|
</Pressable>
|
|
{downDestination && (
|
|
<TVFocusGuideView
|
|
destinations={[downDestination]}
|
|
style={styles.returnFocusGuide}
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
|
StyleSheet.create({
|
|
container: {
|
|
position: "absolute",
|
|
right: scaleSize(80),
|
|
zIndex: 100,
|
|
},
|
|
focusedCard: {
|
|
shadowColor: "#fff",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.6,
|
|
shadowRadius: scaleSize(16),
|
|
},
|
|
blur: {
|
|
borderRadius: scaleSize(16),
|
|
overflow: "hidden",
|
|
},
|
|
innerContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
thumbnail: {
|
|
width: scaleSize(180),
|
|
backgroundColor: "rgba(0,0,0,0.3)",
|
|
},
|
|
content: {
|
|
padding: scaleSize(16),
|
|
justifyContent: "center",
|
|
width: scaleSize(280),
|
|
},
|
|
label: {
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.5)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: scaleSize(4),
|
|
},
|
|
seriesName: {
|
|
fontSize: typography.callout,
|
|
color: "rgba(255,255,255,0.7)",
|
|
marginBottom: scaleSize(2),
|
|
},
|
|
episodeInfo: {
|
|
fontSize: typography.body,
|
|
color: "#fff",
|
|
fontWeight: "600",
|
|
marginBottom: scaleSize(12),
|
|
},
|
|
progressContainer: {
|
|
height: scaleSize(4),
|
|
backgroundColor: "rgba(255,255,255,0.2)",
|
|
borderRadius: scaleSize(2),
|
|
overflow: "hidden",
|
|
},
|
|
progressBar: {
|
|
height: "100%",
|
|
backgroundColor: "#fff",
|
|
borderRadius: scaleSize(2),
|
|
},
|
|
returnFocusGuide: {
|
|
height: 1,
|
|
width: "100%",
|
|
},
|
|
});
|