This commit is contained in:
Fredrik Burmester
2024-10-08 15:39:44 +02:00
parent a5b4f6cc78
commit ec0843d737
33 changed files with 895 additions and 527 deletions

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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);
}
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -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"

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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(() => {

View File

@@ -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"
/>
);
};

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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 };

View 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");
};
}
}, []);
};

View File

@@ -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);

View File

@@ -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
}
}, []);

View File

@@ -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
View 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 };
};

View 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]);
};

View File

@@ -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",

View File

@@ -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,
};
};

View File

@@ -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
}

View File

@@ -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:", {

View File

@@ -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,
}}

View 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,
};
}

View File

@@ -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");
};

View File

@@ -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;
}