From 8f5d0247bb284ea8f07d7079bc424d541981927d Mon Sep 17 00:00:00 2001 From: Jorrin Date: Sat, 6 Apr 2024 22:27:55 +0200 Subject: [PATCH] rework downloads --- apps/expo/src/app/(tabs)/downloads.tsx | 29 +- apps/expo/src/app/(tabs)/settings.tsx | 19 +- apps/expo/src/app/_layout.tsx | 13 +- apps/expo/src/components/DownloadItem.tsx | 8 +- apps/expo/src/components/item/item.tsx | 20 +- .../components/player/CaptionsSelector.tsx | 10 +- .../src/components/player/DownloadButton.tsx | 2 +- .../src/components/player/ScraperProcess.tsx | 2 +- .../src/contexts/DownloadManagerContext.tsx | 442 ---------------- apps/expo/src/hooks/useDownloadManager.tsx | 487 ++++++++++++++++++ apps/expo/src/hooks/useToast.ts | 22 + apps/expo/src/stores/index.ts | 1 + apps/expo/src/stores/settings/index.ts | 50 +- packages/provider-utils/package.json | 2 +- pnpm-lock.yaml | 8 +- 15 files changed, 588 insertions(+), 527 deletions(-) delete mode 100644 apps/expo/src/contexts/DownloadManagerContext.tsx create mode 100644 apps/expo/src/hooks/useDownloadManager.tsx create mode 100644 apps/expo/src/hooks/useToast.ts create mode 100644 apps/expo/src/stores/index.ts diff --git a/apps/expo/src/app/(tabs)/downloads.tsx b/apps/expo/src/app/(tabs)/downloads.tsx index 7a28f32..a705d12 100644 --- a/apps/expo/src/app/(tabs)/downloads.tsx +++ b/apps/expo/src/app/(tabs)/downloads.tsx @@ -10,12 +10,14 @@ import type { ScrapeMedia } from "@movie-web/provider-utils"; import { DownloadItem } from "~/components/DownloadItem"; import ScreenLayout from "~/components/layout/ScreenLayout"; import { MWButton } from "~/components/ui/Button"; -import { useDownloadManager } from "~/contexts/DownloadManagerContext"; +import { useDownloadManager } from "~/hooks/useDownloadManager"; import { PlayerStatus } from "~/stores/player/slices/interface"; import { usePlayerStore } from "~/stores/player/store"; +import { useDownloadHistoryStore } from "~/stores/settings"; const DownloadsScreen: React.FC = () => { - const { startDownload, downloads } = useDownloadManager(); + const { startDownload } = useDownloadManager(); + const downloads = useDownloadHistoryStore((state) => state.downloads); const resetVideo = usePlayerStore((state) => state.resetVideo); const setVideoSrc = usePlayerStore((state) => state.setVideoSrc); const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile); @@ -106,7 +108,10 @@ const DownloadsScreen: React.FC = () => { await startDownload( "http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8", "hls", - exampleShowMedia, + { + ...exampleShowMedia, + tmdbId: "123456", + }, ).catch(console.error); }} > @@ -118,13 +123,17 @@ const DownloadsScreen: React.FC = () => { gap: "$4", }} > - {downloads.map((item) => ( - handlePress(item.localPath)} - /> - ))} + {/* TODO: Differentiate movies/shows, shows in new page */} + {downloads + .map((item) => item.downloads) + .flat() + .map((item) => ( + handlePress(item.localPath)} + /> + ))} ); diff --git a/apps/expo/src/app/(tabs)/settings.tsx b/apps/expo/src/app/(tabs)/settings.tsx index 4adfcb0..58d0f2f 100644 --- a/apps/expo/src/app/(tabs)/settings.tsx +++ b/apps/expo/src/app/(tabs)/settings.tsx @@ -11,7 +11,6 @@ import { MaterialCommunityIcons, MaterialIcons, } from "@expo/vector-icons"; -import { useToastController } from "@tamagui/toast"; import { useMutation } from "@tanstack/react-query"; import { Adapt, @@ -32,6 +31,7 @@ import { MWButton } from "~/components/ui/Button"; import { MWSelect } from "~/components/ui/Select"; import { MWSeparator } from "~/components/ui/Separator"; import { MWSwitch } from "~/components/ui/Switch"; +import { useToast } from "~/hooks/useToast"; import { checkForUpdate } from "~/lib/update"; import { useNetworkSettingsStore, @@ -54,10 +54,10 @@ export default function SettingsScreen() { const { gestureControls, setGestureControls, autoPlay, setAutoPlay } = usePlayerSettingsStore(); const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore(); - const toastController = useToastController(); const [showUpdateSheet, setShowUpdateSheet] = useState(false); const [updateMarkdownContent, setUpdateMarkdownContent] = useState(""); const [downloadUrl, setDownloadUrl] = useState(""); + const { showToast } = useToast(); const mutation = useMutation({ mutationKey: ["checkForUpdate"], @@ -74,11 +74,7 @@ export default function SettingsScreen() { ); setShowUpdateSheet(true); } else { - toastController.show("No updates available", { - burntOptions: { preset: "none" }, - native: true, - duration: 500, - }); + showToast("No updates available"); } }, }); @@ -100,18 +96,13 @@ export default function SettingsScreen() { try { await FileSystem.deleteAsync(cacheDirectory, { idempotent: true }); - - toastController.show("Cache cleared", { + showToast("Cache cleared", { burntOptions: { preset: "done" }, - native: true, - duration: 500, }); } catch (error) { console.error("Error clearing cache directory:", error); - toastController.show("Error clearing cache", { + showToast("Error clearing cache", { burntOptions: { preset: "error" }, - native: true, - duration: 500, }); } }; diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx index 8afc023..a8cd809 100644 --- a/apps/expo/src/app/_layout.tsx +++ b/apps/expo/src/app/_layout.tsx @@ -10,7 +10,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TamaguiProvider, Theme, useTheme } from "tamagui"; import tamaguiConfig from "tamagui.config"; -import { DownloadManagerProvider } from "~/contexts/DownloadManagerContext"; import { useThemeStore } from "~/stores/theme"; // @ts-expect-error - Without named import it causes an infinite loop import _styles from "../../tamagui-web.css"; @@ -106,13 +105,11 @@ function RootLayoutNav() { - - - - - - - + + + + + diff --git a/apps/expo/src/components/DownloadItem.tsx b/apps/expo/src/components/DownloadItem.tsx index e9499d2..af3b8f0 100644 --- a/apps/expo/src/components/DownloadItem.tsx +++ b/apps/expo/src/components/DownloadItem.tsx @@ -5,8 +5,8 @@ import ContextMenu from "react-native-context-menu-view"; import { TouchableOpacity } from "react-native-gesture-handler"; import { Image, Text, View, XStack, YStack } from "tamagui"; -import type { Download } from "~/contexts/DownloadManagerContext"; -import { useDownloadManager } from "~/contexts/DownloadManagerContext"; +import type { Download } from "~/hooks/useDownloadManager"; +import { useDownloadManager } from "~/hooks/useDownloadManager"; import { MWProgress } from "./ui/Progress"; import { FlashingText } from "./ui/Text"; @@ -57,9 +57,9 @@ export function DownloadItem(props: DownloadItemProps) { e: NativeSyntheticEvent, ) => { if (e.nativeEvent.name === ContextMenuActions.Cancel) { - void cancelDownload(props.item.id); + void cancelDownload(props.item); } else if (e.nativeEvent.name === ContextMenuActions.Remove) { - removeDownload(props.item.id); + removeDownload(props.item); } }; diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 408331d..7559bf6 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -4,9 +4,9 @@ import { useCallback } from "react"; import { Keyboard, TouchableOpacity } from "react-native"; import ContextMenu from "react-native-context-menu-view"; import { useRouter } from "expo-router"; -import { useToastController } from "@tamagui/toast"; import { Image, Text, View } from "tamagui"; +import { useToast } from "~/hooks/useToast"; import { usePlayerStore } from "~/stores/player/store"; import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings"; @@ -43,10 +43,10 @@ function checkReleased(media: ItemData): boolean { export default function Item({ data }: { data: ItemData }) { const resetVideo = usePlayerStore((state) => state.resetVideo); const router = useRouter(); - const toastController = useToastController(); const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore(); const { hasWatchHistoryItem, removeFromWatchHistory } = useWatchHistoryStore(); + const { showToast } = useToast(); const { title, type, year, posterUrl } = data; @@ -54,10 +54,8 @@ export default function Item({ data }: { data: ItemData }) { const handlePress = () => { if (!isReleased()) { - toastController.show("This media is not released yet", { + showToast("This media is not released yet", { burntOptions: { preset: "error" }, - native: true, - duration: 500, }); return; } @@ -86,17 +84,13 @@ export default function Item({ data }: { data: ItemData }) { ) => { if (e.nativeEvent.name === ContextMenuActions.Bookmark) { addBookmark(data); - toastController.show("Added to bookmarks", { + showToast("Added to bookmarks", { burntOptions: { preset: "done" }, - native: true, - duration: 500, }); } else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) { removeBookmark(data); - toastController.show("Removed from bookmarks", { + showToast("Removed from bookmarks", { burntOptions: { preset: "done" }, - native: true, - duration: 500, }); } else if (e.nativeEvent.name === ContextMenuActions.Download) { router.push({ @@ -107,10 +101,8 @@ export default function Item({ data }: { data: ItemData }) { e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem ) { removeFromWatchHistory(data); - toastController.show("Removed from Continue Watching", { + showToast("Removed from Continue Watching", { burntOptions: { preset: "done" }, - native: true, - duration: 500, }); } }; diff --git a/apps/expo/src/components/player/CaptionsSelector.tsx b/apps/expo/src/components/player/CaptionsSelector.tsx index cd392ba..ea5bff6 100644 --- a/apps/expo/src/components/player/CaptionsSelector.tsx +++ b/apps/expo/src/components/player/CaptionsSelector.tsx @@ -2,7 +2,6 @@ import type { LanguageCode } from "iso-639-1"; import type { ContentCaption } from "subsrt-ts/dist/types/handler"; import { useState } from "react"; import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; -import { useToastController } from "@tamagui/toast"; import { useMutation } from "@tanstack/react-query"; import { parse } from "subsrt-ts"; import { Spinner, useTheme, View } from "tamagui"; @@ -10,6 +9,7 @@ import { Spinner, useTheme, View } from "tamagui"; import type { Stream } from "@movie-web/provider-utils"; import type { CaptionWithData } from "~/stores/captions"; +import { useToast } from "~/hooks/useToast"; import { getCountryCodeFromLanguage, getPrettyLanguageNameFromLocale, @@ -35,7 +35,7 @@ const parseCaption = async ( }; export const CaptionsSelector = () => { - const toast = useToastController(); + const { showToast } = useToast(); const theme = useTheme(); const [open, setOpen] = useState(false); const captions = usePlayerStore( @@ -97,11 +97,7 @@ export const CaptionsSelector = () => { fontWeight="bold" chromeless onPress={() => { - toast.show("Work in progress", { - burntOptions: { preset: "none" }, - native: true, - duration: 500, - }); + showToast("Work in progress"); }} > Customize diff --git a/apps/expo/src/components/player/DownloadButton.tsx b/apps/expo/src/components/player/DownloadButton.tsx index 07fec47..62c930e 100644 --- a/apps/expo/src/components/player/DownloadButton.tsx +++ b/apps/expo/src/components/player/DownloadButton.tsx @@ -3,7 +3,7 @@ import { useTheme } from "tamagui"; import { findHighestQuality } from "@movie-web/provider-utils"; -import { useDownloadManager } from "~/contexts/DownloadManagerContext"; +import { useDownloadManager } from "~/hooks/useDownloadManager"; import { convertMetaToScrapeMedia } from "~/lib/meta"; import { usePlayerStore } from "~/stores/player/store"; import { MWButton } from "../ui/Button"; diff --git a/apps/expo/src/components/player/ScraperProcess.tsx b/apps/expo/src/components/player/ScraperProcess.tsx index cf74188..a35d21b 100644 --- a/apps/expo/src/components/player/ScraperProcess.tsx +++ b/apps/expo/src/components/player/ScraperProcess.tsx @@ -18,9 +18,9 @@ import { import type { ItemData } from "../item/item"; import type { AudioTrack } from "./AudioTrackSelector"; import type { PlayerMeta } from "~/stores/player/slices/video"; -import { useDownloadManager } from "~/contexts/DownloadManagerContext"; import { useMeta } from "~/hooks/player/useMeta"; import { useScrape } from "~/hooks/player/useSourceScrape"; +import { useDownloadManager } from "~/hooks/useDownloadManager"; import { convertMetaToScrapeMedia } from "~/lib/meta"; import { PlayerStatus } from "~/stores/player/slices/interface"; import { usePlayerStore } from "~/stores/player/store"; diff --git a/apps/expo/src/contexts/DownloadManagerContext.tsx b/apps/expo/src/contexts/DownloadManagerContext.tsx deleted file mode 100644 index 9156e5f..0000000 --- a/apps/expo/src/contexts/DownloadManagerContext.tsx +++ /dev/null @@ -1,442 +0,0 @@ -import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader"; -import type { Asset } from "expo-media-library"; -import type { ReactNode } from "react"; -import React, { createContext, useContext, useEffect, useState } from "react"; -import * as FileSystem from "expo-file-system"; -import * as MediaLibrary from "expo-media-library"; -import * as Network from "expo-network"; -import { NetworkStateType } from "expo-network"; -import { - checkForExistingDownloads, - completeHandler, - download, - setConfig, -} from "@kesha-antonov/react-native-background-downloader"; -import VideoManager from "@salihgun/react-native-video-processor"; -import { useToastController } from "@tamagui/toast"; - -import type { ScrapeMedia } from "@movie-web/provider-utils"; -import { extractSegmentsFromHLS } from "@movie-web/provider-utils"; - -import { - useDownloadHistoryStore, - useNetworkSettingsStore, -} from "~/stores/settings"; - -export interface Download { - id: string; - progress: number; - speed: number; - fileSize: number; - downloaded: number; - url: string; - type: "mp4" | "hls"; - status: - | "downloading" - | "finished" - | "error" - | "merging" - | "cancelled" - | "importing"; - localPath?: string; - media: ScrapeMedia; - downloadTask?: DownloadTask; -} - -export interface DownloadContent { - media: Pick; - downloads: Download[]; -} - -// @ts-expect-error - types are not up to date -setConfig({ - isLogsEnabled: false, - progressInterval: 250, -}); - -interface DownloadManagerContextType { - downloads: Download[]; - startDownload: ( - url: string, - type: "mp4" | "hls", - media: ScrapeMedia, - headers?: Record, - ) => Promise; - removeDownload: (id: string) => void; - cancelDownload: (id: string) => void; -} - -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 toastController = useToastController(); - - useEffect(() => { - const initializeDownloads = () => { - const { downloads } = useDownloadHistoryStore.getState(); - if (downloads) { - setDownloads(downloads); - } - }; - - void initializeDownloads(); - }, []); - - useEffect(() => { - useDownloadHistoryStore.setState({ downloads }); - }, [downloads]); - - useEffect(() => { - const checkRunningTasks = async () => { - const existingTasks = await checkForExistingDownloads(); - existingTasks.forEach((task) => { - task - .progress(({ bytesDownloaded, bytesTotal }) => { - const progress = bytesDownloaded / bytesTotal; - updateDownloadItem(task.id, { progress }); - }) - .done(() => { - completeHandler(task.id); - }) - .error(({ error, errorCode }) => { - console.error(`Download error: ${errorCode} - ${error}`); - }); - }); - }; - - void checkRunningTasks(); - }, []); - - const cancellationFlags = useState>({})[0]; - - const setCancellationFlag = (downloadId: string, flag: boolean): void => { - cancellationFlags[downloadId] = flag; - }; - - const getCancellationFlag = (downloadId: string): boolean => { - return cancellationFlags[downloadId] ?? false; - }; - - const cancelDownload = (downloadId: string) => { - setCancellationFlag(downloadId, true); - const downloadItem = downloads.find((d) => d.id === downloadId); - if (downloadItem?.downloadTask) { - downloadItem.downloadTask.stop(); - } - toastController.show("Download cancelled", { - burntOptions: { preset: "done" }, - native: true, - duration: 500, - }); - }; - - const startDownload = async ( - url: string, - type: "mp4" | "hls", - media: ScrapeMedia, - headers?: Record, - ): Promise => { - const { allowMobileData } = useNetworkSettingsStore.getState(); - - const { type: networkType } = await Network.getNetworkStateAsync(); - - if (networkType === NetworkStateType.CELLULAR && !allowMobileData) { - toastController.show("Mobile data downloads are disabled", { - burntOptions: { preset: "error" }, - native: true, - duration: 500, - }); - return; - } - - const { status } = await MediaLibrary.requestPermissionsAsync(); - if (status !== MediaLibrary.PermissionStatus.GRANTED) { - toastController.show("Permission denied", { - burntOptions: { preset: "error" }, - native: true, - duration: 500, - }); - return; - } - - toastController.show("Download started", { - burntOptions: { preset: "none" }, - native: true, - duration: 500, - }); - - const newDownload: Download = { - id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, - progress: 0, - speed: 0, - fileSize: 0, - downloaded: 0, - type, - url, - status: "downloading", - media, - }; - - setDownloads((currentDownloads) => [newDownload, ...currentDownloads]); - - if (type === "mp4") { - const asset = await downloadMP4(url, newDownload.id, headers ?? {}); - return asset; - } else if (type === "hls") { - const asset = await downloadHLS(url, newDownload.id, headers ?? {}); - return asset; - } - }; - - const updateDownloadItem = (id: string, updates: Partial) => { - setDownloads((currentDownloads) => - currentDownloads.map((download) => - download.id === id ? { ...download, ...updates } : download, - ), - ); - }; - - interface DownloadProgress { - bytesDownloaded: number; - bytesTotal: number; - } - - const downloadMP4 = ( - url: string, - downloadId: string, - headers: Record, - ): Promise => { - return new Promise((resolve, reject) => { - let lastBytesWritten = 0; - let lastTimestamp = Date.now(); - - const updateProgress = (downloadProgress: DownloadProgress) => { - const currentTime = Date.now(); - const timeElapsed = (currentTime - lastTimestamp) / 1000; - - if (timeElapsed === 0) return; - - const newBytes = downloadProgress.bytesDownloaded - lastBytesWritten; - const speed = newBytes / timeElapsed / 1024 / 1024; - const progress = - downloadProgress.bytesDownloaded / downloadProgress.bytesTotal; - - updateDownloadItem(downloadId, { - progress, - speed, - fileSize: downloadProgress.bytesTotal, - downloaded: downloadProgress.bytesDownloaded, - }); - - lastBytesWritten = downloadProgress.bytesDownloaded; - lastTimestamp = currentTime; - }; - - const fileUri = - FileSystem.cacheDirectory + "movie-web" - ? FileSystem.cacheDirectory + "movie-web" + url.split("/").pop() - : null; - if (!fileUri) { - console.error("Cache directory is unavailable"); - reject(new Error("Cache directory is unavailable")); - return; - } - - const downloadTask = download({ - id: downloadId, - url, - destination: fileUri, - headers, - isNotificationVisible: true, - }) - .begin(() => { - updateDownloadItem(downloadId, { downloadTask }); - }) - .progress(({ bytesDownloaded, bytesTotal }) => { - updateProgress({ bytesDownloaded, bytesTotal }); - }) - .done(() => { - saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadId) - .then((asset) => { - if (asset) { - resolve(asset); - } else { - reject(new Error("No asset returned")); - } - }) - .catch((error) => reject(error)); - }) - .error(({ error, errorCode }) => { - console.error(`Download error: ${errorCode} - ${error}`); - reject(new Error(`Download error: ${errorCode} - ${error}`)); - }); - }); - }; - - const downloadHLS = async ( - url: string, - downloadId: string, - headers: Record, - ) => { - const segments = await extractSegmentsFromHLS(url, headers); - - if (!segments || segments.length === 0) { - return removeDownload(downloadId); - } - - const totalSegments = segments.length; - let segmentsDownloaded = 0; - - const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/"; - await ensureDirExists(segmentDir); - - const updateProgress = () => { - const progress = segmentsDownloaded / totalSegments; - updateDownloadItem(downloadId, { - progress, - downloaded: segmentsDownloaded, - fileSize: totalSegments, - }); - }; - - const localSegmentPaths = []; - - for (const [index, segment] of segments.entries()) { - if (getCancellationFlag(downloadId)) { - await cleanupDownload(segmentDir, downloadId); - return; - } - - const segmentFile = `${segmentDir}${index}.ts`; - localSegmentPaths.push(segmentFile); - - try { - await downloadSegment(downloadId, segment, segmentFile, headers); - - if (getCancellationFlag(downloadId)) { - await cleanupDownload(segmentDir, downloadId); - return; - } - - segmentsDownloaded++; - updateProgress(); - } catch (e) { - console.error(e); - if (getCancellationFlag(downloadId)) { - await cleanupDownload(segmentDir, downloadId); - return; - } - } - } - - if (getCancellationFlag(downloadId)) { - return removeDownload(downloadId); - } - - updateDownloadItem(downloadId, { status: "merging" }); - const uri = await VideoManager.mergeVideos( - localSegmentPaths, - `${FileSystem.cacheDirectory}movie-web/output.mp4`, - ); - const asset = await saveFileToMediaLibraryAndDeleteOriginal( - uri, - downloadId, - ); - return asset; - }; - - const downloadSegment = async ( - downloadId: string, - segmentUrl: string, - segmentFile: string, - headers: Record, - ) => { - return new Promise((resolve, reject) => { - const task = download({ - id: `${downloadId}-${segmentUrl.split("/").pop()}`, - url: segmentUrl, - destination: segmentFile, - headers: headers, - }); - - task - .done(() => { - resolve(); - }) - .error((error) => { - console.error(error); - reject(error); - }); - }); - }; - - const cleanupDownload = async (segmentDir: string, downloadId: string) => { - await FileSystem.deleteAsync(segmentDir, { idempotent: true }); - removeDownload(downloadId); - }; - - async function ensureDirExists(dir: string) { - await FileSystem.deleteAsync(dir, { idempotent: true }); - await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); - } - - const saveFileToMediaLibraryAndDeleteOriginal = async ( - fileUri: string, - downloadId: string, - ): Promise => { - try { - updateDownloadItem(downloadId, { status: "importing" }); - - const asset = await MediaLibrary.createAssetAsync(fileUri); - await FileSystem.deleteAsync(fileUri); - - updateDownloadItem(downloadId, { - status: "finished", - localPath: asset.uri, - }); - console.log("File saved to media library and original deleted"); - toastController.show("Download finished", { - burntOptions: { preset: "done" }, - native: true, - duration: 500, - }); - return asset; - } catch (error) { - console.error("Error saving file to media library:", error); - toastController.show("Download failed", { - burntOptions: { preset: "error" }, - native: true, - duration: 500, - }); - } - }; - - const removeDownload = (id: string) => { - const updatedDownloads = downloads.filter((download) => download.id !== id); - setDownloads(updatedDownloads); - useDownloadHistoryStore.setState({ downloads: updatedDownloads }); - }; - - return ( - - {children} - - ); -}; diff --git a/apps/expo/src/hooks/useDownloadManager.tsx b/apps/expo/src/hooks/useDownloadManager.tsx new file mode 100644 index 0000000..c0d50b5 --- /dev/null +++ b/apps/expo/src/hooks/useDownloadManager.tsx @@ -0,0 +1,487 @@ +import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader"; +import type { Asset } from "expo-media-library"; +import { useCallback, useEffect, useState } from "react"; +import * as FileSystem from "expo-file-system"; +import * as MediaLibrary from "expo-media-library"; +import * as Network from "expo-network"; +import { NetworkStateType } from "expo-network"; +import { + checkForExistingDownloads, + completeHandler, + download, + setConfig, +} from "@kesha-antonov/react-native-background-downloader"; +import VideoManager from "@salihgun/react-native-video-processor"; + +import type { ScrapeMedia } from "@movie-web/provider-utils"; +import { extractSegmentsFromHLS } from "@movie-web/provider-utils"; + +import { useToast } from "~/hooks/useToast"; +import { + useDownloadHistoryStore, + useNetworkSettingsStore, +} from "~/stores/settings"; + +interface DownloadProgress { + bytesDownloaded: number; + bytesTotal: number; +} + +export interface Download { + id: string; + progress: number; + speed: number; + fileSize: number; + downloaded: number; + url: string; + type: "mp4" | "hls"; + status: + | "downloading" + | "finished" + | "error" + | "merging" + | "cancelled" + | "importing"; + localPath?: string; + media: ScrapeMedia; + downloadTask?: DownloadTask; +} + +export interface DownloadContent { + media: Pick; + downloads: Download[]; +} + +// @ts-expect-error - types are not up to date +setConfig({ + isLogsEnabled: false, + progressInterval: 250, +}); + +export const useDownloadManager = () => { + const cancellationFlags = useState>({})[0]; + + const downloads = useDownloadHistoryStore((state) => state.downloads); + const setDownloads = useDownloadHistoryStore((state) => state.setDownloads); + const { showToast } = useToast(); + + const setCancellationFlag = (downloadId: string, flag: boolean): void => { + cancellationFlags[downloadId] = flag; + }; + + const getCancellationFlag = useCallback( + (downloadId: string): boolean => { + return cancellationFlags[downloadId] ?? false; + }, + [cancellationFlags], + ); + + const cancelDownload = (download: Download) => { + setCancellationFlag(download.id, true); + if (download?.downloadTask) { + download.downloadTask.stop(); + } + showToast("Download cancelled", { + burntOptions: { preset: "done" }, + }); + }; + + const updateDownloadItem = useCallback( + (downloadId: string, download: Partial) => { + setDownloads((prev) => { + const updatedDownloads = prev.map((content) => { + const updatedDownloadsArray = content.downloads.map((d) => + d.id === downloadId + ? { + ...d, + ...download, + } + : d, + ); + + return { + ...content, + downloads: updatedDownloadsArray, + }; + }); + + return updatedDownloads; + }); + }, + [setDownloads], + ); + + const removeDownload = useCallback( + (download: Download) => { + if (download.media.type === "movie") { + setDownloads((prev) => + prev.filter((d) => d.media.tmdbId !== download.media.tmdbId), + ); + return; + } else if (download.media.type === "show") { + setDownloads((prev) => { + const existingDownload = prev.find( + (d) => d.media.tmdbId === download.media.tmdbId, + ); + if (existingDownload?.downloads.length === 1) { + return prev.filter((d) => d.media.tmdbId !== download.media.tmdbId); + } else { + return prev.map((content) => { + return { + ...content, + downloads: content.downloads.filter( + (d) => d.id !== download.id, + ), + }; + }); + } + }); + } + }, + [setDownloads], + ); + + const saveFileToMediaLibraryAndDeleteOriginal = useCallback( + async (fileUri: string, download: Download): Promise => { + console.log( + "Saving file to media library and deleting original", + fileUri, + ); + try { + updateDownloadItem(download.id, { status: "importing" }); + + const asset = await MediaLibrary.createAssetAsync(fileUri); + await FileSystem.deleteAsync(fileUri); + + updateDownloadItem(download.id, { + status: "finished", + localPath: asset.uri, + }); + console.log("File saved to media library and original deleted"); + showToast("Download finished", { + burntOptions: { preset: "done" }, + }); + return asset; + } catch (error) { + console.error("Error saving file to media library:", error); + showToast("Download failed", { + burntOptions: { preset: "error" }, + }); + } + }, + [updateDownloadItem, showToast], + ); + + const downloadMP4 = useCallback( + ( + url: string, + downloadItem: Download, + headers: Record, + ): Promise => { + return new Promise((resolve, reject) => { + let lastBytesWritten = 0; + let lastTimestamp = Date.now(); + + const updateProgress = (downloadProgress: DownloadProgress) => { + const currentTime = Date.now(); + const timeElapsed = (currentTime - lastTimestamp) / 1000; + + if (timeElapsed === 0) return; + + const newBytes = downloadProgress.bytesDownloaded - lastBytesWritten; + const speed = newBytes / timeElapsed / 1024 / 1024; + const progress = + downloadProgress.bytesDownloaded / downloadProgress.bytesTotal; + + updateDownloadItem(downloadItem.id, { + progress, + speed, + fileSize: downloadProgress.bytesTotal, + downloaded: downloadProgress.bytesDownloaded, + }); + + lastBytesWritten = downloadProgress.bytesDownloaded; + lastTimestamp = currentTime; + }; + + const fileUri = + FileSystem.cacheDirectory + "movie-web" + ? FileSystem.cacheDirectory + "movie-web" + url.split("/").pop() + : null; + if (!fileUri) { + console.error("Cache directory is unavailable"); + reject(new Error("Cache directory is unavailable")); + return; + } + + const downloadTask = download({ + id: downloadItem.id, + url, + destination: fileUri, + headers, + isNotificationVisible: true, + }) + .begin(() => { + updateDownloadItem(downloadItem.id, { downloadTask }); + }) + .progress(({ bytesDownloaded, bytesTotal }) => { + updateProgress({ bytesDownloaded, bytesTotal }); + }) + .done(() => { + saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadItem) + .then((asset) => { + if (asset) { + resolve(asset); + } else { + reject(new Error("No asset returned")); + } + }) + .catch((error) => reject(error)); + }) + .error(({ error, errorCode }) => { + console.error(`Download error: ${errorCode} - ${error}`); + reject(new Error(`Download error: ${errorCode} - ${error}`)); + }); + }); + }, + [saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem], + ); + + const cleanupDownload = useCallback( + async (segmentDir: string, download: Download) => { + await FileSystem.deleteAsync(segmentDir, { idempotent: true }); + removeDownload(download); + }, + [removeDownload], + ); + + const downloadHLS = useCallback( + async ( + url: string, + download: Download, + headers: Record, + ) => { + const segments = await extractSegmentsFromHLS(url, headers); + + if (!segments || segments.length === 0) { + return removeDownload(download); + } + + const totalSegments = segments.length; + let segmentsDownloaded = 0; + + const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/"; + await ensureDirExists(segmentDir); + + const updateProgress = () => { + const progress = segmentsDownloaded / totalSegments; + updateDownloadItem(download.id, { + progress, + downloaded: segmentsDownloaded, + fileSize: totalSegments, + }); + }; + + const localSegmentPaths = []; + + for (const [index, segment] of segments.entries()) { + if (getCancellationFlag(download.id)) { + await cleanupDownload(segmentDir, download); + return; + } + + const segmentFile = `${segmentDir}${index}.ts`; + localSegmentPaths.push(segmentFile); + + try { + await downloadSegment(download.id, segment, segmentFile, headers); + + if (getCancellationFlag(download.id)) { + await cleanupDownload(segmentDir, download); + return; + } + + segmentsDownloaded++; + updateProgress(); + } catch (e) { + console.error(e); + if (getCancellationFlag(download.id)) { + await cleanupDownload(segmentDir, download); + return; + } + } + } + + if (getCancellationFlag(download.id)) { + return removeDownload(download); + } + + updateDownloadItem(download.id, { status: "merging" }); + const uri = await VideoManager.mergeVideos( + localSegmentPaths, + `${FileSystem.cacheDirectory}movie-web/output.mp4`, + ); + const asset = await saveFileToMediaLibraryAndDeleteOriginal( + uri, + download, + ); + return asset; + }, + [ + cleanupDownload, + getCancellationFlag, + removeDownload, + saveFileToMediaLibraryAndDeleteOriginal, + updateDownloadItem, + ], + ); + + const startDownload = useCallback( + async ( + url: string, + type: "mp4" | "hls", + media: ScrapeMedia, + headers?: Record, + ): Promise => { + const { allowMobileData } = useNetworkSettingsStore.getState(); + + const { type: networkType } = await Network.getNetworkStateAsync(); + + if (networkType === NetworkStateType.CELLULAR && !allowMobileData) { + showToast("Mobile data downloads are disabled", { + burntOptions: { preset: "error" }, + }); + return; + } + + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== MediaLibrary.PermissionStatus.GRANTED) { + showToast("Permission denied", { + burntOptions: { preset: "error" }, + }); + return; + } + + const existingDownload = downloads.find( + (d) => d.media.tmdbId === media.tmdbId, + ); + + if (existingDownload && media.type === "movie") { + showToast("Download already exists", { + burntOptions: { preset: "error" }, + }); + return; + } + + if (existingDownload && media.type === "show") { + const existingEpisode = existingDownload.downloads.find( + (d) => + d.media.type === "show" && + d.media.episode.tmdbId === media.episode.tmdbId, + ); + + if (existingEpisode) { + showToast("Download already exists", { + burntOptions: { preset: "error" }, + }); + return; + } + } + showToast("Download started", { + burntOptions: { preset: "none" }, + }); + + const newDownload: Download = { + id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, + progress: 0, + speed: 0, + fileSize: 0, + downloaded: 0, + type, + url, + status: "downloading", + media, + }; + + const newDownloadContent = existingDownload + ? { + ...existingDownload, + downloads: [newDownload, ...existingDownload.downloads], + } + : { + media, + downloads: [newDownload], + }; + + setDownloads((prev) => { + return [...prev, newDownloadContent]; + }); + + if (type === "mp4") { + const asset = await downloadMP4(url, newDownload, headers ?? {}); + return asset; + } else if (type === "hls") { + const asset = await downloadHLS(url, newDownload, headers ?? {}); + return asset; + } + }, + [downloadHLS, downloadMP4, downloads, setDownloads, showToast], + ); + + const downloadSegment = async ( + downloadId: string, + segmentUrl: string, + segmentFile: string, + headers: Record, + ) => { + return new Promise((resolve, reject) => { + const task = download({ + id: `${downloadId}-${segmentUrl.split("/").pop()}`, + url: segmentUrl, + destination: segmentFile, + headers: headers, + }); + + task + .done(() => { + resolve(); + }) + .error((error) => { + console.error(error); + reject(error); + }); + }); + }; + + async function ensureDirExists(dir: string) { + await FileSystem.deleteAsync(dir, { idempotent: true }); + await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); + } + + useEffect(() => { + const checkRunningTasks = async () => { + const existingTasks = await checkForExistingDownloads(); + existingTasks.forEach((task) => { + task + .progress(({ bytesDownloaded, bytesTotal }) => { + const progress = bytesDownloaded / bytesTotal; + updateDownloadItem(task.id, { progress }); + }) + .done(() => { + completeHandler(task.id); + }) + .error(({ error, errorCode }) => { + console.error(`Download error: ${errorCode} - ${error}`); + }); + }); + }; + + void checkRunningTasks(); + }, [updateDownloadItem]); + + return { + startDownload, + removeDownload, + cancelDownload, + }; +}; diff --git a/apps/expo/src/hooks/useToast.ts b/apps/expo/src/hooks/useToast.ts new file mode 100644 index 0000000..c6c6547 --- /dev/null +++ b/apps/expo/src/hooks/useToast.ts @@ -0,0 +1,22 @@ +import { useCallback } from "react"; +import { useToastController } from "@tamagui/toast"; + +type ShowOptions = Parameters["show"]>[1]; + +export const useToast = () => { + const toastController = useToastController(); + + const showToast = useCallback( + (title: string, options?: ShowOptions) => { + toastController.show(title, { + burntOptions: { preset: "none" }, + native: true, + duration: 500, + ...options, + }); + }, + [toastController], + ); + + return { showToast }; +}; diff --git a/apps/expo/src/stores/index.ts b/apps/expo/src/stores/index.ts new file mode 100644 index 0000000..82cef18 --- /dev/null +++ b/apps/expo/src/stores/index.ts @@ -0,0 +1 @@ +export type ReactStyleStateSetter = T | ((prev: T) => T); diff --git a/apps/expo/src/stores/settings/index.ts b/apps/expo/src/stores/settings/index.ts index 8a53d38..56fd35e 100644 --- a/apps/expo/src/stores/settings/index.ts +++ b/apps/expo/src/stores/settings/index.ts @@ -6,8 +6,9 @@ import { createJSONStorage, persist } from "zustand/middleware"; import type { ScrapeMedia } from "@movie-web/provider-utils"; +import type { ReactStyleStateSetter } from ".."; import type { ItemData } from "~/components/item/item"; -import type { Download } from "~/contexts/DownloadManagerContext"; +import type { DownloadContent } from "~/hooks/useDownloadManager"; import type { ThemeStoreOption } from "~/stores/theme"; const storage = new MMKV(); @@ -37,7 +38,7 @@ export const useThemeSettingsStore = create< persist( (set) => ({ theme: "main", - setTheme: (theme: ThemeStoreOption) => set({ theme }), + setTheme: (theme) => set({ theme }), }), { name: "theme-settings", @@ -64,10 +65,9 @@ export const usePlayerSettingsStore = create< android: false, default: true, }), - setGestureControls: (enabled: boolean) => - set({ gestureControls: enabled }), + setGestureControls: (enabled) => set({ gestureControls: enabled }), autoPlay: true, - setAutoPlay: (enabled: boolean) => set({ autoPlay: enabled }), + setAutoPlay: (enabled) => set({ autoPlay: enabled }), }), { name: "player-settings", @@ -77,8 +77,8 @@ export const usePlayerSettingsStore = create< ); interface DownloadHistoryStoreState { - downloads: Download[]; - setDownloads: (downloads: Download[]) => void; + downloads: DownloadContent[]; + setDownloads: (downloads: ReactStyleStateSetter) => void; } export const useDownloadHistoryStore = create< @@ -88,7 +88,18 @@ export const useDownloadHistoryStore = create< persist( (set) => ({ downloads: [], - setDownloads: (downloads: Download[]) => set({ downloads }), + setDownloads: (newDownloadsOrSetterFn) => { + set(({ downloads }) => { + if (Array.isArray(newDownloadsOrSetterFn)) { + const newArr = newDownloadsOrSetterFn; + return { downloads: newArr }; + } + const setterFn = newDownloadsOrSetterFn; + return { + downloads: setterFn(downloads), + }; + }); + }, }), { name: "download-history", @@ -112,8 +123,8 @@ export const useBookmarkStore = create< persist( (set, get) => ({ bookmarks: [], - setBookmarks: (bookmarks: ItemData[]) => set({ bookmarks }), - addBookmark: (item: ItemData) => + setBookmarks: (bookmarks) => set({ bookmarks }), + addBookmark: (item) => set((state) => ({ bookmarks: [...state.bookmarks, item], })), @@ -123,7 +134,7 @@ export const useBookmarkStore = create< (bookmark) => bookmark.id !== item.id, ), })), - isBookmarked: (item: ItemData) => + isBookmarked: (item) => Boolean(get().bookmarks.find((bookmark) => bookmark.id === item.id)), }), { @@ -159,13 +170,13 @@ export const useWatchHistoryStore = create< persist( (set, get) => ({ watchHistory: [], - hasWatchHistoryItem: (item: ItemData) => + hasWatchHistoryItem: (item) => Boolean( get().watchHistory.find( (historyItem) => historyItem.item.id === item.id, ), ), - getWatchHistoryItem: (media: ScrapeMedia) => + getWatchHistoryItem: (media) => get().watchHistory.find((historyItem) => { if (historyItem.media.type === "movie" && media.type === "movie") { return historyItem.media.tmdbId === media.tmdbId; @@ -180,8 +191,7 @@ export const useWatchHistoryStore = create< ); } }), - setWatchHistory: (watchHistory: WatchHistoryItem[]) => - set({ watchHistory }), + setWatchHistory: (watchHistory) => set({ watchHistory }), updateWatchHistory: ( item: ItemData, media: ScrapeMedia, @@ -199,7 +209,7 @@ export const useWatchHistoryStore = create< }, ], })), - removeFromWatchHistory: (item: ItemData) => + removeFromWatchHistory: (item) => set((state) => ({ watchHistory: state.watchHistory.filter( (historyItem) => historyItem.item.id !== item.id, @@ -229,13 +239,11 @@ export const useNetworkSettingsStore = create< persist( (set) => ({ allowMobileData: false, - setAllowMobileData: (enabled: boolean) => - set({ allowMobileData: enabled }), + setAllowMobileData: (enabled) => set({ allowMobileData: enabled }), wifiDefaultQuality: "Highest", - setWifiDefaultQuality: (quality: string) => - set({ wifiDefaultQuality: quality }), + setWifiDefaultQuality: (quality) => set({ wifiDefaultQuality: quality }), mobileDataDefaultQuality: "Lowest", - setMobileDataDefaultQuality: (quality: string) => + setMobileDataDefaultQuality: (quality) => set({ mobileDataDefaultQuality: quality }), }), { diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index 3449671..1891f99 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -29,7 +29,7 @@ }, "prettier": "@movie-web/prettier-config", "dependencies": { - "@movie-web/providers": "^2.2.8", + "@movie-web/providers": "^2.2.9", "parse-hls": "^1.0.7", "srt-webvtt": "^2.0.0", "tmdb-ts": "^1.6.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09c4487..3e5050a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,8 +268,8 @@ importers: packages/provider-utils: dependencies: '@movie-web/providers': - specifier: ^2.2.8 - version: 2.2.8 + specifier: ^2.2.9 + version: 2.2.9 parse-hls: specifier: ^1.0.7 version: 1.0.7 @@ -2897,8 +2897,8 @@ packages: tslib: 2.6.2 dev: false - /@movie-web/providers@2.2.8: - resolution: {integrity: sha512-fsksIYuRn39TLC1PLMZrM6AW5kRQCWFmK0aK/p9bTui0ojs6aXLIZbvIwK0svzKLP2pmH6xJEhALxF8SYPE72Q==} + /@movie-web/providers@2.2.9: + resolution: {integrity: sha512-NHsyplM9Oe4DK3lIkNaEk0CqoQ6IqlaWXeDh01jj+DH4I4EJjSD4ow7OTeAC+BLz3Gwj6fh/vaE2WBGevPTDkQ==} requiresBuild: true dependencies: cheerio: 1.0.0-rc.12