refactor: playing state

This commit is contained in:
Fredrik Burmester
2024-08-20 08:24:05 +02:00
parent 1c31458dd4
commit 469e8b3f01
5 changed files with 392 additions and 264 deletions

View File

@@ -280,12 +280,7 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<PlayButton item={item} url={playbackUrl} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>

View File

@@ -17,6 +17,7 @@ import { useKeepAwake } from "expo-keep-awake";
import { useSettings } from "@/utils/atoms/settings";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -82,99 +83,101 @@ function Layout() {
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>

View File

@@ -17,7 +17,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
@@ -27,22 +34,24 @@ import Animated, {
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { usePlayback } from "@/providers/PlaybackProvider";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
const queryClient = useQueryClient();
const {
currentlyPlaying,
pauseVideo,
playVideo,
setCurrentlyPlayingState,
stopVideo,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
} = usePlayback();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const [show, setShow] = useAtom(showCurrentlyPlayingBarAtom);
const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
@@ -90,124 +99,28 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
}, [segments]);
const { data: item } = useQuery({
queryKey: ["item", currentlyPlaying?.item.Id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: currentlyPlaying?.item.Id,
}),
enabled: !!currentlyPlaying?.item.Id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!sessionData?.PlaySessionId ||
!api ||
!currentlyPlaying?.item.Id ||
!user?.Id ||
!currentTime
) {
return;
}
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
IsPaused: !playing,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
);
useEffect(() => {
if (!item || !api) return;
if (playing) {
videoRef.current?.resume();
} else {
videoRef.current?.pause();
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}
}, [playing, progress, item, sessionData]);
useEffect(() => {
if (fullScreen === true) {
videoRef.current?.presentFullscreenPlayer();
} else {
videoRef.current?.dismissFullscreenPlayer();
}
}, [fullScreen]);
useEffect(() => {
if (!show && currentlyPlaying && item && sessionData && api) {
reportPlaybackStopped({
api,
itemId: item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progress,
});
}
}, [show]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
: 0,
[item]
[currentlyPlaying?.item]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
}),
[item]
[currentlyPlaying?.item, api]
);
if (show === false || !api) return null;
if (!api || !currentlyPlaying) return null;
return (
<Animated.View
@@ -234,10 +147,14 @@ export const CurrentlyPlayingBar: React.FC = () => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
`}
>
{currentlyPlaying?.playbackUrl && (
{currentlyPlaying?.url && (
<Video
ref={videoRef}
allowsExternalPlayback
@@ -249,7 +166,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
backdropUrl && currentlyPlaying.item?.Type === "Audio"
? backdropUrl
: undefined
}
@@ -257,13 +174,13 @@ export const CurrentlyPlayingBar: React.FC = () => {
enable: true,
thread: true,
}}
paused={!playing}
paused={!isPlaying}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: currentlyPlaying.playbackUrl,
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
@@ -271,19 +188,15 @@ export const CurrentlyPlayingBar: React.FC = () => {
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {
setFullScreen(false);
}}
onFullscreenPlayerDidPresent={() => {
setFullScreen(true);
}}
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPlaying(true);
setIsPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setPlaying(false);
setIsPlaying(false);
}
}}
progressUpdateInterval={2000}
@@ -294,11 +207,11 @@ export const CurrentlyPlayingBar: React.FC = () => {
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setPlaying(false);
setCurrentlyPlaying(null);
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
item?.Type !== "Audio" && (
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
@@ -310,37 +223,41 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
if (currentlyPlaying.item?.Type === "Audio")
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
else router.push(`/items/${currentlyPlaying.item?.Id}`);
}}
>
<Text>{item?.Name}</Text>
<Text>{currentlyPlaying.item?.Name}</Text>
</TouchableOpacity>
{item?.Type === "Episode" && (
{currentlyPlaying.item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}`);
router.push(
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
>
<Text>{item.SeriesName}</Text>
<Text>{currentlyPlaying.item.SeriesName}</Text>
</TouchableOpacity>
)}
{item?.Type === "Movie" && (
{currentlyPlaying.item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{item?.ProductionYear}
{currentlyPlaying.item?.ProductionYear}
</Text>
</View>
)}
{item?.Type === "Audio" && (
{currentlyPlaying.item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${item?.AlbumId}`);
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">{item?.Album}</Text>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
</TouchableOpacity>
)}
</View>
@@ -348,12 +265,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (playing) setPlaying(false);
else setPlaying(true);
if (isPlaying) pauseVideo();
else playVideo();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{playing ? (
{isPlaying ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
@@ -361,7 +278,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setShow(false);
setCurrentlyPlayingState(null);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>

View File

@@ -4,24 +4,27 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { Button } from "./Button";
import CastContext, {
PlayServicesState,
useRemoteMediaClient,
} from "react-native-google-cast";
import { usePlayback } from "@/providers/PlaybackProvider";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
onPress: (type?: "cast" | "device") => void;
chromecastReady: boolean;
item?: BaseItemDto | null;
url?: string | null;
}
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
const _onPress = () => {
if (!chromecastReady) {
onPress("device");
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
return;
}
@@ -33,28 +36,45 @@ export const PlayButton: React.FC<Props> = ({
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
async (selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
onPress("cast");
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
break;
case 1:
onPress("device");
setCurrentlyPlayingState({ item, url });
break;
case cancelButtonIndex:
break;
}
},
}
);
};
return (
<Button
onPress={_onPress}
onPress={onPress}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{chromecastReady && <Feather name="cast" size={22} color="white" />}
{client && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}

View File

@@ -0,0 +1,193 @@
import React, {
createContext,
useContext,
useRef,
useState,
useEffect,
ReactNode,
useCallback,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { useSettings } from "@/utils/atoms/settings";
type CurrentlyPlayingState = {
url: string;
item: BaseItemDto;
};
interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: () => void;
pauseVideo: () => void;
stopVideo: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
});
const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
if (state) {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer();
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
};
// Define control methods
const playVideo = useCallback(() => {
videoRef.current?.resume();
setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: true,
});
}, [
api,
currentlyPlaying?.item.Id,
sessionData?.PlaySessionId,
progressTicks,
]);
const pauseVideo = useCallback(() => {
videoRef.current?.pause();
setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: false,
});
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
const stopVideo = useCallback(() => {
reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
}, [currentlyPlaying?.item?.Id, sessionData?.PlaySessionId, progressTicks]);
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: sessionData?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
return (
<PlaybackContext.Provider
value={{
onProgress,
progressTicks,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData,
videoRef,
playVideo,
setCurrentlyPlayingState,
pauseVideo,
stopVideo,
presentFullscreenPlayer,
dismissFullscreenPlayer,
}}
>
{children}
</PlaybackContext.Provider>
);
};
export const usePlayback = () => {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
};