mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
refactor: playing state
This commit is contained in:
@@ -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>
|
||||
|
||||
189
app/_layout.tsx
189
app/_layout.tsx
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
193
providers/PlaybackProvider.tsx
Normal file
193
providers/PlaybackProvider.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user