This commit is contained in:
Fredrik Burmester
2024-10-06 22:48:31 +02:00
parent d6f02bd970
commit aa56749402
18 changed files with 611 additions and 1920 deletions

View File

@@ -1,4 +1,3 @@
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
import { StatusBar } from "expo-status-bar";
import { View, ViewProps } from "react-native";
@@ -8,7 +7,6 @@ export default function page() {
return (
<View className="">
<StatusBar hidden={false} />
<FullScreenMusicPlayer />
</View>
);
}

View File

@@ -0,0 +1,246 @@
import { Controls } from "@/components/video-player/Controls";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import * as NavigationBar from "expo-navigation-bar";
import { useLocalSearchParams, useGlobalSearchParams, Link } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
export default function page() {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
setIsPlaying(false);
videoRef.current?.pause();
} else {
setIsPlaying(true);
videoRef.current?.resume();
}
}, [isPlaying, api, playSettings?.item?.Id, videoRef, settings]);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
}, [videoRef]);
useEffect(() => {
play();
return () => {
stop();
};
});
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
orientationSubscription.remove();
};
}, []);
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
},
[playSettings?.item.Id, isPlaying, api]
);
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
paused={!isPlaying}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = 0;
return {
uri: playUrl,
isNetwork: false,
startPosition,
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api]);
return videoSource;
}

View File

@@ -34,6 +34,7 @@ export default function page() {
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
@@ -131,25 +132,6 @@ export default function page() {
});
};
const firstTime = useRef(true);
useEffect(() => {
play();
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
stop();
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, []);
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
@@ -179,6 +161,13 @@ export default function page() {
[playSettings?.item.Id, isPlaying, api]
);
useEffect(() => {
play();
return () => {
stop();
};
}, []);
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
@@ -196,14 +185,35 @@ export default function page() {
};
}, []);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
const { isConnected } = useWebSocket({
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
@@ -262,7 +272,6 @@ export default function page() {
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
isLandscape={isLandscape}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>

View File

@@ -1,48 +0,0 @@
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
import { useSettings } from "@/utils/atoms/settings";
import * as NavigationBar from "expo-navigation-bar";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export default function page() {
const [settings] = useSettings();
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
return (
<View className="">
<StatusBar hidden />
<FullScreenVideoPlayer />
</View>
);
}

View File

@@ -5,7 +5,6 @@ import {
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
@@ -313,12 +312,12 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<DownloadProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
@@ -330,7 +329,7 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/play"
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
@@ -339,7 +338,7 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/play-video"
name="(auth)/play-offline-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
@@ -377,12 +376,12 @@ function Layout() {
closeButton
/>
</ThemeProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</DownloadProvider>
</JobQueueProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);

View File

@@ -1,60 +1,57 @@
import { useSettings } from "@/utils/atoms/settings";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends ViewProps {}
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | null;
}
export const AudioTrackSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
export const AudioTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
...props
}) => {
const [settings] = useSettings();
const selectedIndex = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const audioStreams = useMemo(
() =>
playSettings?.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio"
),
[playSettings?.mediaSource]
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
);
const selectedAudioStream = useMemo(
() => audioStreams?.find((x) => x.Index === selectedIndex),
[audioStreams, selectedIndex]
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
);
// Set default audio stream only if none is selected and we have audio streams
useEffect(() => {
if (playSettings?.audioIndex !== undefined || !audioStreams?.length) return;
const defaultAudioIndex = audioStreams.find(
if (selected) return;
const defaultAudioIndex = audioStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
if (defaultAudioIndex !== undefined) {
setPlaySettings((prev) => ({
...prev,
audioIndex: defaultAudioIndex,
}));
} else {
const index = playSettings?.mediaSource?.DefaultAudioStreamIndex ?? 0;
setPlaySettings((prev) => ({
...prev,
audioIndex: index,
}));
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
}, [
audioStreams,
settings?.defaultAudioLanguage,
playSettings?.mediaSource,
setPlaySettings,
]);
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings, source]);
return (
<View
@@ -69,7 +66,7 @@ export const AudioTrackSelector: React.FC<Props> = ({ ...props }) => {
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
{selectedAudioStream?.DisplayTitle}
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
@@ -89,10 +86,7 @@ export const AudioTrackSelector: React.FC<Props> = ({ ...props }) => {
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
setPlaySettings((prev) => ({
...prev,
audioIndex: audio.Index,
}));
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -1,8 +1,7 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useEffect, useMemo } from "react";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useMemo } from "react";
export type Bitrate = {
key: string;
@@ -10,7 +9,7 @@ export type Bitrate = {
height?: number;
};
export const BITRATES: Bitrate[] = [
const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -43,11 +42,17 @@ export const BITRATES: Bitrate[] = [
];
interface Props extends React.ComponentProps<typeof View> {
inverted?: boolean;
onChange: (value: Bitrate) => void;
selected?: Bitrate | null;
inverted?: boolean | null;
}
export const BitrateSelector: React.FC<Props> = ({ inverted, ...props }) => {
const { setPlaySettings, playSettings } = usePlaySettings();
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
inverted,
...props
}) => {
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
@@ -58,18 +63,6 @@ export const BitrateSelector: React.FC<Props> = ({ inverted, ...props }) => {
);
}, []);
const selected = useMemo(() => {
return sorted.find((b) => b.value === playSettings?.bitrate?.value);
}, [playSettings?.bitrate]);
// Set default bitrate on load
useEffect(() => {
setPlaySettings((prev) => ({
...prev,
bitrate: BITRATES[0],
}));
}, []);
return (
<View
className="flex shrink"
@@ -84,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({ inverted, ...props }) => {
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.key}
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
@@ -103,10 +96,7 @@ export const BitrateSelector: React.FC<Props> = ({ inverted, ...props }) => {
<DropdownMenu.Item
key={b.key}
onSelect={() => {
setPlaySettings((prev) => ({
...prev,
bitrate: b,
}));
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>

View File

@@ -1,544 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenMusicPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const subscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
return () => subscription?.remove();
});
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: insets.left,
right: insets.right,
width: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={"contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
</>
)}
</Pressable>
<View pointerEvents="none" className="p-4">
<Image
source={poster ? { uri: poster } : undefined}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: insets.left + 16,
width:
dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${"flex-col-reverse py-4 px-4 rounded-2xl"}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
sliderHeight={10}
progress={progress}
thumbWidth={0}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

View File

@@ -1,626 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData, ReactVideoProps } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenVideoPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource: ReactVideoProps["source"] = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return undefined;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: ignoreSafeArea ? 0 : insets.left,
right: ignoreSafeArea ? 0 : insets.right,
width: ignoreSafeArea
? dimensions.window.width
: dimensions.window.width - (insets.left + insets.right),
},
]}
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onVideoTracks={(d) => {
console.log("onVideoTracks ~", d);
}}
/>
</Pressable>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: isLandscape ? insets.left + 32 : insets.left + 16,
width: isLandscape
? dimensions.window.width - insets.left - insets.right - 64
: dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

View File

@@ -12,23 +12,18 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated from "react-native-reanimated";
@@ -41,7 +36,7 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const { setPlaySettings, playUrl } = usePlaySettings();
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const castDevice = useCastDevice();
const navigation = useNavigation();
@@ -52,6 +47,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
ScreenOrientation.Orientation.PORTRAIT_UP
);
const selectedMediaSource = useMemo(() => {
return playSettings?.mediaSource || undefined;
}, [playSettings?.mediaSource]);
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
setPlaySettings((prev) => ({
...prev,
mediaSource,
}));
};
const selectedAudioStream = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const setSelectedAudioStream = (audioIndex: number | undefined) => {
setPlaySettings((prev) => ({
...prev,
audioIndex,
}));
};
const selectedSubtitleStream = useMemo(() => {
return playSettings?.subtitleIndex;
}, [playSettings?.subtitleIndex]);
const setSelectedSubtitleStream = (subtitleIndex: number | undefined) => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex,
}));
};
const maxBitrate = useMemo(() => {
return playSettings?.bitrate;
}, [playSettings?.bitrate]);
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
console.log("setMaxBitrate", bitrate);
setPlaySettings((prev) => ({
...prev,
bitrate,
}));
};
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
@@ -79,21 +119,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<>
<View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} />
<PlayedStatus item={item} />
</>
</View>
)}
</View>
),
});
setPlaySettings((prev) => ({
...prev,
audioIndex: undefined,
subtitleIndex: undefined,
mediaSourceId: undefined,
bitrate: undefined,
mediaSource: undefined,
mediaSource: item.MediaSources?.[0],
item,
}));
}, [item]);
@@ -167,10 +208,32 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector className="mr-1" />
<MediaSourceSelector className="mr-1" />
<AudioTrackSelector className="mr-1" />
<SubtitleTrackSelector />
<BitrateSelector
className="mr-1"
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View>
)}

View File

@@ -1,43 +1,39 @@
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { useAtom } from "jotai";
import { tc } from "@/utils/textTools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {}
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected?: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
export const MediaSourceSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item.MediaSources, selected]
);
const selectedMediaSource = useMemo(() => {
console.log(
"selectedMediaSource",
playSettings?.mediaSource?.MediaStreams?.length
);
return (
playSettings?.mediaSource?.MediaStreams?.find((x) => x.Type === "Video")
?.DisplayTitle || "N/A"
);
}, [playSettings?.mediaSource]);
// Set default media source on component mount
useEffect(() => {
if (
playSettings?.item?.MediaSources?.length &&
!playSettings?.mediaSource
) {
console.log(
"Setting default media source",
playSettings?.item?.MediaSources?.[0].Id
);
setPlaySettings((prev) => ({
...prev,
mediaSource: playSettings?.item?.MediaSources?.[0],
}));
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
onChange(item.MediaSources[0]);
}
}, [playSettings?.item?.MediaSources, setPlaySettings]);
}, [item.MediaSources, selected]);
const name = (name?: string | null) => {
if (name && name.length > 40)
@@ -58,8 +54,8 @@ export const MediaSourceSelector: React.FC<Props> = ({ ...props }) => {
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -73,14 +69,11 @@ export const MediaSourceSelector: React.FC<Props> = ({ ...props }) => {
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{playSettings?.item?.MediaSources?.map((source, idx: number) => (
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
setPlaySettings((prev) => ({
...prev,
mediaSource: source,
}));
onChange(source);
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -1,46 +1,55 @@
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { tc } from "@/utils/textTools";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends ViewProps {}
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | null;
}
export const SubtitleTrackSelector: React.FC<Props> = ({ ...props }) => {
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
...props
}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(
() =>
playSettings?.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle"
) ?? [],
[playSettings?.mediaSource]
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === playSettings?.subtitleIndex),
[subtitleStreams, playSettings?.subtitleIndex]
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
setPlaySettings((prev) => ({
...prev,
subtitleIndex: defaultSubIndex,
}));
onChange(defaultSubIndex);
return;
}
setPlaySettings((prev) => ({
...prev,
subtitleIndex: -1,
}));
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
@@ -79,10 +88,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({ ...props }) => {
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex: -1,
}));
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
@@ -92,10 +98,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({ ...props }) => {
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
setPlaySettings((prev) => ({
...prev,
subtitleIndex: subtitle.Index,
}));
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>

View File

@@ -1,6 +1,6 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
@@ -41,7 +41,7 @@ export const SongsListItem: React.FC<Props> = ({
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setCurrentlyPlayingState } = usePlayback();
const { playSettings, setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
@@ -121,9 +121,8 @@ export const SongsListItem: React.FC<Props> = ({
});
} else {
console.log("Playing on device", url, item.Id);
setCurrentlyPlayingState({
setPlaySettings({
item,
url,
});
router.push("/play-music");
}

View File

@@ -55,7 +55,6 @@ interface Props {
setShowControls: (shown: boolean) => void;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
isLandscape: boolean;
}
export const Controls: React.FC<Props> = ({
@@ -69,7 +68,6 @@ export const Controls: React.FC<Props> = ({
cacheProgress,
showControls,
setShowControls,
isLandscape,
ignoreSafeAreas,
setIgnoreSafeAreas,
}) => {
@@ -259,8 +257,8 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 55 : insets.bottom + 97,
right: isLandscape ? insets.right : insets.right,
bottom: insets.bottom + 97,
right: insets.right,
},
]}
className={`z-10 p-4
@@ -278,8 +276,8 @@ export const Controls: React.FC<Props> = ({
<View
style={{
position: "absolute",
bottom: isLandscape ? insets.bottom + 52 : insets.bottom + 94,
right: isLandscape ? insets.right : insets.right,
bottom: insets.bottom + 94,
right: insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
@@ -390,12 +388,7 @@ export const Controls: React.FC<Props> = ({
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800/90`}
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity

View File

@@ -2,7 +2,6 @@ import { Api } from "@jellyfin/sdk";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useQuery } from "@tanstack/react-query";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";

View File

@@ -1,6 +1,6 @@
// hooks/useFileOpener.ts
import { usePlayback } from "@/providers/PlaybackProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
@@ -8,48 +8,44 @@ import { useCallback } from "react";
export const useFileOpener = () => {
const router = useRouter();
const { startDownloadedFilePlayback } = usePlayback();
const { setPlaySettings, setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
const openFile = useCallback(async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
if (!directory) {
throw new Error("Document directory is not available");
if (!directory) {
throw new Error("Document directory is not available");
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
const url = `${directory}${matchingFile}`;
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
setOfflineSettings({
item,
});
setPlayUrl(url);
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
const url = `${directory}${matchingFile}`;
console.log("Opening " + url);
startDownloadedFilePlayback({
item,
url,
});
router.push("/play");
} catch (error) {
console.error("Error opening file:", error);
// Handle the error appropriately, e.g., show an error message to the user
}
},
[startDownloadedFilePlayback]
);
router.push("/play-offline-video");
} catch (error) {
console.error("Error opening file:", error);
// Handle the error appropriately, e.g., show an error message to the user
}
}, []);
return { openFile };
};

View File

@@ -1,25 +1,24 @@
import React, {
createContext,
useState,
useContext,
useEffect,
useCallback,
} from "react";
import { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { settingsAtom } from "@/utils/atoms/settings";
import { apiAtom, userAtom } from "./JellyfinProvider";
import { useAtomValue } from "jotai";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { Bitrate } from "@/components/BitrateSelector";
import ios from "@/utils/profiles/ios";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { apiAtom, userAtom } from "./JellyfinProvider";
export type PlaybackType = {
item?: BaseItemDto | null;
@@ -32,8 +31,10 @@ export type PlaybackType = {
type PlaySettingsContextType = {
playSettings: PlaybackType | null;
setPlaySettings: React.Dispatch<React.SetStateAction<PlaybackType | null>>;
setOfflineSettings: (data: PlaybackType) => void;
playUrl?: string | null;
reportStopPlayback: (ticks: number) => Promise<void>;
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
};
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
@@ -43,7 +44,7 @@ const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [playSettings, setPlaySettings] = useState<PlaybackType | null>(null);
const [playSettings, _setPlaySettings] = useState<PlaybackType | null>(null);
const [playUrl, setPlayUrl] = useState<string | null>(null);
const api = useAtomValue(apiAtom);
@@ -65,44 +66,56 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
[playSettings?.item?.Id, api]
);
useEffect(() => {
const fetchPlayUrl = async () => {
if (!api || !user || !settings || !playSettings) {
console.log("fetchPlayUrl ~ missing params");
setPlayUrl(null);
return;
}
const setOfflineSettings = useCallback((data: PlaybackType) => {
_setPlaySettings(data);
}, []);
console.log("fetchPlayUrl ~ fetching url", playSettings?.item?.Id);
const setPlaySettings = useCallback(
async (
dataOrUpdater:
| PlaybackType
| null
| ((prev: PlaybackType | null) => PlaybackType | null)
) => {
_setPlaySettings((prevSettings) => {
const newSettings =
typeof dataOrUpdater === "function"
? dataOrUpdater(prevSettings)
: dataOrUpdater;
// Determine the device profile
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
if (!api || !user || !settings || newSettings === null) {
return newSettings;
}
const url = await getStreamUrl({
api,
deviceProfile,
item: playSettings?.item,
mediaSourceId: playSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: playSettings?.bitrate?.value,
audioStreamIndex: playSettings?.audioIndex
? playSettings?.audioIndex
: 0,
subtitleStreamIndex: playSettings?.subtitleIndex
? playSettings?.subtitleIndex
: -1,
userId: user.Id,
forceDirectPlay: false,
sessionData: null,
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
getStreamUrl({
api,
deviceProfile,
item: newSettings?.item,
mediaSourceId: newSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: newSettings?.bitrate?.value,
audioStreamIndex: newSettings?.audioIndex
? newSettings?.audioIndex
: 0,
subtitleStreamIndex: newSettings?.subtitleIndex
? newSettings?.subtitleIndex
: -1,
userId: user.Id,
forceDirectPlay: false,
sessionData: null,
}).then((url) => {
if (url) setPlayUrl(url);
});
return newSettings;
});
setPlayUrl(url);
};
fetchPlayUrl();
}, [api, settings, user, playSettings]);
},
[api, user, settings, setPlayUrl]
);
useEffect(() => {
let deviceProfile: any = ios;
@@ -130,7 +143,14 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
return (
<PlaySettingsContext.Provider
value={{ playSettings, setPlaySettings, playUrl, reportStopPlayback }}
value={{
playSettings,
setPlaySettings,
playUrl,
reportStopPlayback,
setPlayUrl,
setOfflineSettings,
}}
>
{children}
</PlaySettingsContext.Provider>

View File

@@ -1,393 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
export 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: (triggerRef?: boolean) => void;
pauseVideo: (triggerRef?: boolean) => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
isBuffering: boolean;
setIsBuffering: (val: boolean) => void;
onProgress: (data: OnProgressData) => void;
setVolume: (volume: number) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
startDownloadedFilePlayback: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
subtitles: SubtitleTrack[];
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
const previousVolume = useRef<number | null>(null);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isBuffering, setIsBuffering] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
// WS
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const setVolume = useCallback(
(newVolume: number) => {
previousVolume.current = volume;
_setVolume(newVolume);
videoRef.current?.setVolume(newVolume);
},
[_setVolume]
);
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
queryFn: getDeviceId,
});
const startDownloadedFilePlayback = useCallback(
async (state: CurrentlyPlayingState | null) => {
if (!state) {
setCurrentlyPlaying(null);
setIsPlaying(false);
return;
}
setCurrentlyPlaying(state);
setIsPlaying(true);
},
[]
);
const setCurrentlyPlayingState = useCallback(
async (state: CurrentlyPlayingState | null) => {
try {
if (state?.item.Id && user?.Id) {
const vlcLink = "vlc://" + state?.url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
// Support live tv
const res =
state.item.Type !== "Program"
? await getMediaInfoApi(api!).getPlaybackInfo({
itemId: state.item.Id,
userId: user.Id,
})
: await getMediaInfoApi(api!).getPlaybackInfo({
itemId: state.item.ChannelId!,
userId: user.Id,
});
await postCapabilities({
api,
itemId: state.item.Id,
sessionId: res.data.PlaySessionId,
deviceProfile: settings?.deviceProfile,
});
setSession(res.data);
setCurrentlyPlaying(state);
setIsPlaying(true);
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
} catch (e) {
console.error(e);
Alert.alert(
"Something went wrong",
"The item could not be played. Maybe there is no internet connection?",
[
{
style: "destructive",
text: "Try force play",
onPress: () => {
setCurrentlyPlaying(state);
setIsPlaying(true);
},
},
{
text: "Ok",
style: "default",
},
]
);
}
},
[settings, user, api]
);
const playVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.resume();
}
_setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: false,
});
},
[api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
);
const pauseVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.pause();
}
_setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: true,
});
},
[session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
);
const stopPlayback = useCallback(async () => {
const id = currentlyPlaying?.item?.Id;
setCurrentlyPlayingState(null);
await reportPlaybackStopped({
api,
itemId: id,
sessionId: session?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
const setIsPlaying = useCallback(
debounce((value: boolean) => {
_setIsPlaying(value);
}, 500),
[]
);
const _onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!session?.PlaySessionId ||
!currentlyPlaying?.item.Id ||
currentTime === 0
)
return;
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: session?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
);
const onProgress = useCallback(
debounce((e: OnProgressData) => {
_onProgress(e);
}, 500),
[_onProgress]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
console.log("[WS] ~ ", json);
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (command === "Mute") {
console.log("Command ~ Mute");
setVolume(0);
} else if (command === "Unmute") {
console.log("Command ~ Unmute");
setVolume(previousVolume.current || 20);
} else if (command === "SetVolume") {
console.log("Command ~ SetVolume");
} else if (json?.Data?.Name === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
Alert.alert(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
return (
<PlaybackContext.Provider
value={{
onProgress,
isBuffering,
setIsBuffering,
progressTicks,
setVolume,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData: session,
videoRef,
playVideo,
setCurrentlyPlayingState,
pauseVideo,
stopPlayback,
presentFullscreenPlayer,
dismissFullscreenPlayer,
startDownloadedFilePlayback,
subtitles,
}}
>
{children}
</PlaybackContext.Provider>
);
};
export const usePlayback = () => {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
};