fix: animated progress

This commit is contained in:
Fredrik Burmester
2024-08-30 10:07:35 +02:00
parent 68cfe99421
commit 0a53cf6b17

View File

@@ -1,91 +1,62 @@
import { usePlayback } from "@/providers/PlaybackProvider"; import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo, useRef, useState } from "react"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
PlayServicesState, PlayServicesState,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useAtom } from "jotai";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import Animated, { import Animated, {
useSharedValue, Easing,
useAnimatedStyle, interpolate,
withTiming,
interpolateColor, interpolateColor,
runOnJS,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null; item?: BaseItemDto | null;
url?: string | null; url?: string | null;
} }
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => { export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback(); const { setCurrentlyPlayingState } = usePlayback();
const [color] = useAtom(itemThemeColorAtom); const client = useRemoteMediaClient();
// Create a shared value for animation progress const [colorAtom] = useAtom(itemThemeColorAtom);
const progress = useSharedValue(0);
// Create shared values for start and end colors const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const startColor = useSharedValue(color); const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
const endColor = useSharedValue(color);
useEffect(() => { const startWidth = useSharedValue(0);
// When color changes, update end color and animate progress const targetWidth = useSharedValue(0);
endColor.value = color; const endColor = useSharedValue(memoizedColor);
progress.value = 0; // Reset progress const startColor = useSharedValue(memoizedColor);
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms const widthProgress = useSharedValue(0);
}, [color]); const colorChangeProgress = useSharedValue(0);
// Animated style for primary color
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
),
}));
// Animated style for text color
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
),
}));
// Update start color after animation completes
useEffect(() => {
const timeout = setTimeout(() => {
startColor.value = color;
}, 500); // Should match the duration in withTiming
return () => clearTimeout(timeout);
}, [color]);
const onPress = async () => { const onPress = async () => {
if (!url || !item) return; if (!url || !item) return;
if (!client) { if (!client) {
setCurrentlyPlayingState({ item, url }); setCurrentlyPlayingState({ item, url });
return; return;
} }
const options = ["Chromecast", "Device", "Cancel"]; const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
@@ -123,38 +94,123 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
); );
}; };
const playbackPercent = useMemo(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = item.UserData; const userData = memoizedItem.UserData;
if (!userData) return 0; if (userData && userData.PlaybackPositionTicks) {
const PlaybackPositionTicks = userData.PlaybackPositionTicks; return userData.PlaybackPositionTicks > 0
if (!PlaybackPositionTicks) return 0; ? Math.max(
return (PlaybackPositionTicks / item.RunTimeTicks) * 100; (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
}, [item]); MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [memoizedItem]);
useAnimatedReaction(
() => derivedTargetWidth.value,
(newWidth) => {
targetWidth.value = newWidth;
widthProgress.value = 0;
widthProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
);
useAnimatedReaction(
() => memoizedColor,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
colorChangeProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[memoizedColor]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = memoizedColor;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [memoizedColor, memoizedItem]);
/**
* ANIMATED STYLES
*/
const animatedAverageStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
),
}));
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
),
}));
const animatedWidthStyle = useAnimatedStyle(() => ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
)}%`,
}));
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
),
}));
/**
* *********************
*/
return ( return (
<TouchableOpacity onPress={onPress} className="relative" {...props}> <TouchableOpacity
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className="relative"
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View <Animated.View
style={[ style={[animatedAverageStyle]}
animatedPrimaryStyle, className="absolute w-full h-full top-0 left-0 rounded-xl"
{
width:
playbackPercent === 0
? "100%"
: `${Math.max(playbackPercent, 15)}%`,
height: "100%",
},
]}
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
/>
<Animated.View
style={[animatedPrimaryStyle]}
className="absolute w-full h-full top-0 left-0 rounded-xl "
/> />
<View <View
style={{ style={{
borderWidth: 1, borderWidth: 1,
borderColor: color.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "