diff --git a/app/(auth)/(tabs)/index.tsx b/app/(auth)/(tabs)/index.tsx index 531fb35f..7ce65e50 100644 --- a/app/(auth)/(tabs)/index.tsx +++ b/app/(auth)/(tabs)/index.tsx @@ -27,8 +27,6 @@ export default function index() { return []; } - console.log("[2] Items"); - const response = await getItemsApi(api).getResumeItems({ userId: user.Id, }); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 1e82feab..6ce8460b 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -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(null); +// const [session, setSession] = useAtom(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(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 = ({ 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(false); const [key, setKey] = useState(""); @@ -175,7 +189,7 @@ export const DownloadItem: React.FC = ({ url, item }) => { })(); }, [key]); - if (session && session.item.Id !== item.Id!) { + if (process && process.item.Id !== item.Id!) { return ( {}} style={{ opacity: 0.5 }}> @@ -185,16 +199,16 @@ export const DownloadItem: React.FC = ({ url, item }) => { return ( - {session ? ( + {process ? ( { - cancelRemuxing(); + // cancelRemuxing(); }} className="-rotate-45" > = ({ url, item }) => { ) : ( { - startRemuxing(); + downloadFile(); }} > diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index e64d03c0..b527f1d0 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -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 ( diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 6bc64533..d56e7d44 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -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 = ({ 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 = ({ itemId }) => { startPosition, }} ref={videoRef} + onBuffer={onBuffer} onSeek={(t) => onSeek(t)} onError={onError} onProgress={(e) => onProgress(e)} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 340aab1c..6c8af60e 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -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; diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts new file mode 100644 index 00000000..7343ce64 --- /dev/null +++ b/utils/atoms/downloads.ts @@ -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(null); diff --git a/utils/jellyfin.ts b/utils/jellyfin.ts index 9c3e5e06..732d90de 100644 --- a/utils/jellyfin.ts +++ b/utils/jellyfin.ts @@ -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(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,