forked from Ninjalama/streamyfin_mirror
fix: resolve all biome linting errors
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
@@ -16,30 +6,59 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
function migration_20241124(
|
||||
deleteAllFiles: () => Promise<void>,
|
||||
router: any,
|
||||
t: any,
|
||||
) {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onPress: () => router.back(),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.continue"),
|
||||
onPress: () => deleteAllFiles(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } =
|
||||
useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
@@ -54,12 +73,12 @@ export default function page() {
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
migration_20241124();
|
||||
migration_20241124(deleteAllFiles, router, t);
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
}, [downloadedFiles, deleteAllFiles, router, t]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
@@ -98,123 +117,123 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -249,23 +268,3 @@ export default function page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||
[
|
||||
{
|
||||
text: t("home.downloads.back"),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: t("home.downloads.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,29 +9,18 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
{item && <ItemContent />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
@@ -33,6 +32,8 @@ import type {
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
@@ -41,10 +42,6 @@ import { storage } from "@/utils/mmkv";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
const downloadProvider = !Platform.isTV
|
||||
? require("@/providers/DownloadProvider")
|
||||
: { useDownload: () => null };
|
||||
|
||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||
|
||||
export default function page() {
|
||||
@@ -74,7 +71,7 @@ export default function page() {
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const getDownloadedItem = downloadProvider.useDownload();
|
||||
const downloadUtils = useDownload();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -123,18 +120,21 @@ export default function page() {
|
||||
: BITRATES[0].value;
|
||||
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||
null,
|
||||
);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
/** Gets the initial playback position from the URL or the item's user data. */
|
||||
/** Gets the initial playback position from the URL. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}, [playbackPositionFromUrl, item]);
|
||||
return 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
@@ -142,8 +142,11 @@ export default function page() {
|
||||
try {
|
||||
let fetchedItem: BaseItemDto | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (data) fetchedItem = data.item as BaseItemDto;
|
||||
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (data) {
|
||||
fetchedItem = data.item as BaseItemDto;
|
||||
setDownloadedItem(data);
|
||||
}
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
@@ -182,12 +185,15 @@ export default function page() {
|
||||
const native = await generateDeviceProfile();
|
||||
try {
|
||||
let result: Stream | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return;
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
if (offline && downloadedItem) {
|
||||
if (!downloadedItem?.mediaSource) return;
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (item) {
|
||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||
result = {
|
||||
mediaSource: downloadedItem.mediaSource,
|
||||
sessionId: "",
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const res = await getStreamUrl({
|
||||
@@ -220,10 +226,18 @@ export default function page() {
|
||||
}
|
||||
};
|
||||
fetchStreamData();
|
||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||
}, [
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
bitrateValue,
|
||||
api,
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream) return;
|
||||
if (!stream || offline) return;
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -257,8 +271,6 @@ export default function page() {
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
@@ -273,6 +285,7 @@ export default function page() {
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -316,8 +329,7 @@ export default function page() {
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
|
||||
if (offline) return;
|
||||
if (!item?.Id || !stream) return;
|
||||
if (!item?.Id) return;
|
||||
|
||||
reportPlaybackProgress();
|
||||
},
|
||||
@@ -340,7 +352,26 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
if (!api || offline || !stream) return;
|
||||
// If offline we constant want to be writing to our local cache t
|
||||
if (offline) {
|
||||
const downloadedItem = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (downloadedItem) {
|
||||
downloadedItem.item.UserData = {
|
||||
...downloadedItem.item.UserData,
|
||||
PlaybackPositionTicks: msToTicks(progress.get()),
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage: downloadedItem.item.RunTimeTicks
|
||||
? (msToTicks(progress.get()) / downloadedItem.item.RunTimeTicks) *
|
||||
100
|
||||
: 0,
|
||||
Played: false,
|
||||
};
|
||||
downloadUtils.updateDownloadedItem(itemId, downloadedItem);
|
||||
}
|
||||
console.log("reported playback progress", itemId, progress.get());
|
||||
return;
|
||||
}
|
||||
if (!api || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
@@ -358,9 +389,8 @@ export default function page() {
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [offline, getInitialPlaybackTicks]);
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
@@ -601,6 +631,7 @@ export default function page() {
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
isVlc
|
||||
downloadedItem={downloadedItem}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
@@ -24,15 +16,23 @@ import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
@@ -58,7 +58,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
@@ -77,10 +76,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user],
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -88,7 +83,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
const handleSheetChanges = useCallback((_index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -161,7 +156,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
@@ -204,7 +198,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
download: true,
|
||||
// deviceId: mediaSource?.Id,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
@@ -232,7 +225,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
],
|
||||
);
|
||||
@@ -253,7 +245,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
@@ -368,13 +359,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className='opacity-70 text-center w-full flex items-center'>
|
||||
<Text className='text-xs'>
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -8,34 +16,24 @@ import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
@@ -45,231 +43,253 @@ export type SelectedOptions = {
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
export const ItemContent: React.FC = React.memo(() => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
useImageColors({ item });
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
const { data: item } = useItemQuery(id, isOffline);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
useImageColors({ item });
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast
|
||||
background='blur'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
}
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
item,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && !isOffline && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && !isOffline && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
<PlayedStatus
|
||||
items={[item]}
|
||||
size='large'
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
{!isOffline && <AddToFavorites item={item} />}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item, navigation, isOffline, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
}
|
||||
}, [item, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
return (
|
||||
<View
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
<ItemImage
|
||||
variant={
|
||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||
}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
<ItemImage
|
||||
variant={
|
||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||
}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{item.Type !== "Program" && !Platform.isTV && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||
<BitrateSelector
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className='mr-1'
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className='mr-1'
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||
<BitrateSelector
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className='mr-1'
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className='mr-1'
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -15,7 +5,6 @@ import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
@@ -33,12 +22,23 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
@@ -76,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router],
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
@@ -91,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
items.forEach((item) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["continueWatching"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
});
|
||||
};
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({
|
||||
items,
|
||||
isOffline = false,
|
||||
...props
|
||||
}) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||
const toggle = useMarkAsPlayed(items, isOffline);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
@@ -52,8 +24,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
console.log(allPlayed);
|
||||
await markAsPlayedStatus(!allPlayed);
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
|
||||
47
components/common/ProgressBar.tsx
Normal file
47
components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface ProgressBarProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
if (!item.StartDate || !item.EndDate) {
|
||||
return 0;
|
||||
}
|
||||
const startDate = new Date(item.StartDate);
|
||||
const endDate = new Date(item.EndDate);
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (progress <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
@@ -8,9 +6,12 @@ import type {
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (
|
||||
@@ -50,6 +51,7 @@ export const itemRouter = (
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
@@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
let url = itemRouter(item, from);
|
||||
if (isOffline) {
|
||||
url += `&offline=true`;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
@@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
@@ -19,13 +13,24 @@ import {
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
};
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const { processes } = useDownload();
|
||||
if (processes?.length === 0)
|
||||
@@ -59,12 +64,13 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { processes, startDownload } = useDownload();
|
||||
const { startDownload, removeProcess } = useDownload();
|
||||
const router = useRouter();
|
||||
const { removeProcess, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
console.log("process", process.progress);
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
@@ -93,11 +99,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
});
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || !p.progress) return null;
|
||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||
return formatTimeString(timeLeft, "tick");
|
||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||
if (bytesRemaining <= 0) return null;
|
||||
|
||||
const secondsRemaining = bytesRemaining / p.speed;
|
||||
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
};
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
@@ -110,8 +119,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
||||
{...props}
|
||||
>
|
||||
{(process.status === "optimizing" ||
|
||||
process.status === "downloading") && (
|
||||
{process.status === "downloading" && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
@@ -151,8 +159,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
) : (
|
||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && (
|
||||
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className='text-xs'>
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { type TouchableOpacityProps, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
@@ -27,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, [item]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
deleteFile(item.Id, "Episode");
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
@@ -77,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
key={item.Id}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
<View className='flex flex-row items-start mb-2'>
|
||||
@@ -104,7 +86,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { ProgressBar } from "../common/ProgressBar";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface MovieCardProps {
|
||||
@@ -27,16 +26,10 @@ interface MovieCardProps {
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
return storage.getString(item?.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -44,8 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
successHapticFeedback();
|
||||
deleteFile(item.Id, "Movie");
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
@@ -75,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
{base64Image ? (
|
||||
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -88,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
) : (
|
||||
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color='gray'
|
||||
className='self-center mt-16'
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
)}
|
||||
<View className='w-28'>
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
type QueryFunction,
|
||||
@@ -8,9 +6,11 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -20,6 +20,7 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
type HorizontalScrollRef,
|
||||
} from "../common/HorrizontalScroll";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -41,24 +46,28 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["episodes", seasonId],
|
||||
queryKey: ["episodes", seasonId, isOffline],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (isOffline) {
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||
)
|
||||
.map((f) => f.item);
|
||||
}
|
||||
if (!api || !user?.Id || !item?.SeriesId) return [];
|
||||
const response = await getTvShowsApi(api).getEpisodes({
|
||||
userId: user.Id,
|
||||
seasonId: seasonId || undefined,
|
||||
seriesId: item.SeriesId,
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
"Overview",
|
||||
],
|
||||
});
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
@@ -123,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
loading={loading || isLoading || isFetching}
|
||||
renderItem={(_item, idx) => (
|
||||
renderItem={(_item, _idx) => (
|
||||
<TouchableOpacity
|
||||
key={_item.Id}
|
||||
onPress={() => {
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
@@ -29,7 +7,7 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, {
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
@@ -42,27 +20,48 @@ import React, {
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
type SharedValue,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -91,6 +90,7 @@ interface Props {
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
isVlc?: boolean;
|
||||
downloadedItem?: DownloadedItem | null;
|
||||
}
|
||||
|
||||
const CONTROLS_TIMEOUT = 4000;
|
||||
@@ -119,8 +119,8 @@ export const Controls: FC<Props> = ({
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
downloadedItem = null,
|
||||
}) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -134,13 +134,16 @@ export const Controls: FC<Props> = ({
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { previousItem, nextItem } = useAdjacentItems({
|
||||
item,
|
||||
isOffline: offline,
|
||||
});
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
} = useTrickplay(item, downloadedItem);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
@@ -175,19 +178,21 @@ export const Controls: FC<Props> = ({
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
@@ -195,9 +200,7 @@ export const Controls: FC<Props> = ({
|
||||
if (!item || !settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex)
|
||||
@@ -215,15 +218,18 @@ export const Controls: FC<Props> = ({
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
console.log("queryParams", queryParams);
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
@@ -241,7 +247,10 @@ export const Controls: FC<Props> = ({
|
||||
({
|
||||
isAutoPlay,
|
||||
resetWatchCount,
|
||||
}: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
||||
}: {
|
||||
isAutoPlay?: boolean;
|
||||
resetWatchCount?: boolean;
|
||||
}) => {
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
@@ -303,6 +312,17 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) {
|
||||
return;
|
||||
@@ -392,6 +412,7 @@ export const Controls: FC<Props> = ({
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
console.log("handleSliderChange", progressInTicks);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
@@ -560,8 +581,8 @@ export const Controls: FC<Props> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
>
|
||||
{!Platform.isTV && (
|
||||
<View className='mr-auto'>
|
||||
<View className='mr-auto'>
|
||||
{!Platform.isTV && !offline && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
@@ -571,8 +592,8 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<DropdownView />
|
||||
</VideoProvider>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 '>
|
||||
{!Platform.isTV &&
|
||||
@@ -590,7 +611,7 @@ export const Controls: FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
@@ -638,7 +659,6 @@ export const Controls: FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
type HorizontalScrollRef,
|
||||
} from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -33,12 +36,15 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const { offline } = useGlobalSearchParams<{
|
||||
offline: string;
|
||||
}>();
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -50,23 +56,35 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
// This effect fetches the series item data/
|
||||
useEffect(() => {
|
||||
if (item.SeriesId) {
|
||||
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||
(res) => {
|
||||
setSeriesItem(res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [item.SeriesId]);
|
||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||
const { data: seriesItem } = useItemQuery(item.SeriesId!, isOffline);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.SeriesId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = [
|
||||
...new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
Id: seasonNumber,
|
||||
IndexNumber: seasonNumber,
|
||||
Name: `Season ${seasonNumber}`,
|
||||
SeriesId: item.SeriesId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||
@@ -93,9 +111,19 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
[seasons, seasonIndex],
|
||||
);
|
||||
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f: DownloadedItem) =>
|
||||
f.item.SeriesId === item.SeriesId &&
|
||||
f.item.ParentIndexNumber === seasonIndex,
|
||||
)
|
||||
.map((f: DownloadedItem) => f.item);
|
||||
}
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId || "",
|
||||
@@ -112,7 +140,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.Type === "Episode" && item.Id) {
|
||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||
const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id);
|
||||
if (index !== undefined && index !== -1) {
|
||||
setTimeout(() => {
|
||||
scrollToIndex(index);
|
||||
@@ -163,92 +191,89 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||
>
|
||||
{seriesItem && (
|
||||
<SeasonDropdown
|
||||
item={seriesItem}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
close();
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||
>
|
||||
{seriesItem && (
|
||||
<SeasonDropdown
|
||||
item={seriesItem}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
close();
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, _idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOffline && (
|
||||
<View className='self-start mt-2'>
|
||||
<DownloadSingleItem item={_item} />
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={5}
|
||||
className='text-xs text-neutral-500 shrink'
|
||||
>
|
||||
{_item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
|
||||
{_item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
item?: BaseItemDto | null;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
export const useAdjacentItems = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
}: AdjacentEpisodesProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!api || !item || !item.SeriesId) {
|
||||
if (!item || !item.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
if (!downloadedFiles) return null;
|
||||
const seriesEpisodes = downloadedFiles
|
||||
.filter((f) => f.item.SeriesId === item.SeriesId)
|
||||
.map((f) => f.item);
|
||||
|
||||
seriesEpisodes.sort((a, b) => {
|
||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
||||
return (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0);
|
||||
}
|
||||
return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0);
|
||||
});
|
||||
|
||||
const currentIndex = seriesEpisodes.findIndex(
|
||||
(ep) => ep.Id === item.Id,
|
||||
);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: BaseItemDto[] = [];
|
||||
if (currentIndex > 0) {
|
||||
result.push(seriesEpisodes[currentIndex - 1]);
|
||||
}
|
||||
result.push(seriesEpisodes[currentIndex]);
|
||||
if (currentIndex < seriesEpisodes.length - 1) {
|
||||
result.push(seriesEpisodes[currentIndex + 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,7 +71,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
return res.data.Items || null;
|
||||
},
|
||||
enabled:
|
||||
!!api &&
|
||||
(isOffline || !!api) &&
|
||||
!!item?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useCreditSkipper = (
|
||||
itemId: string | undefined,
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (time: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
@@ -43,50 +26,28 @@ export const useCreditSkipper = (
|
||||
seek(seconds);
|
||||
};
|
||||
|
||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||
queryKey: ["creditTimestamps", itemId],
|
||||
queryFn: async () => {
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await api?.axiosInstance.get(
|
||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res?.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
retry: false,
|
||||
});
|
||||
const { data: segments } = useSegments(itemId, isOffline);
|
||||
const creditTimestamps = segments?.creditSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
setShowSkipCreditButton(
|
||||
currentTime > creditTimestamps.Credits.Start &&
|
||||
currentTime < creditTimestamps.Credits.End,
|
||||
currentTime > creditTimestamps.startTime &&
|
||||
currentTime < creditTimestamps.endTime,
|
||||
);
|
||||
}
|
||||
}, [creditTimestamps, currentTime]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(creditTimestamps.Credits.End);
|
||||
wrappedSeek(creditTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
console.error("Error skipping credit", error);
|
||||
}
|
||||
}, [creditTimestamps]);
|
||||
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
throw new Error("Document directory is not available");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
throw new Error("Item ID is not available");
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(directory);
|
||||
const path = itemId!;
|
||||
const matchingFile = files.find((file) => file.startsWith(path));
|
||||
|
||||
if (!matchingFile) {
|
||||
throw new Error(`No file found for item ${path}`);
|
||||
}
|
||||
|
||||
return `${directory}${matchingFile}`;
|
||||
};
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
@@ -33,9 +10,19 @@ export const useDownloadedFileOpener = () => {
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
// @ts-expect-error
|
||||
router.push(`/player/direct-player?offline=true&itemId=${item.Id}`);
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
IntroStart: number;
|
||||
ShowSkipPromptAt: number;
|
||||
Valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in seconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string | undefined,
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ticks: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
@@ -43,35 +30,14 @@ export const useIntroSkipper = (
|
||||
seek(seconds);
|
||||
};
|
||||
|
||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||
queryKey: ["introTimestamps", itemId],
|
||||
queryFn: async () => {
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await api?.axiosInstance.get(
|
||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res?.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
retry: false,
|
||||
});
|
||||
const { data: segments } = useSegments(itemId, isOffline);
|
||||
const introTimestamps = segments?.introSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
setShowSkipButton(
|
||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
||||
currentTime < introTimestamps.HideSkipPromptAt,
|
||||
currentTime > introTimestamps.startTime &&
|
||||
currentTime < introTimestamps.endTime,
|
||||
);
|
||||
}
|
||||
}, [introTimestamps, currentTime]);
|
||||
@@ -80,12 +46,12 @@ export const useIntroSkipper = (
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(introTimestamps.IntroEnd);
|
||||
wrappedSeek(introTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
console.error("Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps]);
|
||||
|
||||
|
||||
34
hooks/useItemQuery.ts
Normal file
34
hooks/useItemQuery.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
const downloadedItem = downloadedFiles?.find(
|
||||
(item) => item.item.Id === itemId,
|
||||
);
|
||||
if (downloadedItem) return downloadedItem.item;
|
||||
return null;
|
||||
}
|
||||
if (!api || !user || !itemId) return null;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
};
|
||||
@@ -1,102 +1,50 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { togglePlayState } from "@/utils/jellyfin/playstate/togglePlayState";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
|
||||
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[], isOffline = false) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const invalidateQueries = () => {
|
||||
const queriesToInvalidate = [
|
||||
["resumeItems"],
|
||||
["continueWatching"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["episodes"],
|
||||
["seasons"],
|
||||
["home"],
|
||||
];
|
||||
|
||||
const downloads = useDownload();
|
||||
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const invalidateQueries = async () => {
|
||||
const queriesToInvalidate: QueryKey[] = [];
|
||||
items.forEach((item) => {
|
||||
if (!item.Id) return;
|
||||
queriesToInvalidate.push(["item", item.Id]);
|
||||
});
|
||||
|
||||
queriesToInvalidate.forEach((queryKey) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
await Promise.all(
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = async (played: boolean) => {
|
||||
const toggle = async (played: boolean) => {
|
||||
lightHapticFeedback();
|
||||
|
||||
items.forEach((item) => {
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) => {
|
||||
if (oldData) {
|
||||
return {
|
||||
...oldData,
|
||||
UserData: {
|
||||
...oldData.UserData,
|
||||
Played: played,
|
||||
},
|
||||
};
|
||||
}
|
||||
return oldData;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
played
|
||||
? markAsPlayed({ api, item, userId: user?.Id })
|
||||
: markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id }),
|
||||
),
|
||||
);
|
||||
|
||||
// Bulk invalidate
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"resumeItems",
|
||||
"continueWatching",
|
||||
"nextUp-all",
|
||||
"nextUp",
|
||||
"episodes",
|
||||
"seasons",
|
||||
"home",
|
||||
...items.map((item) => ["item", item.Id]),
|
||||
].flat(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Revert all optimistic updates on any failure
|
||||
items.forEach((item) => {
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
UserData: { ...oldData.UserData, Played: played },
|
||||
}
|
||||
: oldData,
|
||||
);
|
||||
});
|
||||
console.error("Error updating played status:", error);
|
||||
}
|
||||
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
togglePlayState({
|
||||
api,
|
||||
item,
|
||||
userId: user?.Id,
|
||||
isOffline,
|
||||
downloads,
|
||||
played,
|
||||
}),
|
||||
),
|
||||
);
|
||||
invalidatePlaybackProgressCache();
|
||||
invalidateQueries();
|
||||
};
|
||||
|
||||
return markAsPlayedStatus;
|
||||
return toggle;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useTwoWaySync } from "./useTwoWaySync";
|
||||
|
||||
/**
|
||||
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
||||
*/
|
||||
export function useInvalidatePlaybackProgressCache() {
|
||||
const queryClient = useQueryClient();
|
||||
const { downloadedFiles } = useDownload();
|
||||
const { syncPlaybackState } = useTwoWaySync();
|
||||
|
||||
const revalidate = async () => {
|
||||
// List of all the queries to invalidate
|
||||
@@ -17,11 +21,33 @@ export function useInvalidatePlaybackProgressCache() {
|
||||
["episodes"],
|
||||
["seasons"],
|
||||
["home"],
|
||||
["downloadedItems"],
|
||||
];
|
||||
|
||||
// Invalidate each query
|
||||
for (const queryKey of queriesToInvalidate) {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
// We Invalidate all the queries to the latest server versions
|
||||
await Promise.all(
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync playback state for downloaded items
|
||||
if (downloadedFiles) {
|
||||
// We sync the playback state for the downloaded items
|
||||
const syncResults = await Promise.all(
|
||||
downloadedFiles.map((downloadedItem) =>
|
||||
syncPlaybackState(downloadedItem.item.Id!),
|
||||
),
|
||||
);
|
||||
// We invalidate the queries again in case we have updated a server's playback progress.
|
||||
const shouldInvalidate = syncResults.some((result) => result);
|
||||
|
||||
console.log("shouldInvalidate", shouldInvalidate);
|
||||
if (shouldInvalidate) {
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
Height?: number;
|
||||
Width?: number;
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
}
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import {
|
||||
calculateTrickplayTile,
|
||||
generateTrickplayUrl,
|
||||
getTrickplayInfo,
|
||||
} from "@/utils/trickplay";
|
||||
|
||||
interface TrickplayUrl {
|
||||
x: number;
|
||||
@@ -26,129 +14,72 @@ interface TrickplayUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
export const useTrickplay = (
|
||||
item: BaseItemDto,
|
||||
downloadedItem?: DownloadedItem | null,
|
||||
) => {
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200; // 200ms throttle
|
||||
const throttleDelay = 200;
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
if (!enabled || !item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
return getTrickplayInfo(item);
|
||||
}, [item]);
|
||||
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayData = item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first available resolution
|
||||
const firstResolution = Object.keys(trickplayData)[0];
|
||||
return firstResolution
|
||||
? {
|
||||
resolution: firstResolution,
|
||||
aspectRatio:
|
||||
trickplayData[firstResolution].Width! /
|
||||
trickplayData[firstResolution].Height!,
|
||||
data: trickplayData[firstResolution],
|
||||
}
|
||||
: null;
|
||||
}, [item, enabled]);
|
||||
|
||||
// Takes in ticks.
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastCalculationTime.current < throttleDelay) {
|
||||
return null;
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastCalculationTime.current = now;
|
||||
|
||||
if (!trickplayInfo || !api || !item.Id) {
|
||||
return null;
|
||||
const { sheetIndex, x, y } = calculateTrickplayTile(
|
||||
progress,
|
||||
trickplayInfo,
|
||||
);
|
||||
|
||||
const url = generateTrickplayUrl(
|
||||
item.Id,
|
||||
trickplayInfo.resolution,
|
||||
sheetIndex,
|
||||
downloadedItem?.trickPlayData?.path,
|
||||
);
|
||||
|
||||
if (url) {
|
||||
setTrickPlayUrl({ x, y, url });
|
||||
}
|
||||
|
||||
const { data, resolution } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!resolution ||
|
||||
!Width ||
|
||||
!Height
|
||||
) {
|
||||
throw new Error("Invalid trickplay data");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progress));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tileSize = TileWidth * TileHeight;
|
||||
const tileOffset = currentTile % tileSize;
|
||||
const index = Math.floor(currentTile / tileSize);
|
||||
|
||||
const tileOffsetX = tileOffset % TileWidth;
|
||||
const tileOffsetY = Math.floor(tileOffset / TileWidth);
|
||||
|
||||
const newTrickPlayUrl = {
|
||||
x: tileOffsetX,
|
||||
y: tileOffsetY,
|
||||
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
|
||||
};
|
||||
|
||||
setTrickPlayUrl(newTrickPlayUrl);
|
||||
return newTrickPlayUrl;
|
||||
},
|
||||
[trickplayInfo, item, api, enabled],
|
||||
[trickplayInfo, item, throttleDelay, downloadedItem],
|
||||
);
|
||||
|
||||
const prefetchAllTrickplayImages = useCallback(() => {
|
||||
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
|
||||
if (!trickplayInfo || !item.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, resolution } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(
|
||||
item.Id,
|
||||
trickplayInfo.resolution,
|
||||
index,
|
||||
downloadedItem?.trickPlayData?.path,
|
||||
);
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!resolution ||
|
||||
!Width ||
|
||||
!Height
|
||||
) {
|
||||
throw new Error("Invalid trickplay data");
|
||||
if (url) {
|
||||
Image.prefetch(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate tiles per sheet
|
||||
const tilesPerRow = TileWidth;
|
||||
const tilesPerColumn = TileHeight;
|
||||
const tilesPerSheet = tilesPerRow * tilesPerColumn;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
// Prefetch all trickplay images
|
||||
for (let index = 0; index < totalIndexes; index++) {
|
||||
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
|
||||
Image.prefetch(url);
|
||||
}
|
||||
}, [trickplayInfo, item, api, enabled]);
|
||||
}, [trickplayInfo, item, downloadedItem]);
|
||||
|
||||
return {
|
||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
||||
prefetchAllTrickplayImages: enabled
|
||||
? prefetchAllTrickplayImages
|
||||
: () => null,
|
||||
trickplayInfo: enabled ? trickplayInfo : null,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
prefetchAllTrickplayImages,
|
||||
trickplayInfo,
|
||||
};
|
||||
};
|
||||
|
||||
140
hooks/useTwoWaySync.ts
Normal file
140
hooks/useTwoWaySync.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { apiAtom, userAtom } from "../providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* This hook is used to sync the playback state of a downloaded item with the server.
|
||||
* It ensures that the playback state of a downloaded item is always up to date.
|
||||
*
|
||||
* The syncPlaybackState function returns a Promise<boolean> indicating whether a server update was made (true) or not (false).
|
||||
*/
|
||||
export const useTwoWaySync = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [_settings] = useSettings();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
|
||||
const syncPlaybackState = async (itemId: string) => {
|
||||
if (!api || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
if (!localItem) return;
|
||||
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({ itemId, userId: user.Id })
|
||||
).data;
|
||||
if (!remoteItem) return;
|
||||
|
||||
const localLastPlayed = localItem.item.UserData?.LastPlayedDate
|
||||
? new Date(localItem.item.UserData.LastPlayedDate)
|
||||
: new Date(0);
|
||||
const remoteLastPlayed = remoteItem.UserData?.LastPlayedDate
|
||||
? new Date(remoteItem.UserData.LastPlayedDate)
|
||||
: new Date(0);
|
||||
|
||||
console.log("localItem", localItem.item.Name);
|
||||
console.log("localLastPlayed", localLastPlayed);
|
||||
console.log("remoteLastPlayed", remoteLastPlayed);
|
||||
|
||||
// Update our local item if the remote has been played more recently.
|
||||
// Another edge case is when the remote item not been played. We want to sync that with the se
|
||||
// If they're same we fall back to the server as the single source of truth.
|
||||
if (remoteLastPlayed >= localLastPlayed) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
LastPlayedDate: remoteItem.UserData?.LastPlayedDate,
|
||||
PlaybackPositionTicks: remoteItem.UserData?.PlaybackPositionTicks,
|
||||
Played: remoteItem.UserData?.Played,
|
||||
PlayedPercentage: remoteItem.UserData?.PlayedPercentage,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log("syncing local to remote", localItem.item.Name);
|
||||
await syncLocalToRemote(localItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to sync the playback state of a downloaded item with the server.
|
||||
*/
|
||||
const syncLocalToRemote = async (localItem: DownloadedItem) => {
|
||||
if (!api || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the local Item is marked as completed, we mark it as played on the server.
|
||||
// Report the playback progress to the server.
|
||||
|
||||
console.log(
|
||||
"playback position ticks",
|
||||
localItem.item.UserData?.PlaybackPositionTicks,
|
||||
);
|
||||
|
||||
// If the local item is marked as played, we mark it as played on the server.
|
||||
if (localItem.item.UserData?.Played) {
|
||||
await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: localItem.item.Id!,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// If the local item is marked as unplayed, we mark it as unplayed on the server.
|
||||
await getPlaystateApi(api).markUnplayedItem({
|
||||
itemId: localItem.item.Id!,
|
||||
});
|
||||
}
|
||||
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: localItem.item.Id,
|
||||
PositionTicks: localItem.item.UserData?.PlaybackPositionTicks,
|
||||
} as PlaybackProgressInfo,
|
||||
});
|
||||
// Pull back whatever the server gives us we use that as the single source of truth.
|
||||
// We do this because sometimes, the server marks things as played when the posistion ticks marks a certain point.
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({
|
||||
itemId: localItem.item.Id!,
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
if (!remoteItem) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
"remote item after synced played status",
|
||||
remoteItem.UserData?.Played,
|
||||
);
|
||||
console.log("remote last played date", remoteItem.UserData?.LastPlayedDate);
|
||||
|
||||
updateDownloadedItem(localItem.item.Id!, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...remoteItem.UserData,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localItem2 = getDownloadedItemById(localItem.item.Id!);
|
||||
|
||||
console.log("localItem2", localItem2?.item.UserData?.LastPlayedDate);
|
||||
};
|
||||
|
||||
return { syncPlaybackState, syncLocalToRemote };
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
75
providers/Downloads/types.ts
Normal file
75
providers/Downloads/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/**
|
||||
* Represents the data for downloaded trickplay files.
|
||||
*/
|
||||
export interface TrickPlayData {
|
||||
/** The local directory path where trickplay image sheets are stored. */
|
||||
path: string;
|
||||
/** The total size of all trickplay images in bytes. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the user data for a downloaded item.
|
||||
*/
|
||||
interface UserData {
|
||||
subtitleStreamIndex: number;
|
||||
/** The last known audio stream index. */
|
||||
audioStreamIndex: number;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The full Jellyfin item object. */
|
||||
item: BaseItemDto;
|
||||
/** The local file path to the downloaded video file. The path is already prefixed with file:// */
|
||||
videoFilePath: string;
|
||||
/** The size of the downloaded video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The data for the associated trickplay files. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/**
|
||||
* The specific media source that was downloaded.
|
||||
* Contains info on available audio/subtitle tracks within the file.
|
||||
*/
|
||||
mediaSource: MediaSourceInfo;
|
||||
/** The intro segments for the item. */
|
||||
introSegments?: MediaTimeSegments[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegments[];
|
||||
/** The user data for the item. */
|
||||
userData?: UserData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a downloaded Season, containing a map of its episodes.
|
||||
*/
|
||||
export interface DownloadedSeason {
|
||||
/** A map of episode numbers to their downloaded item data. */
|
||||
episodes: Record<number, DownloadedItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a downloaded series, containing seasons and their episodes.
|
||||
*/
|
||||
export interface DownloadedSeries {
|
||||
/** The Jellyfin item object for the series itself. */
|
||||
seriesInfo: BaseItemDto;
|
||||
/** A map of season numbers to their downloaded season data. */
|
||||
seasons: Record<number, DownloadedSeason>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main structure for all downloaded content stored locally.
|
||||
* This object is what will be saved to your local storage.
|
||||
*/
|
||||
export interface DownloadsDatabase {
|
||||
/** A map of movie IDs to their downloaded item data. */
|
||||
movies: Record<string, DownloadedItem>;
|
||||
/** A map of series IDs to their downloaded series data. */
|
||||
series: Record<string, DownloadedSeries>;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { processesAtom } from "@/providers/DownloadProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { processesAtom } from "@/providers/DownloadProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@@ -68,5 +68,5 @@ export const useJobProcessor = () => {
|
||||
console.info("Processing queue", queue);
|
||||
queueActions.processJob(queue, setQueue, setRunning);
|
||||
}
|
||||
}, [processes, queue, running, setQueue, setRunning]);
|
||||
}, [processes, queue, running, setQueue, setRunning, settings]);
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
interface MarkAsNotPlayedParams {
|
||||
api: Api | null | undefined;
|
||||
itemId: string | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as not played for a specific user.
|
||||
*
|
||||
* @param params - The parameters for marking an item as not played
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const markAsNotPlayed = async ({
|
||||
api,
|
||||
itemId,
|
||||
userId,
|
||||
}: MarkAsNotPlayedParams): Promise<void> => {
|
||||
if (!api || !itemId || !userId) {
|
||||
console.error("Invalid parameters for markAsNotPlayed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.axiosInstance.delete(
|
||||
`${api.basePath}/UserPlayedItems/${itemId}`,
|
||||
{
|
||||
params: { userId },
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
console.error(
|
||||
"Failed to mark item as not played:",
|
||||
axiosError.message,
|
||||
axiosError.response?.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
interface MarkAsPlayedParams {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as played and updates its progress to completion.
|
||||
*
|
||||
* @param params - The parameters for marking an item as played∏
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const markAsPlayed = async ({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
}: MarkAsPlayedParams): Promise<boolean> => {
|
||||
if (!api || !item?.Id || !userId || !item.RunTimeTicks) {
|
||||
console.error("Invalid parameters for markAsPlayed");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: item.Id,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
99
utils/jellyfin/playstate/togglePlayState.ts
Normal file
99
utils/jellyfin/playstate/togglePlayState.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type { AxiosError } from "axios";
|
||||
import type { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
interface TogglePlayStateParams {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
downloads: ReturnType<typeof useDownload>;
|
||||
isOffline?: boolean;
|
||||
played: boolean;
|
||||
}
|
||||
|
||||
export const togglePlayState = async ({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
downloads,
|
||||
isOffline = false,
|
||||
played,
|
||||
}: TogglePlayStateParams): Promise<boolean> => {
|
||||
if (!item?.Id) {
|
||||
console.error("Invalid item for togglePlayState");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
const downloadedItem = downloads.getDownloadedItemById(item.Id);
|
||||
if (downloadedItem) {
|
||||
const newUserData = played
|
||||
? {
|
||||
Played: true,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlaybackPositionTicks: 0,
|
||||
}
|
||||
: {
|
||||
Played: false,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
};
|
||||
|
||||
downloads.updateDownloadedItem(item.Id, {
|
||||
...downloadedItem,
|
||||
item: {
|
||||
...downloadedItem.item,
|
||||
UserData: {
|
||||
...downloadedItem.item.UserData,
|
||||
...newUserData,
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!api || !userId) {
|
||||
console.error("Invalid parameters for online togglePlayState");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (played) {
|
||||
if (!item.RunTimeTicks) {
|
||||
console.error(
|
||||
"Invalid parameters for markAsPlayed (RunTimeTicks missing)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const response = await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: item.Id,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
return response.status === 200;
|
||||
} else {
|
||||
await api.axiosInstance.delete(
|
||||
`${api.basePath}/UserPlayedItems/${item.Id}`,
|
||||
{
|
||||
params: { userId },
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
console.error(
|
||||
`Failed to toggle play state to ${played}:`,
|
||||
axiosError.message,
|
||||
axiosError.response?.status,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
||||
import type {
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import axios from "axios";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { writeToLog } from "./log";
|
||||
|
||||
interface IJobInput {
|
||||
@@ -14,25 +13,22 @@ interface IJobInput {
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface JobStatus {
|
||||
export type JobStatus = {
|
||||
id: string;
|
||||
status:
|
||||
| "queued"
|
||||
| "optimizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "downloading";
|
||||
progress: number;
|
||||
outputPath: string;
|
||||
inputUrl: string;
|
||||
deviceId: string;
|
||||
itemId: string;
|
||||
item: BaseItemDto;
|
||||
speed?: number;
|
||||
itemId: string;
|
||||
deviceId: string;
|
||||
progress: number;
|
||||
status: "downloading" | "paused" | "error" | "pending";
|
||||
timestamp: Date;
|
||||
base64Image?: string;
|
||||
}
|
||||
mediaSource: MediaSourceInfo;
|
||||
maxBitrate: Bitrate;
|
||||
bytesDownloaded?: number;
|
||||
lastProgressUpdateTime?: Date;
|
||||
speed?: number;
|
||||
estimatedTotalSizeBytes?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all jobs for a specific device.
|
||||
|
||||
@@ -59,80 +59,55 @@ export default {
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official foramts
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
|
||||
{ Format: "webvtt", Method: "Embed" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "Encode" },
|
||||
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "Encode" },
|
||||
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "Encode" },
|
||||
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "Encode" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "Encode" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "Encode" },
|
||||
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "Encode" },
|
||||
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "Encode" },
|
||||
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "Encode" },
|
||||
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "Encode" },
|
||||
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "Encode" },
|
||||
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "Encode" },
|
||||
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "Encode" },
|
||||
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "Encode" },
|
||||
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "Encode" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "Encode" },
|
||||
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "Encode" },
|
||||
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
|
||||
114
utils/segments.ts
Normal file
114
utils/segments.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
IntroStart: number;
|
||||
ShowSkipPromptAt: number;
|
||||
Valid: boolean;
|
||||
}
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useSegments = (itemId: string, isOffline: boolean) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
const downloadedItem = downloadedFiles?.find(
|
||||
(d: DownloadedItem) => d.item.Id === itemId,
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["segments", itemId, isOffline],
|
||||
queryFn: async () => {
|
||||
if (isOffline && downloadedItem) {
|
||||
return getSegmentsForItem(downloadedItem);
|
||||
}
|
||||
if (!api) {
|
||||
throw new Error("API client is not available");
|
||||
}
|
||||
return fetchAndParseSegments(itemId, api);
|
||||
},
|
||||
enabled: !!api,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSegmentsForItem = (
|
||||
item: DownloadedItem,
|
||||
): {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} => {
|
||||
return {
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
|
||||
try {
|
||||
const [introRes, creditRes] = await Promise.allSettled([
|
||||
api.axiosInstance.get<IntroTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
),
|
||||
api.axiosInstance.get<CreditTimestamps>(
|
||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||
introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
creditRes.status === "fulfilled" &&
|
||||
creditRes.value.data.Credits.Valid
|
||||
) {
|
||||
creditSegments.push({
|
||||
startTime: creditRes.value.data.Credits.Start,
|
||||
endTime: creditRes.value.data.Credits.End,
|
||||
text: "Credits",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch segments", error);
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
};
|
||||
122
utils/trickplay.ts
Normal file
122
utils/trickplay.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "./store";
|
||||
import { ticksToMs } from "./time";
|
||||
|
||||
export interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
Height?: number;
|
||||
Width?: number;
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
export interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
totalImageSheets: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the trickplay metadata from a BaseItemDto.
|
||||
* @param item The Jellyfin media item.
|
||||
* @returns Parsed trickplay information or null if not available.
|
||||
*/
|
||||
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
|
||||
if (!item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayDataForSource = item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayDataForSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstResolution = Object.keys(trickplayDataForSource)[0];
|
||||
if (!firstResolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trickplayDataForSource[firstResolution];
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!Width ||
|
||||
!Height ||
|
||||
!item.RunTimeTicks
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
return {
|
||||
resolution: firstResolution,
|
||||
aspectRatio: Width / Height,
|
||||
data,
|
||||
totalImageSheets,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the specific image sheet and tile offset for a given time.
|
||||
* @param progressTicks The current playback time in ticks.
|
||||
* @param trickplayInfo The parsed trickplay information object.
|
||||
* @returns An object with the image sheet index, and the X/Y coordinates for the tile.
|
||||
*/
|
||||
export const calculateTrickplayTile = (
|
||||
progressTicks: number,
|
||||
trickplayInfo: TrickplayInfo,
|
||||
) => {
|
||||
const { data } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight } = data;
|
||||
|
||||
if (!Interval || !TileWidth || !TileHeight) {
|
||||
throw new Error("Invalid trickplay data provided to calculateTile");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progressTicks));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const sheetIndex = Math.floor(currentTile / tilesPerSheet);
|
||||
const tileIndexInSheet = currentTile % tilesPerSheet;
|
||||
|
||||
const x = tileIndexInSheet % TileWidth;
|
||||
const y = Math.floor(tileIndexInSheet / TileWidth);
|
||||
|
||||
return { sheetIndex, x, y };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a trickplay URL based on the item ID, resolution, sheet index, and offline path.
|
||||
* @param itemId The ID of the media item.
|
||||
* @param resolution The resolution of the trickplay image.
|
||||
* @param sheetIndex The index of the image sheet.
|
||||
* @param offlinePath The path to the offline trickplay images.
|
||||
* @returns The URL of the trickplay image.
|
||||
*/
|
||||
export const generateTrickplayUrl = (
|
||||
itemId: string,
|
||||
resolution: string,
|
||||
sheetIndex: number,
|
||||
offlinePath?: string,
|
||||
): string | null => {
|
||||
if (offlinePath) {
|
||||
return `${offlinePath}${sheetIndex}.jpg`;
|
||||
}
|
||||
const api = store.get(apiAtom);
|
||||
if (api) {
|
||||
return `${api.basePath}/Videos/${itemId}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user