This commit is contained in:
Fredrik Burmester
2024-08-02 16:01:09 +02:00
parent 634f28ac28
commit 7e9ccc38e6
7 changed files with 242 additions and 150 deletions

View File

@@ -27,8 +27,6 @@ export default function index() {
return [];
}
console.log("[2] Items");
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
});

View File

@@ -10,157 +10,171 @@ import { useCallback, useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { router } from "expo-router";
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ProcessItem, runningProcesses } from "@/utils/atoms/downloads";
type DownloadProps = {
item: BaseItemDto;
url: string;
};
type ProcessItem = {
item: BaseItemDto;
progress: number;
};
// const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
// if (!item.Id || !item.Name) {
// writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", {
// item,
// inputUrl,
// });
// throw new Error("Item must have an Id and Name");
// }
export const runningProcesses = atom<ProcessItem | null>(null);
// const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses);
const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", {
item,
inputUrl,
});
throw new Error("Item must have an Id and Name");
}
// const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses);
// const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`;
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
// const startRemuxing = useCallback(async () => {
// if (!item.Id || !item.Name) {
// writeToLog(
// "ERROR",
// "useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments",
// {
// item,
// inputUrl,
// }
// );
// throw new Error("Item must have an Id and Name");
// }
const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`;
// writeToLog(
// "INFO",
// `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`,
// {
// item,
// inputUrl,
// }
// );
const startRemuxing = useCallback(async () => {
if (!item.Id || !item.Name) {
writeToLog(
"ERROR",
"useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments",
{
item,
inputUrl,
}
);
throw new Error("Item must have an Id and Name");
}
// try {
// setSession({
// item,
// progress: 0,
// });
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`,
{
item,
inputUrl,
}
);
// FFmpegKitConfig.enableStatisticsCallback((statistics) => {
// let percentage = 0;
try {
setSession({
item,
progress: 0,
});
// const videoLength =
// (item.MediaSources?.[0].RunTimeTicks || 0) / 10000000; // In seconds
// const fps = item.MediaStreams?.[0].RealFrameRate || 25;
// const totalFrames = videoLength * fps;
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
let percentage = 0;
// const processedFrames = statistics.getVideoFrameNumber();
const videoLength =
(item.MediaSources?.[0].RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0].RealFrameRate || 25;
const totalFrames = videoLength * fps;
// if (totalFrames > 0) {
// percentage = Math.floor((processedFrames / totalFrames) * 100);
// }
const processedFrames = statistics.getVideoFrameNumber();
// setSession((prev) => {
// return prev?.item.Id === item.Id!
// ? { ...prev, progress: percentage }
// : prev;
// });
// });
if (totalFrames > 0) {
percentage = Math.floor((processedFrames / totalFrames) * 100);
}
// await FFmpegKit.executeAsync(command, async (session) => {
// const returnCode = await session.getReturnCode();
// if (returnCode.isValueSuccess()) {
// const currentFiles: BaseItemDto[] = JSON.parse(
// (await AsyncStorage.getItem("downloaded_files")) || "[]"
// );
setSession((prev) => {
return prev?.item.Id === item.Id!
? { ...prev, progress: percentage }
: prev;
});
});
// const otherItems = currentFiles.filter((i) => i.Id !== item.Id);
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
// await AsyncStorage.setItem(
// "downloaded_files",
// JSON.stringify([...otherItems, item])
// );
const otherItems = currentFiles.filter((i) => i.Id !== item.Id);
// writeToLog(
// "INFO",
// `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// } else if (returnCode.isValueError()) {
// console.error("Failed to remux:");
// writeToLog(
// "ERROR",
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// } else if (returnCode.isValueCancel()) {
// console.log("Remuxing was cancelled");
// writeToLog(
// "INFO",
// `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// }
// });
// } catch (error) {
// console.error("Failed to remux:", error);
// writeToLog(
// "ERROR",
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// }
// }, [inputUrl, output, item, command]);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify([...otherItems, item])
);
// const cancelRemuxing = useCallback(async () => {
// FFmpegKit.cancel();
// setSession(null);
// console.log("Remuxing cancelled");
// }, []);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null);
} else if (returnCode.isValueError()) {
console.error("Failed to remux:");
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null);
} else if (returnCode.isValueCancel()) {
console.log("Remuxing was cancelled");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null);
}
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
{
item,
inputUrl,
}
);
}
}, [inputUrl, output, item, command]);
const cancelRemuxing = useCallback(async () => {
FFmpegKit.cancel();
setSession(null);
console.log("Remuxing cancelled");
}, []);
return { session, startRemuxing, cancelRemuxing };
};
// return { session, startRemuxing, cancelRemuxing };
// };
export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(
url,
item
);
// const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(
// url,
// item
// );
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const { downloadMedia, isDownloading, error } = useDownloadMedia(api);
const downloadFile = useCallback(async () => {
const playbackInfo = await getPlaybackInfo(api, item.Id, user?.Id);
const source = playbackInfo?.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
console.log("file not supported");
}
}, [item, user]);
const [downloaded, setDownloaded] = useState<boolean>(false);
const [key, setKey] = useState<string>("");
@@ -175,7 +189,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
})();
}, [key]);
if (session && session.item.Id !== item.Id!) {
if (process && process.item.Id !== item.Id!) {
return (
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
@@ -185,16 +199,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
return (
<View>
{session ? (
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
// cancelRemuxing();
}}
className="-rotate-45"
>
<ProgressCircle
size={22}
fill={session.progress}
fill={process.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
@@ -213,7 +227,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
) : (
<TouchableOpacity
onPress={() => {
startRemuxing();
downloadFile();
}}
>
<Ionicons name="cloud-download-outline" size={28} color="white" />

View File

@@ -12,8 +12,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
console.log("PlayedStatus", item.UserData);
const queryClient = useQueryClient();
return (

View File

@@ -10,8 +10,10 @@ import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
OnProgressData,
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
@@ -146,8 +148,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
// console.log("Seek to time: ", seekTime);
};
const onError = (error: any) => {
// console.log("Video Error: ", error);
const onError = (error: OnVideoErrorData) => {
console.log("Video Error: ", JSON.stringify(error.error));
};
const onBuffer = (error: OnBufferData) => {
console.log("Video buffering: ", error.isBuffering);
};
const play = () => {
@@ -187,6 +193,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
startPosition,
}}
ref={videoRef}
onBuffer={onBuffer}
onSeek={(t) => onSeek(t)}
onError={onError}
onProgress={(e) => onProgress(e)}

View File

@@ -106,7 +106,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
setUser(null);
setApi(null);
await AsyncStorage.removeItem("token");
},
onError: (error) => {
@@ -124,18 +123,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
(await AsyncStorage.getItem("user")) as string
) as UserDto;
console.log({
token,
serverUrl,
user,
});
if (serverUrl && token && user.Id) {
console.log("[0] Setting api");
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
setUser(user);
console.log(apiInstance.accessToken);
}
return true;

9
utils/atoms/downloads.ts Normal file
View File

@@ -0,0 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
};
export const runningProcesses = atom<ProcessItem | null>(null);

View File

@@ -3,9 +3,86 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getMediaInfoApi,
getUserLibraryApi,
getPlaystateApi,
} from "@jellyfin/sdk/lib/utils/api";
import { iosProfile } from "./device-profiles";
import * as FileSystem from "expo-file-system";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useAtom } from "jotai";
import { runningProcesses } from "./atoms/downloads";
import { useCallback, useState } from "react";
export const useDownloadMedia = (api: Api | null) => {
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useAtom(runningProcesses);
const downloadMedia = useCallback(
async (item: BaseItemDto | null) => {
if (!item?.Id || !api) {
setError("Invalid item or API");
return false;
}
setIsDownloading(true);
setError(null);
const itemId = item.Id;
try {
const filename = `${itemId}.mp4`;
const fileUri = `${FileSystem.documentDirectory}${filename}`;
const downloadResumable = FileSystem.createDownloadResumable(
`${api.basePath}/Items/${itemId}/Download`,
fileUri,
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
(downloadProgress) => {
const currentProgress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
console.log(`Download progress: ${currentProgress * 100}%`);
setProgress({
item,
progress: currentProgress * 100,
});
}
);
const res = await downloadResumable.downloadAsync();
const uri = res?.uri;
console.log("File downloaded to:", uri);
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
const otherItems = currentFiles.filter((i) => i.Id !== itemId);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify([...otherItems, item])
);
setIsDownloading(false);
return true;
} catch (error) {
console.error("Error downloading media:", error);
setError("Failed to download media");
setIsDownloading(false);
return false;
}
},
[api, setProgress]
);
return { downloadMedia, isDownloading, error };
};
export const markAsNotPlayed = async ({
api,
@@ -126,8 +203,6 @@ export const nextUp = async ({
}
);
console.log(response.data);
return response?.data.Items as BaseItemDto[];
} catch (error) {
const e = error as any;
@@ -195,7 +270,7 @@ export const reportPlaybackProgress = async ({
}
try {
const response = await api.axiosInstance.post(
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,