diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 2008839..e096b8e 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -23,6 +23,10 @@ const defineConfig = (): ExpoConfig => ({ bundleIdentifier: "dev.movieweb.app", supportsTablet: true, requireFullScreen: true, + infoPlist: { + NSPhotoLibraryUsageDescription: + "This app saves videos to the photo library.", + }, }, android: { package: "dev.movieweb.app", diff --git a/apps/expo/src/app/(tabs)/downloads.tsx b/apps/expo/src/app/(tabs)/downloads.tsx index d496dee..0972125 100644 --- a/apps/expo/src/app/(tabs)/downloads.tsx +++ b/apps/expo/src/app/(tabs)/downloads.tsx @@ -1,26 +1,12 @@ +import React from "react"; import { ScrollView } from "react-native-gesture-handler"; -import type { DownloadItemProps } from "~/components/DownloadItem"; import { DownloadItem } from "~/components/DownloadItem"; import ScreenLayout from "~/components/layout/ScreenLayout"; +import { useDownloadManager } from "~/hooks/DownloadManagerContext"; -export default function DownloadsScreen() { - const downloads: DownloadItemProps[] = [ - { - filename: "episode.mp4", - progress: 0.3, - speed: 1.2, - fileSize: 500 * 1024 * 1024, - downloaded: 150 * 1024 * 1024, - }, - { - filename: "episode.m3u8", - progress: 0.7, - speed: 0.8, - fileSize: 200 * 1024 * 1024, - downloaded: 140 * 1024 * 1024, - }, - ]; +const DownloadsScreen: React.FC = () => { + const { downloads } = useDownloadManager(); return ( @@ -31,4 +17,6 @@ export default function DownloadsScreen() { ); -} +}; + +export default DownloadsScreen; diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx index 92e458b..78ccc46 100644 --- a/apps/expo/src/app/_layout.tsx +++ b/apps/expo/src/app/_layout.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TamaguiProvider, Theme, useTheme } from "tamagui"; import tamaguiConfig from "tamagui.config"; +import { DownloadManagerProvider } from "~/hooks/DownloadManagerContext"; import { useThemeStore } from "~/stores/theme"; // @ts-expect-error - Without named import it causes an infinite loop import _styles from "../../tamagui-web.css"; @@ -59,9 +60,11 @@ export default function RootLayout() { } return ( - - - + + + + + ); } diff --git a/apps/expo/src/components/DownloadItem.tsx b/apps/expo/src/components/DownloadItem.tsx index 95583e6..5b43faf 100644 --- a/apps/expo/src/components/DownloadItem.tsx +++ b/apps/expo/src/components/DownloadItem.tsx @@ -7,6 +7,7 @@ export interface DownloadItemProps { speed: number; fileSize: number; downloaded: number; + isFinished: boolean; } const formatBytes = (bytes: number, decimals = 2) => { @@ -24,8 +25,9 @@ export const DownloadItem: React.FC = ({ speed, fileSize, downloaded, + isFinished, }) => { - const percentage = (progress * 100).toFixed(0); + const percentage = progress * 100; const formattedFileSize = formatBytes(fileSize); const formattedDownloaded = formatBytes(downloaded); @@ -34,7 +36,11 @@ export const DownloadItem: React.FC = ({ {filename} - + = ({ {percentage}% - {formattedDownloaded} of {formattedFileSize} - - {speed} MB/s - + {isFinished ? ( + + Finished + + ) : ( + + {speed.toFixed(2)} MB/s + + )} ); diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 427c3ad..380f0c9 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -5,6 +5,7 @@ import ContextMenu from "react-native-context-menu-view"; import { useRouter } from "expo-router"; import { Image, Text, View } from "tamagui"; +// import { useDownloadManager } from "~/hooks/DownloadManagerContext"; import { usePlayerStore } from "~/stores/player/store"; export interface ItemData { @@ -18,6 +19,8 @@ export interface ItemData { export default function Item({ data }: { data: ItemData }) { const resetVideo = usePlayerStore((state) => state.resetVideo); const router = useRouter(); + // const { startDownload } = useDownloadManager(); + const { title, type, year, posterUrl } = data; const handlePress = () => { @@ -35,9 +38,13 @@ export default function Item({ data }: { data: ItemData }) { ]; const onContextMenuPress = ( - _e: NativeSyntheticEvent, + e: NativeSyntheticEvent, ) => { - // do stuff + console.log(e.nativeEvent.name); + // startDownload( + // "https://samplelib.com/lib/preview/mp4/sample-5s.mp4", + // "mp4", + // ).catch(console.error); }; return ( diff --git a/apps/expo/src/hooks/DownloadManagerContext.tsx b/apps/expo/src/hooks/DownloadManagerContext.tsx new file mode 100644 index 0000000..cc7c75c --- /dev/null +++ b/apps/expo/src/hooks/DownloadManagerContext.tsx @@ -0,0 +1,157 @@ +import type { ReactNode } from "react"; +import React, { createContext, useContext, useState } from "react"; +import * as FileSystem from "expo-file-system"; +import * as MediaLibrary from "expo-media-library"; + +interface DownloadItem { + filename: string; + progress: number; + speed: number; + fileSize: number; + downloaded: number; + url: string; + type: "mp4" | "hls"; + isFinished: boolean; +} + +interface DownloadManagerContextType { + downloads: DownloadItem[]; + startDownload: (url: string, type: "mp4" | "hls") => Promise; +} + +const DownloadManagerContext = createContext< + DownloadManagerContextType | undefined +>(undefined); + +export const useDownloadManager = () => { + const context = useContext(DownloadManagerContext); + if (!context) { + throw new Error( + "useDownloadManager must be used within a DownloadManagerProvider", + ); + } + return context; +}; + +export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [downloads, setDownloads] = useState([]); + + const startDownload = async (url: string, type: "mp4" | "hls") => { + const newDownload: DownloadItem = { + filename: url.split("/").pop() ?? "unknown", + progress: 0, + speed: 0, + fileSize: 0, + downloaded: 0, + type, + url, + isFinished: false, + }; + + setDownloads((currentDownloads) => [...currentDownloads, newDownload]); + + if (type === "mp4") { + await downloadMP4(url); + } else if (type === "hls") { + // HLS stuff later + } + }; + + const downloadMP4 = async (url: string) => { + let lastBytesWritten = 0; + let lastTimestamp = Date.now(); + + const callback = (downloadProgress: FileSystem.DownloadProgressData) => { + const currentTime = Date.now(); + const timeElapsed = (currentTime - lastTimestamp) / 1000; + + if (timeElapsed === 0) return; + + const bytesWritten = downloadProgress.totalBytesWritten; + const newBytes = bytesWritten - lastBytesWritten; + const speed = newBytes / timeElapsed / 1024; + + const progress = + bytesWritten / downloadProgress.totalBytesExpectedToWrite; + + setDownloads((currentDownloads) => + currentDownloads.map((item) => + item.url === url + ? { + ...item, + progress, + speed, + fileSize: downloadProgress.totalBytesExpectedToWrite, + } + : item, + ), + ); + + lastBytesWritten = bytesWritten; + lastTimestamp = currentTime; + }; + + const fileUri = FileSystem.documentDirectory + ? FileSystem.documentDirectory + url.split("/").pop() + : null; + if (!fileUri) { + console.error("Document directory is unavailable"); + return; + } + + const downloadResumable = FileSystem.createDownloadResumable( + url, + fileUri, + {}, + callback, + ); + + try { + const result = await downloadResumable.downloadAsync(); + if (result) { + console.log("Finished downloading to ", result.uri); + await saveFileToMediaLibraryAndDeleteOriginal(result.uri); + + setDownloads((currentDownloads) => + currentDownloads.map((item) => + item.url === url + ? { + ...item, + progress: 1, + speed: 0, + downloaded: item.fileSize, + isFinished: true, + } + : item, + ), + ); + } + } catch (e) { + console.error(e); + } + }; + + const saveFileToMediaLibraryAndDeleteOriginal = async (fileUri: string) => { + try { + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== MediaLibrary.PermissionStatus.GRANTED) { + throw new Error("MediaLibrary permission not granted"); + } + + await MediaLibrary.saveToLibraryAsync(fileUri); + await FileSystem.deleteAsync(fileUri); + + console.log("File saved to media library and original deleted"); + } catch (error) { + console.error("Error saving file to media library:", error); + } + }; + + return ( + + {children} + + ); +};