diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 5430c27d..718a22b6 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -41,17 +41,6 @@ const downloads: React.FC = () => { return Object.values(series); }, [downloadedFiles]); - const eta = useMemo(() => { - const length = process?.item?.RunTimeTicks || 0; - - if (!process?.speed || !process?.progress) return ""; - - const timeLeft = - (length - length * (process.progress / 100)) / process.speed; - - return formatNumber(timeLeft / 10000); - }, [process]); - useEffect(() => { (async () => { const dir = FileSystem.documentDirectory; @@ -171,14 +160,8 @@ const downloads: React.FC = () => { - {process.progress.toFixed(0)}% + {(process.progress * 100).toFixed(0)}% - - {process.speed?.toFixed(2)}x - - - ETA {eta} - = ({ item }) => { const { deleteFile } = useFiles(); - const router = useRouter(); + const { openFile } = useFileOpener(); - const { startDownloadedFilePlayback } = usePlayback(); - - const handleOpenFile = useCallback(async () => { - const url = `${FileSystem.documentDirectory}${item.Id}/0.ts`; - console.log(url); - - const fileInfo = await FileSystem.getInfoAsync(url); - - if (!fileInfo.exists) { - console.warn("m3u8 file does not exist:", url); - } - - startDownloadedFilePlayback({ - item, - url, - }); - router.push("/play"); - }, [item, startDownloadedFilePlayback]); + const handleOpenFile = useCallback(() => { + openFile(item); + }, [item, openFile]); /** * Handles deleting the file with haptic feedback. diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 13833e66..16808bbf 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -1,5 +1,4 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as FileSystem from "expo-file-system"; import * as Haptics from "expo-haptics"; import React, { useCallback } from "react"; import { TouchableOpacity, View } from "react-native"; @@ -9,9 +8,7 @@ import { useFiles } from "@/hooks/useFiles"; import { runtimeTicksToMinutes } from "@/utils/time"; import { Text } from "../common/Text"; -import { usePlayback } from "@/providers/PlaybackProvider"; -import { useRouter } from "expo-router"; -import { deleteDownloadedItem } from "@/hooks/useDownloadM3U8Files"; +import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; interface MovieCardProps { item: BaseItemDto; @@ -24,35 +21,11 @@ interface MovieCardProps { */ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useFiles(); - const router = useRouter(); - const { startDownloadedFilePlayback } = usePlayback(); + const { openFile } = useFileOpener(); - const handleOpenFile = useCallback(async () => { - try { - const directoryPath = `${FileSystem.documentDirectory}${item.Id}`; - const m3u8FilePath = `${directoryPath}/local.m3u8`; - - console.log("Path: ", m3u8FilePath); - - // Check if the m3u8 file exists - const fileInfo = await FileSystem.getInfoAsync(m3u8FilePath); - - if (!fileInfo.exists) { - console.warn("m3u8 file does not exist:", m3u8FilePath); - } - - // Start playback - startDownloadedFilePlayback({ - item, - url: `${m3u8FilePath}`, - }); - - // Navigate to the play screen - router.push("/play"); - } catch (error) { - console.error("Error opening file:", error); - } - }, [item, startDownloadedFilePlayback, router, deleteDownloadedItem]); + const handleOpenFile = useCallback(() => { + openFile(item); + }, [item, openFile]); /** * Handles deleting the file with haptic feedback. diff --git a/hooks/useDownloadM3U8Files.ts b/hooks/useDownloadM3U8Files.ts index f3e1b909..708942f1 100644 --- a/hooks/useDownloadM3U8Files.ts +++ b/hooks/useDownloadM3U8Files.ts @@ -6,6 +6,7 @@ import { download } from "@kesha-antonov/react-native-background-downloader"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQueryClient } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; +import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; import { useCallback } from "react"; import { toast } from "sonner-native"; @@ -27,6 +28,11 @@ export const useDownloadM3U8Files = (item: BaseItemDto) => { toast.success("Download started", { invert: true }); writeToLog("INFO", `Starting download for item ${item.Name}`); + setProgress({ + startTime: new Date(), + item, + progress: 0, + }); try { const directoryPath = `${FileSystem.documentDirectory}${item.Id}`; @@ -55,12 +61,22 @@ export const useDownloadM3U8Files = (item: BaseItemDto) => { const segmentUrl = `${api.basePath}/videos/${item.Id}/${segment.path}`; const destination = `${directoryPath}/${i}.ts`; - await download({ + download({ id: `${item.Id}_segment_${i}`, url: segmentUrl, destination: destination, }).done((e) => { console.log("Download completed for segment", i); + setProgress((prev) => { + const newProgress = ((prev?.progress || 0) + 1) / segments.length; + if (prev === null) { + return null; + } + return { + ...prev, + progress: newProgress, + }; + }); }); } @@ -194,3 +210,5 @@ export async function getAllDownloadedItems(): Promise { return []; } } + + diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts new file mode 100644 index 00000000..6c0ece93 --- /dev/null +++ b/hooks/useDownloadedFileOpener.ts @@ -0,0 +1,89 @@ +// hooks/useFileOpener.ts + +import { useCallback } from "react"; +import { useRouter } from "expo-router"; +import * as FileSystem from "expo-file-system"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { usePlayback } from "@/providers/PlaybackProvider"; +import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; + +export const useFileOpener = () => { + const router = useRouter(); + const { startDownloadedFilePlayback } = usePlayback(); + + const openFile = useCallback( + async (item: BaseItemDto) => { + const m3u8File = `${FileSystem.documentDirectory}${item.Id}/playlist.m3u8`; + const outputFile = `${FileSystem.documentDirectory}${item.Id}/output.mp4`; + + console.log("Checking for output file:", outputFile); + + const outputFileInfo = await FileSystem.getInfoAsync(outputFile); + + if (outputFileInfo.exists) { + console.log("Output MP4 file already exists. Playing directly."); + startDownloadedFilePlayback({ + item, + url: outputFile, + }); + router.push("/play"); + return; + } + + console.log("Output MP4 file does not exist. Converting from M3U8."); + + const m3u8FileInfo = await FileSystem.getInfoAsync(m3u8File); + + if (!m3u8FileInfo.exists) { + console.warn("m3u8 file does not exist:", m3u8File); + return; + } + + const conversionSuccess = await convertM3U8ToMP4(m3u8File, outputFile); + + if (conversionSuccess) { + startDownloadedFilePlayback({ + item, + url: outputFile, + }); + router.push("/play"); + } else { + console.error("Failed to convert M3U8 to MP4"); + // Handle conversion failure (e.g., show an error message to the user) + } + }, + [startDownloadedFilePlayback] + ); + + return { openFile }; +}; + +export async function convertM3U8ToMP4( + inputM3U8: string, + outputMP4: string +): Promise { + console.log("Converting M3U8 to MP4"); + console.log("Input M3U8:", inputM3U8); + console.log("Output MP4:", outputMP4); + + try { + const command = `-i ${inputM3U8} -c copy ${outputMP4}`; + console.log("Executing FFmpeg command:", command); + + const session = await FFmpegKit.execute(command); + const returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + console.log("Conversion completed successfully"); + return true; + } else { + console.error("Conversion failed. Return code:", returnCode); + const output = await session.getOutput(); + console.error("FFmpeg output:", output); + return false; + } + } catch (error) { + console.error("Error during conversion:", error); + return false; + } +} diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts index 143345f0..1fc47d18 100644 --- a/utils/atoms/downloads.ts +++ b/utils/atoms/downloads.ts @@ -4,7 +4,6 @@ import { atom } from "jotai"; export type ProcessItem = { item: BaseItemDto; progress: number; - speed?: number; startTime?: Date; };