mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
wip
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -57,8 +57,8 @@ export default function index() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
@@ -226,6 +226,7 @@ export default function index() {
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
@@ -340,7 +341,7 @@ export default function index() {
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (e1 || e2 || !api)
|
||||
if (e1 || e2)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function settings() {
|
||||
registerBackgroundFetchAsync
|
||||
</Button> */}
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<ListItem title="User" subTitle={user?.Name} />
|
||||
|
||||
@@ -1,12 +1,308 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
import { Text } from "@/components/common/Text";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
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 { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
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");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
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 (ticks: number) => {
|
||||
console.log("togglePlay");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item.Id,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="">
|
||||
<StatusBar hidden={false} />
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
|
||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||
<Image
|
||||
source={poster}
|
||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full opacity-0"
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
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}
|
||||
enableTrickplay={false}
|
||||
/>
|
||||
</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,
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
@@ -7,17 +9,18 @@ import {
|
||||
} 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 NavigationBar from "expo-navigation-bar";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -25,16 +28,11 @@ import 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);
|
||||
@@ -45,27 +43,19 @@ export default function page() {
|
||||
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]);
|
||||
}, [isPlaying]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
@@ -77,67 +67,29 @@ export default function page() {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
play();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
});
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
useEffect(() => {
|
||||
const orientationSubscription =
|
||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||
setOrientation(
|
||||
orientationToOrientationLock(event.orientationInfo.orientation)
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
});
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
return () => {
|
||||
orientationSubscription.remove();
|
||||
};
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -158,7 +110,6 @@ export default function page() {
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
paused={!isPlaying}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
@@ -176,7 +127,9 @@ export default function page() {
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
@@ -198,25 +151,6 @@ export default function page() {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
@@ -8,25 +11,22 @@ import {
|
||||
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 { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
VideoRef,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
} from "react-native-video";
|
||||
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
@@ -44,9 +44,6 @@ export default function page() {
|
||||
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);
|
||||
@@ -59,7 +56,6 @@ export default function page() {
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
@@ -76,7 +72,6 @@ export default function page() {
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
@@ -98,19 +93,16 @@ export default function page() {
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setIsPlaybackStopped(true);
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
@@ -142,7 +134,7 @@ export default function page() {
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
@@ -180,50 +172,9 @@ export default function page() {
|
||||
}, [play, 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 { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
@@ -232,6 +183,36 @@ export default function page() {
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
const selectedSubtitleTrack = useMemo(() => {
|
||||
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
||||
(s) => s.Index === playSettings.subtitleIndex
|
||||
);
|
||||
console.log(a);
|
||||
return a;
|
||||
}, [playSettings]);
|
||||
|
||||
const [hlsSubTracks, setHlsSubTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const selectedTextTrack = useMemo(() => {
|
||||
for (let st of hlsSubTracks) {
|
||||
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
||||
return {
|
||||
type: SelectedTrackType.TITLE,
|
||||
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [hlsSubTracks]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -251,7 +232,6 @@ export default function page() {
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
paused={!isPlaying}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
@@ -270,7 +250,14 @@ export default function page() {
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => setIsPlaying(state.isPlaying)}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
console.log("onTextTracks ~", data);
|
||||
setHlsSubTracks(data.textTracks as any);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -198,8 +199,10 @@ const checkAndRequestPermissions = async () => {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
|
||||
if (status === "granted") {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
@@ -208,6 +211,11 @@ const checkAndRequestPermissions = async () => {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
}
|
||||
};
|
||||
@@ -403,6 +411,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
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 React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
@@ -35,24 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) 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, source]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
|
||||
@@ -9,7 +9,7 @@ export type Bitrate = {
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const BITRATES: Bitrate[] = [
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
@@ -39,7 +39,7 @@ const BITRATES: Bitrate[] = [
|
||||
value: 250000,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
|
||||
@@ -17,12 +17,12 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
@@ -31,6 +31,7 @@ import ProgressCircle from "./ProgressCircle";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { toast } from "sonner-native";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
@@ -42,10 +43,11 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload } = useDownload();
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -54,6 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(() => {
|
||||
return user?.Policy?.EnableContentDownloading;
|
||||
}, [user]);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import {
|
||||
Bitrate,
|
||||
BITRATES,
|
||||
BitrateSelector,
|
||||
} from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -20,10 +24,16 @@ import {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useFocusEffect, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import Animated from "react-native-reanimated";
|
||||
@@ -32,13 +42,14 @@ import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const [settings] = useSettings();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
@@ -47,6 +58,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
const selectedMediaSource = useMemo(() => {
|
||||
return playSettings?.mediaSource || undefined;
|
||||
}, [playSettings?.mediaSource]);
|
||||
@@ -62,7 +89,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
return playSettings?.audioIndex;
|
||||
}, [playSettings?.audioIndex]);
|
||||
|
||||
const setSelectedAudioStream = (audioIndex: number | undefined) => {
|
||||
const setSelectedAudioStream = (audioIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex,
|
||||
@@ -73,7 +100,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
return playSettings?.subtitleIndex;
|
||||
}, [playSettings?.subtitleIndex]);
|
||||
|
||||
const setSelectedSubtitleStream = (subtitleIndex: number | undefined) => {
|
||||
const setSelectedSubtitleStream = (subtitleIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex,
|
||||
@@ -128,15 +155,14 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
),
|
||||
});
|
||||
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex: undefined,
|
||||
subtitleIndex: undefined,
|
||||
mediaSourceId: undefined,
|
||||
bitrate: undefined,
|
||||
mediaSource: item.MediaSources?.[0],
|
||||
item,
|
||||
}));
|
||||
// setPlaySettings((prev) => ({
|
||||
// audioIndex: undefined,
|
||||
// subtitleIndex: undefined,
|
||||
// mediaSourceId: undefined,
|
||||
// bitrate: undefined,
|
||||
// mediaSource: item.MediaSources?.[0],
|
||||
// item,
|
||||
// }));
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
|
||||
const onError = (error: any) => {
|
||||
console.error("Video Error: ", error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Video
|
||||
source={{
|
||||
uri: url,
|
||||
isNetwork: false,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onError={onError}
|
||||
ignoreSilentSwitch="ignore"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,9 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
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 React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[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) {
|
||||
onChange(defaultSubIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(-1);
|
||||
}, [subtitleStreams, settings]);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,10 +76,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
className="flex flex-col"
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
{base64Image ? (
|
||||
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
|
||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -92,7 +92,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
@@ -72,32 +68,18 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const play = async (type: "device" | "cast") => {
|
||||
const play = useCallback(async (type: "device" | "cast") => {
|
||||
if (!user?.Id || !api || !item.Id) {
|
||||
console.warn("No user, api or item", user, api, item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const sessionData = response.data;
|
||||
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
const data = await setPlaySettings({
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosFmp4,
|
||||
mediaSourceId: item.Id,
|
||||
});
|
||||
|
||||
if (!data?.url || !item) {
|
||||
console.warn("No url or item", data?.url, item.Id);
|
||||
return;
|
||||
if (!data?.url) {
|
||||
throw new Error("play-music ~ No stream url");
|
||||
}
|
||||
|
||||
if (type === "cast" && client) {
|
||||
@@ -121,12 +103,9 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
});
|
||||
} else {
|
||||
console.log("Playing on device", data.url, item.Id);
|
||||
setPlaySettings({
|
||||
item,
|
||||
});
|
||||
router.push("/play-music");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
|
||||
type?: "next" | "previous";
|
||||
}
|
||||
|
||||
export const NextEpisodeButton: React.FC<Props> = ({
|
||||
export const NextItemButton: React.FC<Props> = ({
|
||||
item,
|
||||
type = "next",
|
||||
...props
|
||||
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: ["nextItem", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
if (
|
||||
!api ||
|
||||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
if (!nextEpisode) return true;
|
||||
if (nextEpisode.Id === item.Id) return true;
|
||||
if (!nextItem) return true;
|
||||
if (nextItem.Id === item.Id) return true;
|
||||
return false;
|
||||
}, [nextEpisode, type]);
|
||||
}, [nextItem, type]);
|
||||
|
||||
if (item.Type !== "Episode") return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
||||
onPress={() => router.setParams({ id: nextItem?.Id })}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
@@ -1,24 +1,17 @@
|
||||
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
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,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
@@ -38,23 +31,22 @@ import Animated, {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import { Text } from "../common/Text";
|
||||
import { itemRouter } from "../common/TouchableItemRouter";
|
||||
import { Loader } from "../Loader";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
@@ -70,13 +62,13 @@ export const Controls: React.FC<Props> = ({
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
enableTrickplay = true,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setPlaySettings } = usePlaySettings();
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
|
||||
const op = useSharedValue<number>(1);
|
||||
@@ -117,9 +109,11 @@ export const Controls: React.FC<Props> = ({
|
||||
}
|
||||
}, [showControls, isBuffering]);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentEpisodes({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
||||
useTrickplay(item);
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
enableTrickplay
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0); // Seconds
|
||||
const [remainingTime, setRemainingTime] = useState(0); // Seconds
|
||||
@@ -127,7 +121,7 @@ export const Controls: React.FC<Props> = ({
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
const from = useMemo(() => segments[2], [segments]);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
@@ -152,6 +146,40 @@ export const Controls: React.FC<Props> = ({
|
||||
videoRef
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: previousItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [previousItem, settings]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: nextItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [nextItem, settings]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
@@ -175,14 +203,12 @@ export const Controls: React.FC<Props> = ({
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
const handleSliderComplete = (value: number) => {
|
||||
const handleSliderComplete = useCallback((value: number) => {
|
||||
progress.value = value;
|
||||
isSeeking.value = false;
|
||||
videoRef.current?.seek(value / 10000000);
|
||||
setTimeout(() => {
|
||||
videoRef.current?.resume();
|
||||
}, 200);
|
||||
};
|
||||
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, []);
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
calculateTrickplayUrl(value);
|
||||
@@ -190,52 +216,44 @@ export const Controls: React.FC<Props> = ({
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
videoRef.current?.pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls]);
|
||||
}, [showControls, isPlaying]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings) return;
|
||||
console.log("handleSkipBackward");
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||
setTimeout(() => {
|
||||
videoRef.current?.resume();
|
||||
}, 200);
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings]);
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings) return;
|
||||
console.log("handleSkipForward");
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||
setTimeout(() => {
|
||||
videoRef.current?.resume();
|
||||
}, 200);
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} 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]);
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
@@ -395,7 +413,7 @@ export const Controls: React.FC<Props> = ({
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleGoToPreviousItem}
|
||||
onPress={goToPreviousItem}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
@@ -427,7 +445,7 @@ export const Controls: React.FC<Props> = ({
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleGoToNextItem}
|
||||
onPress={goToNextItem}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
|
||||
export const Colors = {
|
||||
primary: "#9334E9",
|
||||
text: "#ECEDEE",
|
||||
background: "#151718",
|
||||
tint: tintColorDark,
|
||||
tint: "#fff",
|
||||
icon: "#9BA1A6",
|
||||
tabIconDefault: "#9BA1A6",
|
||||
tabIconSelected: "#9333ea",
|
||||
|
||||
@@ -1,64 +1,98 @@
|
||||
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 { useAtom } from "jotai";
|
||||
import index from "@/app/(auth)/(tabs)/(home)";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const useAdjacentEpisodes = ({ item }: AdjacentEpisodesProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const { data: previousItem } = useQuery({
|
||||
queryKey: ["previousItem", item?.ParentId, item?.IndexNumber],
|
||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
|
||||
console.log("Getting previous item for " + indexNumber);
|
||||
if (
|
||||
!api ||
|
||||
!item?.ParentId ||
|
||||
item?.IndexNumber === undefined ||
|
||||
item?.IndexNumber === null ||
|
||||
item?.IndexNumber - 2 < 0
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null ||
|
||||
indexNumber - 1 < 1
|
||||
) {
|
||||
console.log("No previous item");
|
||||
console.log("No previous item", {
|
||||
itemIndex: indexNumber,
|
||||
itemId: item?.Id,
|
||||
parentId: parentId,
|
||||
indexNumber: indexNumber,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const newIndexNumber = indexNumber - 2;
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: item.ParentId!,
|
||||
startIndex: item.IndexNumber! - 2,
|
||||
parentId: parentId!,
|
||||
startIndex: newIndexNumber,
|
||||
limit: 1,
|
||||
sortBy: ["IndexNumber"],
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode",
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: ["nextItem", item?.ParentId, item?.IndexNumber],
|
||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
|
||||
if (
|
||||
!api ||
|
||||
!item?.ParentId ||
|
||||
item?.IndexNumber === undefined ||
|
||||
item?.IndexNumber === null
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null
|
||||
) {
|
||||
console.log("No next item");
|
||||
console.log("No next item", {
|
||||
itemId: item?.Id,
|
||||
parentId: parentId,
|
||||
indexNumber: indexNumber,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: item.ParentId!,
|
||||
startIndex: item.IndexNumber!,
|
||||
parentId: parentId!,
|
||||
startIndex: indexNumber,
|
||||
sortBy: ["IndexNumber"],
|
||||
limit: 1,
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode",
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return { previousItem, nextItem };
|
||||
|
||||
17
hooks/useAndroidNavigationBar.ts
Normal file
17
hooks/useAndroidNavigationBar.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const useAndroidNavigationBar = () => {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setVisibilityAsync("hidden");
|
||||
NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||
|
||||
return () => {
|
||||
NavigationBar.setVisibilityAsync("visible");
|
||||
NavigationBar.setBehaviorAsync("inset-swipe");
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export const useCreditSkipper = (
|
||||
}, [creditTimestamps, currentTime]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
console.log("skipCredits");
|
||||
if (!creditTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(creditTimestamps.Credits.End);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// hooks/useFileOpener.ts
|
||||
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -42,8 +43,8 @@ export const useFileOpener = () => {
|
||||
|
||||
router.push("/play-offline-video");
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
// Handle the error appropriately, e.g., show an error message to the user
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useIntroSkipper = (
|
||||
}, [introTimestamps, currentTime]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
console.log("skipIntro");
|
||||
if (!introTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||
|
||||
28
hooks/useOrientation.ts
Normal file
28
hooks/useOrientation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useOrientation = () => {
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.OrientationLock.UNKNOWN
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const orientationSubscription =
|
||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||
setOrientation(
|
||||
orientationToOrientationLock(event.orientationInfo.orientation)
|
||||
);
|
||||
});
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
});
|
||||
|
||||
return () => {
|
||||
orientationSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { orientation };
|
||||
};
|
||||
25
hooks/useOrientationSettings.ts
Normal file
25
hooks/useOrientationSettings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useOrientationSettings = () => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate) {
|
||||
// Don't need to do anything
|
||||
} else if (settings?.defaultVideoOrientation) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (settings?.autoRotate) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [settings]);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
@@ -10,6 +10,9 @@ import { toast } from "sonner-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useRouter } from "expo-router";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -19,9 +22,12 @@ import { JobStatus } from "@/utils/optimize-server";
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const { loadImage, saveImage, image2Base64, saveBase64Image } =
|
||||
useImageStorage();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
@@ -32,8 +38,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
|
||||
toast.success(`Download started for ${item.Name}`, {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
|
||||
@@ -26,14 +26,14 @@ interface TrickplayUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200; // 200ms throttle
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
if (!item.Id || !item.Trickplay) {
|
||||
if (!enabled || !item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -55,10 +55,14 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
data: trickplayData[firstResolution],
|
||||
}
|
||||
: null;
|
||||
}, [item]);
|
||||
}, [item, enabled]);
|
||||
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastCalculationTime.current < throttleDelay) {
|
||||
return null;
|
||||
@@ -97,8 +101,12 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
setTrickPlayUrl(newTrickPlayUrl);
|
||||
return newTrickPlayUrl;
|
||||
},
|
||||
[trickplayInfo, item, api]
|
||||
[trickplayInfo, item, api, enabled]
|
||||
);
|
||||
|
||||
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
||||
return {
|
||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
||||
trickplayInfo: enabled ? trickplayInfo : null,
|
||||
};
|
||||
};
|
||||
|
||||
22
package.json
22
package.json
@@ -64,22 +64,22 @@
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "~0.75.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-awesome-slider": "^2.5.3",
|
||||
"react-native-circular-progress": "^1.4.0",
|
||||
"react-native-compressor": "^1.8.25",
|
||||
"react-native-gesture-handler": "~2.18.1",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "^6.4.1",
|
||||
"react-native-reanimated": "~3.15.0",
|
||||
"react-native-pager-view": "6.3.0",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "~3.34.0",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-tab-view": "^3.5.2",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
@@ -103,15 +103,5 @@
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native@~0.74.0",
|
||||
"react-native-reanimated@~3.10.0",
|
||||
"react-native-gesture-handler@~2.16.1",
|
||||
"react-native-screens@~3.31.1"
|
||||
]
|
||||
}
|
||||
}
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ function useDownloadProvider() {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
||||
console.error("Error in startBackgroundDownload:", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error("Axios error details:", {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi, getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -17,7 +17,8 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { apiAtom, getOrSetDeviceId, userAtom } from "./JellyfinProvider";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
|
||||
export type PlaybackType = {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -29,11 +30,17 @@ export type PlaybackType = {
|
||||
|
||||
type PlaySettingsContextType = {
|
||||
playSettings: PlaybackType | null;
|
||||
setPlaySettings: React.Dispatch<React.SetStateAction<PlaybackType | null>>;
|
||||
setPlaySettings: (
|
||||
dataOrUpdater:
|
||||
| PlaybackType
|
||||
| null
|
||||
| ((prev: PlaybackType | null) => PlaybackType | null)
|
||||
) => Promise<{ url: string | null; sessionId: string | null } | null>;
|
||||
playUrl?: string | null;
|
||||
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
playSessionId?: string | null;
|
||||
setOfflineSettings: (data: PlaybackType) => void;
|
||||
setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
|
||||
};
|
||||
|
||||
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
|
||||
@@ -55,52 +62,69 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
_setPlaySettings(data);
|
||||
}, []);
|
||||
|
||||
const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
|
||||
setPlaySettings({
|
||||
item: item,
|
||||
});
|
||||
setPlayUrl(url);
|
||||
};
|
||||
|
||||
const setPlaySettings = useCallback(
|
||||
async (
|
||||
dataOrUpdater:
|
||||
| PlaybackType
|
||||
| null
|
||||
| ((prev: PlaybackType | null) => PlaybackType | null)
|
||||
) => {
|
||||
_setPlaySettings((prevSettings) => {
|
||||
const newSettings =
|
||||
typeof dataOrUpdater === "function"
|
||||
? dataOrUpdater(prevSettings)
|
||||
: dataOrUpdater;
|
||||
): Promise<{ url: string | null; sessionId: string | null } | null> => {
|
||||
if (!api || !user || !settings) {
|
||||
_setPlaySettings(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!api || !user || !settings || newSettings === null) {
|
||||
return newSettings;
|
||||
}
|
||||
const newSettings =
|
||||
typeof dataOrUpdater === "function"
|
||||
? dataOrUpdater(playSettings)
|
||||
: dataOrUpdater;
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
if (settings?.deviceProfile === "Native") deviceProfile = native;
|
||||
if (settings?.deviceProfile === "Old") deviceProfile = old;
|
||||
if (newSettings === null) {
|
||||
_setPlaySettings(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
getStreamUrl({
|
||||
let deviceProfile: any = iosFmp4;
|
||||
if (settings?.deviceProfile === "Native") deviceProfile = native;
|
||||
if (settings?.deviceProfile === "Old") deviceProfile = old;
|
||||
|
||||
console.log("Selected sub index: ", newSettings?.subtitleIndex);
|
||||
|
||||
try {
|
||||
const data = await 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,
|
||||
audioStreamIndex: newSettings?.audioIndex ?? 0,
|
||||
subtitleStreamIndex: newSettings?.subtitleIndex ?? -1,
|
||||
userId: user.Id,
|
||||
forceDirectPlay: false,
|
||||
sessionData: null,
|
||||
}).then((data) => {
|
||||
setPlayUrl(data?.url!);
|
||||
setPlaySessionId(data?.sessionId!);
|
||||
});
|
||||
|
||||
return newSettings;
|
||||
});
|
||||
console.log("getStreamUrl ~ ", data?.url);
|
||||
|
||||
_setPlaySettings(newSettings);
|
||||
setPlayUrl(data?.url!);
|
||||
setPlaySessionId(data?.sessionId!);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.warn("Error getting stream URL:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[api, user, settings, setPlayUrl]
|
||||
[api, user, settings, playSettings]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,6 +158,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
setPlaySettings,
|
||||
playUrl,
|
||||
setPlayUrl,
|
||||
setMusicPlaySettings,
|
||||
setOfflineSettings,
|
||||
playSessionId,
|
||||
}}
|
||||
|
||||
63
utils/jellyfin/getDefaultPlaySettings.ts
Normal file
63
utils/jellyfin/getDefaultPlaySettings.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// utils/getDefaultPlaySettings.ts
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Settings } from "../atoms/settings";
|
||||
|
||||
interface PlaySettings {
|
||||
item: BaseItemDto;
|
||||
bitrate: (typeof BITRATES)[0];
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex?: number | null;
|
||||
subtitleIndex?: number | null;
|
||||
}
|
||||
|
||||
export function getDefaultPlaySettings(
|
||||
item: BaseItemDto,
|
||||
settings: Settings
|
||||
): PlaySettings {
|
||||
if (item.Type === "Program") {
|
||||
return {
|
||||
item,
|
||||
bitrate: BITRATES[0],
|
||||
mediaSource: undefined,
|
||||
audioIndex: undefined,
|
||||
subtitleIndex: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Get first media source
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
|
||||
if (!mediaSource) throw new Error("No media source found");
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
|
||||
// 3. Get default or preferred subtitle
|
||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
)?.Index;
|
||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate
|
||||
const bitrate = BITRATES[0];
|
||||
|
||||
return {
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex: preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex,
|
||||
subtitleIndex: preferedSubtitleIndex ?? defaultSubtitleIndex ?? -1,
|
||||
};
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export const getStreamUrl = async ({
|
||||
height?: number;
|
||||
mediaSourceId?: string | null;
|
||||
}): Promise<{
|
||||
url: string | null | undefined;
|
||||
sessionId: string | null | undefined;
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
} | null> => {
|
||||
if (!api || !userId || !item?.Id) {
|
||||
return null;
|
||||
@@ -66,7 +66,7 @@ export const getStreamUrl = async ({
|
||||
}
|
||||
);
|
||||
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
|
||||
sessionId = res0.data.PlaySessionId;
|
||||
sessionId = res0.data.PlaySessionId || null;
|
||||
|
||||
if (transcodeUrl) {
|
||||
return { url: `${api.basePath}${transcodeUrl}`, sessionId };
|
||||
@@ -75,29 +75,6 @@ export const getStreamUrl = async ({
|
||||
|
||||
const itemId = item.Id;
|
||||
|
||||
// const res2 = await api.axiosInstance.post(
|
||||
// `${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
||||
// {
|
||||
// DeviceProfile: deviceProfile,
|
||||
// UserId: userId,
|
||||
// MaxStreamingBitrate: maxStreamingBitrate,
|
||||
// StartTimeTicks: startTimeTicks,
|
||||
// EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
||||
// AutoOpenLiveStream: true,
|
||||
// MediaSourceId: mediaSourceId,
|
||||
// AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
||||
// AudioStreamIndex: audioStreamIndex,
|
||||
// SubtitleStreamIndex: subtitleStreamIndex,
|
||||
// DeInterlace: true,
|
||||
// BreakOnNonKeyFrames: false,
|
||||
// CopyTimestamps: false,
|
||||
// EnableMpegtsM2TsMode: false,
|
||||
// },
|
||||
// {
|
||||
// headers: getAuthHeaders(api),
|
||||
// }
|
||||
// );
|
||||
|
||||
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
userId,
|
||||
@@ -120,55 +97,64 @@ export const getStreamUrl = async ({
|
||||
breakOnNonKeyFrames: false,
|
||||
copyTimestamps: false,
|
||||
enableMpegtsM2TsMode: false,
|
||||
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
sessionId = res2.data.PlaySessionId;
|
||||
sessionId = res2.data.PlaySessionId || null;
|
||||
|
||||
mediaSource = res2.data.MediaSources?.find(
|
||||
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
||||
);
|
||||
|
||||
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
|
||||
if (item.MediaType === "Video") {
|
||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: userId,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
PlaySessionId: sessionData?.PlaySessionId || "",
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
url = `${
|
||||
api.basePath
|
||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||
console.log("getStreamUrl ~ ", item.MediaType);
|
||||
|
||||
if (item.MediaType === "Video") {
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
sessionId: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
|
||||
return {
|
||||
url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`,
|
||||
sessionId: sessionId,
|
||||
};
|
||||
}
|
||||
} else if (mediaSource?.TranscodingUrl) {
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.log("getStreamUrl: no url found", {
|
||||
api: api.basePath,
|
||||
userId,
|
||||
item: item.Id,
|
||||
mediaSourceId,
|
||||
if (item.MediaType === "Audio") {
|
||||
console.log("getStreamUrl ~ Audio");
|
||||
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
return { url: `${api.basePath}${mediaSource.TranscodingUrl}`, sessionId };
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: userId,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
PlaySessionId: sessionData?.PlaySessionId || "",
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
return null;
|
||||
return {
|
||||
url: `${
|
||||
api.basePath
|
||||
}/Audio/${itemId}/universal?${searchParams.toString()}`,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sessionId,
|
||||
};
|
||||
throw new Error("Unsupported media type");
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import axios from "axios";
|
||||
import { writeToLog } from "./log";
|
||||
|
||||
interface IJobInput {
|
||||
deviceId?: string | null;
|
||||
@@ -108,6 +109,7 @@ export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user