mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
wip
This commit is contained in:
@@ -1,104 +1,191 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { settingsAtom, useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
|
||||
type PlaybackType = {
|
||||
item: BaseItemDto;
|
||||
mediaSourceId: string;
|
||||
subtitleIndex: number;
|
||||
audioIndex: number;
|
||||
url: string;
|
||||
quality: any;
|
||||
};
|
||||
|
||||
export const playInfoAtom = atom<PlaybackType | null>(null);
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import settings from "./(tabs)/(home)/settings";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { StatusBar, View } from "react-native";
|
||||
import React from "react";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
|
||||
export default function page() {
|
||||
const [playInfo, setPlayInfo] = useAtom(playInfoAtom);
|
||||
const { playSettings, setPlaySettings, playUrl, reportStopPlayback } =
|
||||
usePlaySettings();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const poster = usePoster(playInfo, api);
|
||||
const videoSource = useVideoSource(playInfo, api, poster);
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
|
||||
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
if (!playInfo || !api || !videoSource) return null;
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("play-video ~", playUrl);
|
||||
});
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
const togglePlay = useCallback(
|
||||
(ticks: number) => {
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: playSettings?.item?.Id,
|
||||
positionTicks: ticks,
|
||||
sessionId: undefined,
|
||||
IsPaused: true,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: playSettings?.item?.Id,
|
||||
positionTicks: ticks,
|
||||
sessionId: undefined,
|
||||
IsPaused: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
togglePlay(playSettings.item?.UserData?.PlaybackPositionTicks || 0);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const onProgress = useCallback(
|
||||
(data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
const ticks = data.currentTime * 10000000;
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: playSettings?.item.Id,
|
||||
positionTicks: ticks,
|
||||
sessionId: undefined,
|
||||
IsPaused: !isPlaying,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api]
|
||||
);
|
||||
|
||||
return (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeArea ? "cover" : "contain"}
|
||||
onProgress={() => {}}
|
||||
onLoad={(data) => {}}
|
||||
onError={() => {}}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
/>
|
||||
<View className="relative h-screen w-screen flex flex-col items-center justify-center">
|
||||
<StatusBar hidden />
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
paused={!isPlaying}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeArea ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onLoad={(data) => {}}
|
||||
onError={() => {}}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
|
||||
/>
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
playInfo: PlaybackType | null,
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!playInfo?.item || !api) return undefined;
|
||||
return playInfo.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${playInfo.item.AlbumId}/Images/Primary?tag=${playInfo.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
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: playInfo.item,
|
||||
item: playSettings.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [playInfo?.item, api]);
|
||||
}, [playSettings?.item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playInfo: PlaybackType | null,
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
poster: string | undefined
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playInfo || !api) return null;
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playInfo.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playInfo.item.UserData.PlaybackPositionTicks / 10000)
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playInfo.url,
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playInfo.item?.AlbumArtist ?? undefined,
|
||||
title: playInfo.item?.Name || "Unknown",
|
||||
description: playInfo.item?.Overview ?? undefined,
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playInfo.item?.Album ?? undefined,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playInfo, api, poster]);
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
115
app/_layout.tsx
115
app/_layout.tsx
@@ -6,6 +6,7 @@ import {
|
||||
} 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";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
@@ -317,62 +318,68 @@ function Layout() {
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack
|
||||
initialRouteName="/home"
|
||||
screenOptions={{
|
||||
autoHideHomeIndicator: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
<PlaySettingsProvider>
|
||||
<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)/play"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-video"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-music"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-music"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } 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";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
export const AudioTrackSelector: React.FC<Props> = ({ ...props }) => {
|
||||
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const selectedIndex = useMemo(() => {
|
||||
return playSettings?.audioIndex;
|
||||
}, [playSettings?.audioIndex]);
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
() =>
|
||||
playSettings?.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Audio"
|
||||
),
|
||||
[playSettings?.mediaSource]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected]
|
||||
const selectedAudioStream = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selectedIndex),
|
||||
[audioStreams, selectedIndex]
|
||||
);
|
||||
|
||||
// Set default audio stream only if none is selected and we have audio streams
|
||||
useEffect(() => {
|
||||
const defaultAudioIndex = audioStreams?.find(
|
||||
if (playSettings?.audioIndex !== undefined || !audioStreams?.length) return;
|
||||
|
||||
const defaultAudioIndex = audioStreams.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
|
||||
onChange(defaultAudioIndex);
|
||||
return;
|
||||
}
|
||||
const index = source.DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
onChange(index);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(0);
|
||||
}, [audioStreams, settings]);
|
||||
if (defaultAudioIndex !== undefined) {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
} else {
|
||||
const index = playSettings?.mediaSource?.DefaultAudioStreamIndex ?? 0;
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex: index,
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
audioStreams,
|
||||
settings?.defaultAudioLanguage,
|
||||
playSettings?.mediaSource,
|
||||
setPlaySettings,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -65,7 +69,7 @@ export const AudioTrackSelector: React.FC<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}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
{selectedAudioStream?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -85,7 +89,10 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex: audio.Index,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
|
||||
@@ -15,21 +15,11 @@ import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BaseItemDto } 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 { useAtom, useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
@@ -39,20 +29,18 @@ import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
const [settings] = useSettings();
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(-1);
|
||||
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
@@ -99,6 +87,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
),
|
||||
});
|
||||
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
item,
|
||||
}));
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,87 +104,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
else headerHeightRef.current = 400;
|
||||
}, [item, orientation]);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) {
|
||||
return null;
|
||||
}
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id,
|
||||
userId: user?.Id,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item.Id,
|
||||
maxBitrate,
|
||||
castDevice?.deviceId,
|
||||
selectedMediaSource?.Id,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
sessionData?.PlaySessionId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
item.Type !== "Program" &&
|
||||
(!sessionData || !selectedMediaSource?.Id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let deviceProfile: any = iosFmp4;
|
||||
|
||||
if (castDevice?.deviceId) {
|
||||
deviceProfile = chromecastProfile;
|
||||
} else if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
console.log("playbackUrl...");
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
forceDirectPlay: settings?.forceDirectPlay,
|
||||
height: maxBitrate.height,
|
||||
mediaSourceId: selectedMediaSource?.Id,
|
||||
});
|
||||
|
||||
console.info("Stream URL:", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
@@ -200,6 +112,14 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// useFocusEffect(
|
||||
// useCallback(() => {
|
||||
// return () => {
|
||||
// setPlaySettings(null);
|
||||
// };
|
||||
// }, [setPlaySettings])
|
||||
// );
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
@@ -256,31 +176,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MediaSourceSelector className="mr-1" />
|
||||
<AudioTrackSelector className="mr-1" />
|
||||
<SubtitleTrackSelector />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
<PlayButton item={item} url={playUrl} className="grow" />
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: MediaSourceInfo) => void;
|
||||
selected: MediaSourceInfo | null;
|
||||
}
|
||||
interface Props extends React.ComponentProps<typeof View> {}
|
||||
|
||||
export const MediaSourceSelector: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const mediaSources = useMemo(() => {
|
||||
return item.MediaSources;
|
||||
}, [item]);
|
||||
export const MediaSourceSelector: React.FC<Props> = ({ ...props }) => {
|
||||
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
|
||||
|
||||
const selectedMediaSource = useMemo(
|
||||
() =>
|
||||
mediaSources
|
||||
?.find((x) => x.Id === selected?.Id)
|
||||
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
||||
[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 (mediaSources?.length) onChange(mediaSources[0]);
|
||||
}, [mediaSources]);
|
||||
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],
|
||||
}));
|
||||
}
|
||||
}, [playSettings?.item?.MediaSources, setPlaySettings]);
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
@@ -71,11 +73,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{mediaSources?.map((source, idx: number) => (
|
||||
{playSettings?.item?.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
mediaSource: source,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
|
||||
@@ -65,8 +65,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
router.push("/play");
|
||||
router.push("/play-video");
|
||||
return;
|
||||
}
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
@@ -163,8 +162,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
router.push("/play");
|
||||
router.push("/play-video");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
|
||||
@@ -1,55 +1,46 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
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 * 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 React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({ ...props }) => {
|
||||
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
() =>
|
||||
playSettings?.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle"
|
||||
) ?? [],
|
||||
[playSettings?.mediaSource]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected]
|
||||
() => subtitleStreams.find((x) => x.Index === playSettings?.subtitleIndex),
|
||||
[subtitleStreams, playSettings?.subtitleIndex]
|
||||
);
|
||||
|
||||
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) {
|
||||
onChange(defaultSubIndex);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex: defaultSubIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(-1);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex: -1,
|
||||
}));
|
||||
}, [subtitleStreams, settings]);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
@@ -88,7 +79,10 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex: -1,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
@@ -98,7 +92,10 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex: subtitle.Index,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
|
||||
451
components/video-player/Controls.tsx
Normal file
451
components/video-player/Controls.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
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 { 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
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,
|
||||
Share,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { OnProgressData, ReactVideoProps, VideoRef } from "react-native-video";
|
||||
import { itemRouter } from "../common/TouchableItemRouter";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
videoRef,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
cacheProgress,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentEpisodes({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
||||
useTrickplay(item);
|
||||
|
||||
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 min = useSharedValue(0);
|
||||
const max = useSharedValue(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(
|
||||
item.Id,
|
||||
currentTime,
|
||||
videoRef
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
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]
|
||||
);
|
||||
|
||||
const isLandscape = useMemo(() => {
|
||||
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
|
||||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||
? true
|
||||
: false;
|
||||
}, [orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = item.RunTimeTicks || 0;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
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);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}, [previousItem, from, router]);
|
||||
|
||||
const handleGoToNextItem = useCallback(() => {
|
||||
if (!nextItem || !from) return;
|
||||
const url = itemRouter(nextItem, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}, [nextItem, from, router]);
|
||||
|
||||
const toggleIgnoreSafeArea = useCallback(() => {
|
||||
setIgnoreSafeArea((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="absolute h-screen w-screen top-0 left-0">
|
||||
<View className="relative">
|
||||
{(showControls || isBuffering) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className=" bg-black/50 z-0 w-screen h-screen absolute top-0 left-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
|
||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||
className={`z-10 absolute bottom-16 right-4 ${
|
||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<View className="absolute top-16 right-4 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={() => {
|
||||
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
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`absolute bottom-4 left-0 w-screen p-4 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{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={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -7,69 +7,59 @@ import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
currentlyPlaying?: CurrentlyPlayingState | null;
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const useAdjacentEpisodes = ({
|
||||
currentlyPlaying,
|
||||
}: AdjacentEpisodesProps) => {
|
||||
export const useAdjacentEpisodes = ({ item }: AdjacentEpisodesProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { data: previousItem } = useQuery({
|
||||
queryKey: [
|
||||
"previousItem",
|
||||
currentlyPlaying?.item.ParentId,
|
||||
currentlyPlaying?.item.IndexNumber,
|
||||
],
|
||||
queryKey: ["previousItem", item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
if (
|
||||
!api ||
|
||||
!currentlyPlaying?.item.ParentId ||
|
||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||
currentlyPlaying?.item.IndexNumber === null ||
|
||||
currentlyPlaying.item.IndexNumber - 2 < 0
|
||||
!item?.ParentId ||
|
||||
item?.IndexNumber === undefined ||
|
||||
item?.IndexNumber === null ||
|
||||
item?.IndexNumber - 2 < 0
|
||||
) {
|
||||
console.log("No previous item");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: currentlyPlaying.item.ParentId!,
|
||||
startIndex: currentlyPlaying.item.IndexNumber! - 2,
|
||||
parentId: item.ParentId!,
|
||||
startIndex: item.IndexNumber! - 2,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||
enabled: item?.Type === "Episode",
|
||||
});
|
||||
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: [
|
||||
"nextItem",
|
||||
currentlyPlaying?.item.ParentId,
|
||||
currentlyPlaying?.item.IndexNumber,
|
||||
],
|
||||
queryKey: ["nextItem", item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
if (
|
||||
!api ||
|
||||
!currentlyPlaying?.item.ParentId ||
|
||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||
currentlyPlaying?.item.IndexNumber === null
|
||||
!item?.ParentId ||
|
||||
item?.IndexNumber === undefined ||
|
||||
item?.IndexNumber === null
|
||||
) {
|
||||
console.log("No next item");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: currentlyPlaying.item.ParentId!,
|
||||
startIndex: currentlyPlaying.item.IndexNumber!,
|
||||
parentId: item.ParentId!,
|
||||
startIndex: item.IndexNumber!,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||
enabled: item?.Type === "Episode",
|
||||
});
|
||||
|
||||
return { previousItem, nextItem };
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// hooks/useTrickplay.ts
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { SharedValue } from "react-native-reanimated";
|
||||
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface TrickplayData {
|
||||
Interval?: number;
|
||||
@@ -28,21 +26,19 @@ interface TrickplayUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useTrickplay = (
|
||||
currentlyPlaying?: CurrentlyPlayingState | null
|
||||
) => {
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200; // 200ms throttle
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
||||
if (!item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSourceId = currentlyPlaying.item.Id;
|
||||
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayData = item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayData) {
|
||||
return null;
|
||||
@@ -59,7 +55,7 @@ export const useTrickplay = (
|
||||
data: trickplayData[firstResolution],
|
||||
}
|
||||
: null;
|
||||
}, [currentlyPlaying]);
|
||||
}, [item]);
|
||||
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
@@ -69,7 +65,7 @@ export const useTrickplay = (
|
||||
}
|
||||
lastCalculationTime.current = now;
|
||||
|
||||
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
||||
if (!trickplayInfo || !api || !item.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -95,13 +91,13 @@ export const useTrickplay = (
|
||||
const newTrickPlayUrl = {
|
||||
x: rowInTile,
|
||||
y: colInTile,
|
||||
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||
};
|
||||
|
||||
setTrickPlayUrl(newTrickPlayUrl);
|
||||
return newTrickPlayUrl;
|
||||
},
|
||||
[trickplayInfo, currentlyPlaying, api]
|
||||
[trickplayInfo, item, api]
|
||||
);
|
||||
|
||||
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
||||
|
||||
118
providers/PlaySettingsProvider.tsx
Normal file
118
providers/PlaySettingsProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
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";
|
||||
|
||||
export type PlaybackType = {
|
||||
item?: BaseItemDto | null;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
subtitleIndex?: number | null;
|
||||
audioIndex?: number | null;
|
||||
quality?: any | null;
|
||||
};
|
||||
|
||||
type PlaySettingsContextType = {
|
||||
playSettings: PlaybackType | null;
|
||||
setPlaySettings: React.Dispatch<React.SetStateAction<PlaybackType | null>>;
|
||||
playUrl: string | null;
|
||||
reportStopPlayback: (ticks: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [playSettings, setPlaySettings] = useState<PlaybackType | null>(null);
|
||||
const [playUrl, setPlayUrl] = useState<string | null>(null);
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const reportStopPlayback = useCallback(
|
||||
async (ticks: number) => {
|
||||
const id = playSettings?.item?.Id;
|
||||
setPlaySettings(null);
|
||||
|
||||
await reportPlaybackStopped({
|
||||
api,
|
||||
itemId: id,
|
||||
sessionId: undefined,
|
||||
positionTicks: ticks,
|
||||
});
|
||||
},
|
||||
[playSettings?.item?.Id, api]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlayUrl = async () => {
|
||||
if (!api || !user || !settings) {
|
||||
setPlayUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the device profile
|
||||
let deviceProfile: any = iosFmp4;
|
||||
if (settings?.deviceProfile === "Native") deviceProfile = native;
|
||||
if (settings?.deviceProfile === "Old") deviceProfile = old;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
deviceProfile,
|
||||
item: playSettings?.item,
|
||||
mediaSourceId: playSettings?.mediaSource?.Id,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate: 0,
|
||||
audioStreamIndex: playSettings?.audioIndex
|
||||
? playSettings?.audioIndex
|
||||
: 0,
|
||||
subtitleStreamIndex: playSettings?.subtitleIndex
|
||||
? playSettings?.subtitleIndex
|
||||
: -1,
|
||||
userId: user.Id,
|
||||
forceDirectPlay: false,
|
||||
sessionData: null,
|
||||
});
|
||||
|
||||
setPlayUrl(url);
|
||||
};
|
||||
|
||||
fetchPlayUrl();
|
||||
}, [api, settings, user, playSettings]);
|
||||
|
||||
return (
|
||||
<PlaySettingsContext.Provider
|
||||
value={{ playSettings, setPlaySettings, playUrl, reportStopPlayback }}
|
||||
>
|
||||
{children}
|
||||
</PlaySettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePlaySettings = () => {
|
||||
const context = useContext(PlaySettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"usePlaySettings must be used within a PlaySettingsProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -133,7 +133,7 @@ const saveSettings = async (settings: Settings) => {
|
||||
};
|
||||
|
||||
// Create an atom to store the settings in memory
|
||||
const settingsAtom = atom<Settings | null>(null);
|
||||
export const settingsAtom = atom<Settings | null>(null);
|
||||
|
||||
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
||||
export const useSettings = () => {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
PlaybackInfoResponse,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { isPlainObject } from "lodash";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
export const getStreamUrl = async ({
|
||||
api,
|
||||
@@ -64,14 +61,7 @@ export const getStreamUrl = async ({
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const mediaSourceId = res0.data.MediaSources?.[0].Id;
|
||||
const liveStreamId = res0.data.MediaSources?.[0].LiveStreamId;
|
||||
|
||||
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
|
||||
|
||||
console.log("transcodeUrl", transcodeUrl);
|
||||
|
||||
if (transcodeUrl) return `${api.basePath}${transcodeUrl}`;
|
||||
}
|
||||
|
||||
@@ -131,7 +121,9 @@ export const getStreamUrl = async ({
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
}
|
||||
|
||||
if (!url) throw new Error("No url");
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user