From 349a86bcfb93444a451df9c4ba1ca61c65cf8e9f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 2 Jan 2025 09:44:32 +0100 Subject: [PATCH] fix: remove anything regarding downloads --- app.json | 40 +- app/(auth)/(tabs)/(home)/_layout.tsx | 12 - .../(tabs)/(home)/downloads/[seriesId].tsx | 132 ---- app/(auth)/(tabs)/(home)/downloads/index.tsx | 231 ------ app/(auth)/(tabs)/(home)/index.tsx | 67 -- app/(auth)/(tabs)/(home)/settings.tsx | 52 -- .../(home,libraries,search)/series/[id].tsx | 30 - app/(auth)/player/direct-player.tsx | 56 +- app/_layout.tsx | 374 ++------- app/login.tsx | 1 - bun.lockb | Bin 602637 -> 601233 bytes components/DownloadItem.tsx | 405 ---------- components/ItemContent.tsx | 2 - components/downloads/ActiveDownloads.tsx | 191 ----- components/downloads/DownloadSize.tsx | 47 -- components/downloads/EpisodeCard.tsx | 112 --- components/downloads/MovieCard.tsx | 113 --- components/downloads/SeriesCard.tsx | 82 -- components/series/SeasonPicker.tsx | 31 +- components/settings/SettingToggles.tsx | 245 +----- components/video-player/controls/Controls.tsx | 13 +- .../video-player/controls/EpisodeList.tsx | 4 - .../controls/dropdown/DropdownViewDirect.tsx | 14 +- .../dropdown/DropdownViewTranscoding.tsx | 1 - hooks/useDownloadedFileOpener.ts | 48 -- hooks/useRemuxHlsToMp4.ts | 201 ----- hooks/useWebsockets.ts | 3 - package.json | 2 - plugins/withChangeNativeAndroidTextToWhite.js | 30 - plugins/withRNBackgroundDownloader.js | 48 -- providers/DownloadProvider.tsx | 716 ------------------ utils/atoms/downloads.ts | 0 utils/atoms/queue.ts | 67 -- utils/atoms/settings.ts | 28 - utils/download.ts | 33 - utils/log.tsx | 30 +- utils/optimize-server.ts | 239 ------ 37 files changed, 101 insertions(+), 3599 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx delete mode 100644 app/(auth)/(tabs)/(home)/downloads/index.tsx delete mode 100644 components/DownloadItem.tsx delete mode 100644 components/downloads/ActiveDownloads.tsx delete mode 100644 components/downloads/DownloadSize.tsx delete mode 100644 components/downloads/EpisodeCard.tsx delete mode 100644 components/downloads/MovieCard.tsx delete mode 100644 components/downloads/SeriesCard.tsx delete mode 100644 hooks/useDownloadedFileOpener.ts delete mode 100644 hooks/useRemuxHlsToMp4.ts delete mode 100644 plugins/withChangeNativeAndroidTextToWhite.js delete mode 100644 plugins/withRNBackgroundDownloader.js delete mode 100644 providers/DownloadProvider.tsx delete mode 100644 utils/atoms/downloads.ts delete mode 100644 utils/atoms/queue.ts delete mode 100644 utils/download.ts delete mode 100644 utils/optimize-server.ts diff --git a/app.json b/app.json index ce2a3dd2..dc399d04 100644 --- a/app.json +++ b/app.json @@ -41,33 +41,17 @@ "foregroundImage": "./assets/images/adaptive_icon.png" }, "package": "com.fredrikburmester.streamyfin", - "permissions": [ - "android.permission.FOREGROUND_SERVICE", - "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", - "android.permission.WRITE_SETTINGS" - ] + "permissions": [] }, "plugins": [ "expo-router", "expo-font", "@config-plugins/ffmpeg-kit-react-native", - [ - "react-native-google-cast", - { - "useDefaultExpandedMediaControls": true - } - ], [ "react-native-video", { "enableNotificationControls": true, - "enableBackgroundAudio": true, - "androidExtensions": { - "useExoplayerRtsp": false, - "useExoplayerSmoothStreaming": false, - "useExoplayerHls": true, - "useExoplayerDash": false - } + "enableBackgroundAudio": true } ], [ @@ -76,20 +60,6 @@ "ios": { "deploymentTarget": "15.6", "useFrameworks": "static" - }, - "android": { - "android": { - "compileSdkVersion": 34, - "targetSdkVersion": 34, - "buildToolsVersion": "34.0.0" - }, - "minSdkVersion": 24, - "usesCleartextTraffic": true, - "packagingOptions": { - "jniLibs": { - "useLegacyPackaging": true - } - } } } ], @@ -106,12 +76,8 @@ } ], "expo-asset", - [ - "react-native-edge-to-edge", - { "android": { "parentTheme": "Material3" } } - ], + ["react-native-edge-to-edge"], ["react-native-bottom-tabs"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["@react-native-tvos/config-tv"] ], "experiments": { diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index d3ef928a..0117a1ee 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -31,18 +31,6 @@ export default function IndexLayout() { ), }} /> - - ( - {} - ); - const { downloadedFiles, deleteItems } = useDownload(); - - const series = useMemo(() => { - try { - return ( - downloadedFiles - ?.filter((f) => f.item.SeriesId == seriesId) - ?.sort( - (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber! - ) || [] - ); - } catch { - return []; - } - }, [downloadedFiles]); - - const seasonIndex = - seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || - episodeSeasonIndex || - ""; - - const groupBySeason = useMemo(() => { - const seasons: Record = {}; - - series?.forEach((episode) => { - if (!seasons[episode.item.ParentIndexNumber!]) { - seasons[episode.item.ParentIndexNumber!] = []; - } - - seasons[episode.item.ParentIndexNumber!].push(episode.item); - }); - return ( - seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ?? - [] - ); - }, [series, seasonIndex]); - - const initialSeasonIndex = useMemo( - () => - Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? - series?.[0]?.item?.ParentIndexNumber, - [groupBySeason] - ); - - useEffect(() => { - if (series.length > 0) { - navigation.setOptions({ - title: series[0].item.SeriesName, - }); - } else { - storage.delete(seriesId); - router.back(); - } - }, [series]); - - const deleteSeries = useCallback(() => { - Alert.alert( - "Delete season", - "Are you sure you want to delete the entire season?", - [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: () => deleteItems(groupBySeason), - style: "destructive", - }, - ] - ); - }, [groupBySeason]); - - return ( - - {series.length > 0 && ( - - s.item)} - state={seasonIndexState} - initialSeasonIndex={initialSeasonIndex!} - onSelect={(season) => { - setSeasonIndexState((prev) => ({ - ...prev, - [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, - })); - }} - /> - - {groupBySeason.length} - - - - - - - - )} - - {groupBySeason.map((episode, index) => ( - - ))} - - - ); -} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx deleted file mode 100644 index 2d4dcaa5..00000000 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; -import { MovieCard } from "@/components/downloads/MovieCard"; -import { SeriesCard } from "@/components/downloads/SeriesCard"; -import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; -import { queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; -import {useNavigation, useRouter} from "expo-router"; -import { useAtom } from "jotai"; -import React, {useEffect, useMemo, useRef} from "react"; -import {Alert, ScrollView, TouchableOpacity, View} from "react-native"; -import { Button } from "@/components/Button"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import {DownloadSize} from "@/components/downloads/DownloadSize"; -import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; -import {toast} from "sonner-native"; -import {writeToLog} from "@/utils/log"; - -export default function page() { - const navigation = useNavigation(); - const [queue, setQueue] = useAtom(queueAtom); - const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); - const router = useRouter(); - const [settings] = useSettings(); - const bottomSheetModalRef = useRef(null); - - const movies = useMemo(() => { - try { - return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; - } catch { - migration_20241124(); - return []; - } - }, [downloadedFiles]); - - const groupedBySeries = useMemo(() => { - try { - const episodes = downloadedFiles?.filter( - (f) => f.item.Type === "Episode" - ); - const series: { [key: string]: DownloadedItem[] } = {}; - episodes?.forEach((e) => { - if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = []; - series[e.item.SeriesName!].push(e); - }); - return Object.values(series); - } catch { - migration_20241124(); - return []; - } - }, [downloadedFiles]); - - const insets = useSafeAreaInsets(); - - useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - f.item) || []}/> - - ) - }) - }, [downloadedFiles]); - - const deleteMovies = () => deleteFileByType("Movie") - .then(() => toast.success("Deleted all movies successfully!")) - .catch((reason) => { - writeToLog("ERROR", reason); - toast.error("Failed to delete all movies"); - }); - const deleteShows = () => deleteFileByType("Episode") - .then(() => toast.success("Deleted all TV-Series successfully!")) - .catch((reason) => { - writeToLog("ERROR", reason); - toast.error("Failed to delete all TV-Series"); - }); - const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()]) - - return ( - <> - - - - {settings?.downloadMethod === "remux" && ( - - Queue - - Queue and downloads will be lost on app restart - - - {queue.map((q, index) => ( - - router.push(`/(auth)/items/page?id=${q.item.Id}`) - } - className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" - key={index} - > - - {q.item.Name} - {q.item.Type} - - { - removeProcess(q.id); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} - > - - - - ))} - - - {queue.length === 0 && ( - No items in queue - )} - - )} - - - - - {movies.length > 0 && ( - - - Movies - - {movies?.length} - - - - - {movies?.map((item) => ( - - - - ))} - - - - )} - {groupedBySeries.length > 0 && ( - - - TV-Series - - {groupedBySeries?.length} - - - - - {groupedBySeries?.map((items) => ( - - i.item)} - key={items[0].item.SeriesId} - /> - - ))} - - - - )} - {downloadedFiles?.length === 0 && ( - - No downloaded items - - )} - - - ( - - )} - > - - - - - - - - - - ); -} - -function migration_20241124() { - const router = useRouter(); - const { deleteAllFiles } = useDownload(); - Alert.alert( - "New app version requires re-download", - "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", - [ - { - text: "Back", - onPress: () => router.back(), - }, - { - text: "Delete", - style: "destructive", - onPress: async () => await deleteAllFiles(), - }, - ] - ); -} diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 2a9cd1ab..db0a8957 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -6,7 +6,6 @@ import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { Feather, Ionicons } from "@expo/vector-icons"; @@ -64,31 +63,10 @@ export default function index() { const [isConnected, setIsConnected] = useState(null); const [loadingRetry, setLoadingRetry] = useState(false); - const { downloadedFiles, cleanCacheDirectory } = useDownload(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - useEffect(() => { - const hasDownloads = downloadedFiles && downloadedFiles.length > 0; - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className="p-2" - > - - - ), - }); - }, [downloadedFiles, navigation, router]); - const checkConnection = useCallback(async () => { setLoadingRetry(true); const state = await NetInfo.fetch(); @@ -107,9 +85,6 @@ export default function index() { setIsConnected(state.isConnected); }); - cleanCacheDirectory() - .then(r => console.log("Cache directory cleaned")) - .catch(e => console.error("Something went wrong cleaning cache directory")) return () => { unsubscribe(); }; @@ -308,48 +283,6 @@ export default function index() { return ss; }, [api, user?.Id, collections, mediaListCollections]); - if (isConnected === false) { - return ( - - No Internet - - No worries, you can still watch{"\n"}downloaded content. - - - - - - - ); - } - if (e1 || e2) return ( diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 46aecbae..ac1f51fe 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,7 +2,6 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import {useDownload} from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, useLog } from "@/utils/log"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; @@ -17,7 +16,6 @@ import { toast } from "sonner-native"; export default function settings() { const { logout } = useJellyfin(); - const { deleteAllFiles, appSizeUsage } = useDownload(); const { logs } = useLog(); const [api] = useAtom(apiAtom); @@ -25,18 +23,6 @@ export default function settings() { const insets = useSafeAreaInsets(); - const { data: size, isLoading: appSizeLoading } = useQuery({ - queryKey: ["appSize", appSizeUsage], - queryFn: async () => { - const app = await appSizeUsage; - - const remaining = await FileSystem.getFreeDiskStorageAsync(); - const total = await FileSystem.getTotalDiskCapacityAsync(); - - return { app, remaining, total, used: (total - remaining) / total }; - }, - }); - const openQuickConnectAuthCodeInput = () => { Alert.prompt( "Quick connect", @@ -66,16 +52,6 @@ export default function settings() { ); }; - const onDeleteClicked = async () => { - try { - await deleteAllFiles(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } catch (e) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - toast.error("Error deleting files"); - } - }; - const onClearLogsClicked = async () => { clearLogs(); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -119,34 +95,6 @@ export default function settings() { - - Storage - - {size && App usage: {size.app.bytesToReadable()}} - - {size && ( - - Available: {size.remaining?.bytesToReadable()}, Total:{" "} - {size.total?.bytesToReadable()} - - )} - - - - Logs diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 9b7db729..cccbc777 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -1,10 +1,6 @@ -import { Text } from "@/components/common/Text"; -import { DownloadItems } from "@/components/DownloadItem"; import { ParallaxScrollView } from "@/components/ParallaxPage"; -import { Ratings } from "@/components/Ratings"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; -import { ItemActions } from "@/components/series/SeriesActions"; import { SeriesHeader } from "@/components/series/SeriesHeader"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -77,32 +73,6 @@ const page: React.FC = () => { enabled: !!api && !!user?.Id && !!item?.Id, }); - useEffect(() => { - navigation.setOptions({ - headerRight: () => - !isLoading && - allEpisodes && - allEpisodes.length > 0 && ( - - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> - - ), - }); - }, [allEpisodes, isLoading]); - if (!item || !backdropUrl) return null; return ( diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index e4b49320..a4a52abb 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -2,7 +2,6 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/controls/Controls"; -import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; @@ -13,8 +12,8 @@ import { ProgressUpdatePayload, VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; -import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; @@ -32,22 +31,13 @@ import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, + useEffect, useMemo, useRef, useState, - useEffect, } from "react"; -import { - Alert, - BackHandler, - View, - AppState, - AppStateStatus, - Platform, -} from "react-native"; +import { Alert, AppState, AppStateStatus, Platform, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; -import settings from "../(tabs)/(home)/settings"; -import { useSettings } from "@/utils/atoms/settings"; export default function page() { const videoRef = useRef(null); @@ -65,7 +55,6 @@ export default function page() { const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - const { getDownloadedItem } = useDownload(); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const setShowControls = useCallback((show: boolean) => { @@ -79,17 +68,14 @@ export default function page() { subtitleIndex: subtitleIndexStr, mediaSourceId, bitrateValue: bitrateValueStr, - offline: offlineStr, } = useGlobalSearchParams<{ itemId: string; audioIndex: string; subtitleIndex: string; mediaSourceId: string; bitrateValue: string; - offline: string; }>(); const [settings] = useSettings(); - const offline = offlineStr === "true"; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; @@ -104,12 +90,6 @@ export default function page() { } = useQuery({ queryKey: ["item", itemId], queryFn: async () => { - console.log("Offline:", offline); - if (offline) { - const item = await getDownloadedItem(itemId); - if (item) return item.item; - } - const res = await getUserLibraryApi(api!).getItem({ itemId, userId: user?.Id, @@ -128,21 +108,6 @@ export default function page() { } = useQuery({ queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], queryFn: async () => { - console.log("Offline:", offline); - if (offline) { - const data = await getDownloadedItem(itemId); - if (!data?.mediaSource) return null; - - const url = await getDownloadedFileUrl(data.item.Id!); - - if (item) - return { - mediaSource: data.mediaSource, - url, - sessionId: undefined, - }; - } - const res = await getStreamUrl({ api, item, @@ -181,7 +146,7 @@ export default function page() { if (isPlaying) { await videoRef.current?.pause(); - if (!offline && stream) { + if (stream) { await getPlaystateApi(api).onPlaybackProgress({ itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, @@ -199,7 +164,7 @@ export default function page() { console.log("Actually marked as paused"); } else { videoRef.current?.play(); - if (!offline && stream) { + if (stream) { await getPlaystateApi(api).onPlaybackProgress({ itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, @@ -223,13 +188,10 @@ export default function page() { audioIndex, subtitleIndex, mediaSourceId, - offline, progress.value, ]); const reportPlaybackStopped = useCallback(async () => { - if (offline) return; - const currentTimeInTicks = msToTicks(progress.value); await getPlaystateApi(api!).onPlaybackStopped({ @@ -250,8 +212,6 @@ export default function page() { // TODO: unused should remove. const reportPlaybackStart = useCallback(async () => { - if (offline) return; - if (!stream) return; await getPlaystateApi(api!).onPlaybackStart({ itemId: item?.Id!, @@ -276,8 +236,6 @@ export default function page() { progress.value = currentTime; - if (offline) return; - const currentTimeInTicks = msToTicks(currentTime); if (!item?.Id || !stream) return; @@ -303,7 +261,6 @@ export default function page() { isPlaying: isPlaying, togglePlay: togglePlay, stopPlayback: stop, - offline, }); const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { @@ -328,8 +285,6 @@ export default function page() { }, []); const startPosition = useMemo(() => { - if (offline) return 0; - return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0; @@ -495,7 +450,6 @@ export default function page() { enableTrickplay={true} getAudioTracks={videoRef.current?.getAudioTracks} getSubtitleTracks={videoRef.current?.getSubtitleTracks} - offline={offline} setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleURL={videoRef.current.setSubtitleURL} setAudioTrack={videoRef.current.setAudioTrack} diff --git a/app/_layout.tsx b/app/_layout.tsx index 8c9da2f7..7da3ce25 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,31 +1,16 @@ import "@/augmentations"; -import { DownloadProvider } from "@/providers/DownloadProvider"; -import { - getOrSetDeviceId, - getTokenFromStorage, - JellyfinProvider, -} from "@/providers/JellyfinProvider"; +import { JellyfinProvider } from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; -import { Settings, useSettings } from "@/utils/atoms/settings"; -import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; +import { useSettings } from "@/utils/atoms/settings"; import { LogProvider, writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { - checkForExistingDownloads, - completeHandler, - download, -} from "@kesha-antonov/react-native-background-downloader"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import * as BackgroundFetch from "expo-background-fetch"; -import * as FileSystem from "expo-file-system"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; import * as Linking from "expo-linking"; @@ -33,10 +18,9 @@ import * as Notifications from "expo-notifications"; import { router, Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; -import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; -import { useEffect, useRef } from "react"; -import { Appearance, AppState } from "react-native"; +import { useEffect } from "react"; +import { Appearance } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; @@ -44,170 +28,6 @@ import { Toaster } from "sonner-native"; SplashScreen.preventAutoHideAsync(); -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: false, - }), -}); - -function useNotificationObserver() { - useEffect(() => { - let isMounted = true; - - function redirect(notification: Notifications.Notification) { - const url = notification.request.content.data?.url; - if (url) { - router.push(url); - } - } - - Notifications.getLastNotificationResponseAsync().then((response) => { - if (!isMounted || !response?.notification) { - return; - } - redirect(response?.notification); - }); - - const subscription = Notifications.addNotificationResponseReceivedListener( - (response) => { - redirect(response.notification); - } - ); - - return () => { - isMounted = false; - subscription.remove(); - }; - }, []); -} - -TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - console.log("TaskManager ~ trigger"); - - const now = Date.now(); - - const settingsData = storage.getString("settings"); - - if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; - - const settings: Partial = JSON.parse(settingsData); - const url = settings?.optimizedVersionsServerUrl; - - if (!settings?.autoDownload || !url) - return BackgroundFetch.BackgroundFetchResult.NoData; - - const token = getTokenFromStorage(); - const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; - - if (!token || !deviceId || !baseDirectory) - return BackgroundFetch.BackgroundFetchResult.NoData; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader: token, - url, - }); - - console.log("TaskManager ~ Active jobs: ", jobs.length); - - for (let job of jobs) { - if (job.status === "completed") { - const downloadUrl = url + "download/" + job.id; - const tasks = await checkForExistingDownloads(); - - if (tasks.find((task) => task.id === job.id)) { - console.log("TaskManager ~ Download already in progress: ", job.id); - continue; - } - - download({ - id: job.id, - url: downloadUrl, - destination: `${baseDirectory}${job.item.Id}.mp4`, - headers: { - Authorization: token, - }, - }) - .begin(() => { - console.log("TaskManager ~ Download started: ", job.id); - }) - .done(() => { - console.log("TaskManager ~ Download completed: ", job.id); - saveDownloadedItemInfo(job.item); - completeHandler(job.id); - cancelJobById({ - authHeader: token, - id: job.id, - url: url, - }); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download completed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }) - .error((error) => { - console.log("TaskManager ~ Download error: ", job.id, error); - completeHandler(job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download failed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }); - } - } - - console.log(`Auto download started: ${new Date(now).toISOString()}`); - - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; -}); - -const checkAndRequestPermissions = async () => { - try { - const hasAskedBefore = storage.getString( - "hasAskedForNotificationPermission" - ); - - if (hasAskedBefore !== "true") { - const { status } = await Notifications.requestPermissionsAsync(); - - if (status === "granted") { - writeToLog("INFO", "Notification permissions granted."); - console.log("Notification permissions granted."); - } else { - writeToLog("ERROR", "Notification permissions denied."); - console.log("Notification permissions denied."); - } - - storage.set("hasAskedForNotificationPermission", "true"); - } else { - console.log("Already asked for notification permissions before."); - } - } catch (error) { - writeToLog( - "ERROR", - "Error checking/requesting notification permissions:", - error - ); - console.error("Error checking/requesting notification permissions:", error); - } -}; - export default function RootLayout() { const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), @@ -245,66 +65,7 @@ const queryClient = new QueryClient({ }); function Layout() { - const [settings, updateSettings] = useSettings(); - const [orientation, setOrientation] = useAtom(orientationAtom); - useKeepAwake(); - useNotificationObserver(); - - useEffect(() => { - checkAndRequestPermissions(); - }, []); - - useEffect(() => { - if (settings?.autoRotate === true) - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); - else - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP - ); - }, [settings]); - - const appState = useRef(AppState.currentState); - - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === "active" - ) { - checkForExistingDownloads(); - } - }); - - checkForExistingDownloads(); - - return () => { - subscription.remove(); - }; - }, []); - - useEffect(() => { - const subscription = ScreenOrientation.addOrientationChangeListener( - (event) => { - setOrientation(event.orientationInfo.orientation); - } - ); - - ScreenOrientation.getOrientationAsync().then((initialOrientation) => { - setOrientation(initialOrientation); - }); - - return () => { - ScreenOrientation.removeOrientationChangeListener(subscription); - }; - }, []); - - const url = Linking.useURL(); - - if (url) { - const { hostname, path, queryParams } = Linking.parse(url); - } - return ( @@ -314,62 +75,60 @@ function Layout() { - - - - + null, + }} + /> + + + + + + + @@ -380,24 +139,3 @@ function Layout() { ); } - -function saveDownloadedItemInfo(item: BaseItemDto) { - try { - const downloadedItems = storage.getString("downloadedItems"); - let items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.Id === item.Id); - if (existingItemIndex !== -1) { - items[existingItemIndex] = item; - } else { - items.push(item); - } - - storage.set("downloadedItems", JSON.stringify(items)); - } catch (error) { - writeToLog("ERROR", "Failed to save downloaded item information:", error); - console.error("Failed to save downloaded item information:", error); - } -} diff --git a/app/login.tsx b/app/login.tsx index af81b7d2..914fdb42 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -4,7 +4,6 @@ import { Text } from "@/components/common/Text"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { Ionicons } from "@expo/vector-icons"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; -import { getSystemApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; diff --git a/bun.lockb b/bun.lockb index 770f94139736a9868a37a58d025a7dedb1519bcc..f04851233f2f5a86025da8f919c0dc032d31fd2a 100755 GIT binary patch delta 99812 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%0DW;6*N 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 delta 100081 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 diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx deleted file mode 100644 index 4618bb4f..00000000 --- a/components/DownloadItem.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { queueActions, queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; -import download from "@/utils/profiles/download"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import { - BottomSheetBackdrop, - BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { Href, router, useFocusEffect } from "expo-router"; -import { useAtom } from "jotai"; -import React, { useCallback, useMemo, useRef, useState } from "react"; -import { Alert, View, ViewProps } from "react-native"; -import { toast } from "sonner-native"; -import { AudioTrackSelector } from "./AudioTrackSelector"; -import { Bitrate, BitrateSelector } from "./BitrateSelector"; -import { Button } from "./Button"; -import { Text } from "./common/Text"; -import { Loader } from "./Loader"; -import { MediaSourceSelector } from "./MediaSourceSelector"; -import ProgressCircle from "./ProgressCircle"; -import { RoundButton } from "./RoundButton"; -import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; - -interface DownloadProps extends ViewProps { - items: BaseItemDto[]; - MissingDownloadIconComponent: () => React.ReactElement; - DownloadedIconComponent: () => React.ReactElement; - title?: string; - subtitle?: string; - size?: "default" | "large"; -} - -export const DownloadItems: React.FC = ({ - items, - MissingDownloadIconComponent, - DownloadedIconComponent, - title = "Download", - subtitle = "", - size = "default", - ...props -}) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const [queue, setQueue] = useAtom(queueAtom); - const [settings] = useSettings(); - const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); - const { startRemuxing } = useRemuxHlsToMp4(); - - const [selectedMediaSource, setSelectedMediaSource] = useState< - MediaSourceInfo | undefined | null - >(undefined); - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); - const [maxBitrate, setMaxBitrate] = useState({ - key: "Max", - value: undefined, - }); - - const userCanDownload = useMemo( - () => user?.Policy?.EnableContentDownloading, - [user] - ); - const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === "optimized", - [settings] - ); - - const bottomSheetModalRef = useRef(null); - - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef.current?.present(); - }, []); - - const handleSheetChanges = useCallback((index: number) => {}, []); - - const closeModal = useCallback(() => { - bottomSheetModalRef.current?.dismiss(); - }, []); - - const itemIds = useMemo(() => items.map((i) => i.Id), [items]); - - const itemsNotDownloaded = useMemo( - () => - items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), - [items, downloadedFiles] - ); - - const allItemsDownloaded = useMemo(() => { - if (items.length === 0) return false; - return itemsNotDownloaded.length === 0; - }, [items, itemsNotDownloaded]); - const itemsProcesses = useMemo( - () => processes?.filter((p) => itemIds.includes(p.item.Id)), - [processes, itemIds] - ); - - const progress = useMemo(() => { - if (itemIds.length == 1) - return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); - return ( - ((itemIds.length - - queue.filter((q) => itemIds.includes(q.item.Id)).length) / - itemIds.length) * - 100 - ); - }, [queue, itemsProcesses, itemIds]); - - const itemsQueued = useMemo(() => { - return ( - itemsNotDownloaded.length > 0 && - itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id)) - ); - }, [queue, itemsNotDownloaded]); - const navigateToDownloads = () => router.push("/downloads"); - - const onDownloadedPress = () => { - const firstItem = items?.[0]; - router.push( - firstItem.Type !== "Episode" - ? "/downloads" - : ({ - pathname: `/downloads/${firstItem.SeriesId}`, - params: { - episodeSeasonIndex: firstItem.ParentIndexNumber, - }, - } as Href) - ); - }; - - const acceptDownloadOptions = useCallback(() => { - if (userCanDownload === true) { - if (itemsNotDownloaded.some((i) => !i.Id)) { - throw new Error("No item id"); - } - closeModal(); - - if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded); - else { - queueActions.enqueue( - queue, - setQueue, - ...itemsNotDownloaded.map((item) => ({ - id: item.Id!, - execute: async () => await initiateDownload(item), - item, - })) - ); - } - } else { - toast.error("You are not allowed to download files."); - } - }, [ - queue, - setQueue, - itemsNotDownloaded, - usingOptimizedServer, - userCanDownload, - maxBitrate, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - ]); - - const initiateDownload = useCallback( - async (...items: BaseItemDto[]) => { - if ( - !api || - !user?.Id || - items.some((p) => !p.Id) || - (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) - ) { - throw new Error( - "DownloadItem ~ initiateDownload: No api or user or item" - ); - } - let mediaSource = selectedMediaSource; - let audioIndex: number | undefined = selectedAudioStream; - let subtitleIndex: number | undefined = selectedSubtitleStream; - - for (const item of items) { - if (itemsNotDownloaded.length > 1) { - ({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( - item, - settings! - )); - } - - const res = await getStreamUrl({ - api, - item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: mediaSource?.Id, - subtitleStreamIndex: subtitleIndex, - deviceProfile: download, - }); - - if (!res) { - Alert.alert( - "Something went wrong", - "Could not get stream url from Jellyfin" - ); - continue; - } - - const { mediaSource: source, url } = res; - - if (!url || !source) throw new Error("No url"); - - saveDownloadItemInfoToDiskTmp(item, source, url); - - if (usingOptimizedServer) { - await startBackgroundDownload(url, item, source); - } else { - await startRemuxing(item, url, source); - } - } - }, - [ - api, - user?.Id, - itemsNotDownloaded, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - settings, - maxBitrate, - usingOptimizedServer, - startBackgroundDownload, - startRemuxing, - ] - ); - - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [] - ); - useFocusEffect( - useCallback(() => { - if (!settings) return; - if (itemsNotDownloaded.length !== 1) return; - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(items[0], settings); - - setSelectedMediaSource(mediaSource ?? undefined); - setSelectedAudioStream(audioIndex ?? 0); - setSelectedSubtitleStream(subtitleIndex ?? -1); - setMaxBitrate(bitrate); - }, [items, itemsNotDownloaded, settings]) - ); - - const renderButtonContent = () => { - if (processes && itemsProcesses.length > 0) { - return progress === 0 ? ( - - ) : ( - - - - ); - } else if (itemsQueued) { - return ; - } else if (allItemsDownloaded) { - return ; - } else { - return ; - } - }; - - const onButtonPress = () => { - if (processes && itemsProcesses.length > 0) { - navigateToDownloads(); - } else if (itemsQueued) { - navigateToDownloads(); - } else if (allItemsDownloaded) { - onDownloadedPress(); - } else { - handlePresentModalPress(); - } - }; - - return ( - - - {renderButtonContent()} - - - - - - - {title} - - - {subtitle || `Download ${itemsNotDownloaded.length} items`} - - - - - {itemsNotDownloaded.length === 1 && ( - <> - - {selectedMediaSource && ( - - - - - )} - - )} - - - - - {usingOptimizedServer - ? "Using optimized server" - : "Using default method"} - - - - - - - ); -}; - -export const DownloadSingleItem: React.FC<{ - size?: "default" | "large"; - item: BaseItemDto; -}> = ({ item, size = "default" }) => { - return ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> - ); -}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 20331d85..e46646a2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -1,6 +1,5 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { DownloadSingleItem } from "@/components/DownloadItem"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlayButton } from "@/components/PlayButton"; @@ -88,7 +87,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( {item.Type !== "Program" && ( - )} diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx deleted file mode 100644 index 556ae8c7..00000000 --- a/components/downloads/ActiveDownloads.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { JobStatus } from "@/utils/optimize-server"; -import { formatTimeString } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; -import { FFmpegKit } from "ffmpeg-kit-react-native"; -import { useAtom } from "jotai"; -import { - ActivityIndicator, - TouchableOpacity, - TouchableOpacityProps, - View, - ViewProps, -} from "react-native"; -import { toast } from "sonner-native"; -import { Button } from "../Button"; -import { Image } from "expo-image"; -import { useMemo } from "react"; -import { storage } from "@/utils/mmkv"; - -interface Props extends ViewProps {} - -export const ActiveDownloads: React.FC = ({ ...props }) => { - const { processes } = useDownload(); - if (processes?.length === 0) - return ( - - Active download - No active downloads - - ); - - return ( - - Active downloads - - {processes?.map((p) => ( - - ))} - - - ); -}; - -interface DownloadCardProps extends TouchableOpacityProps { - process: JobStatus; -} - -const DownloadCard = ({ process, ...props }: DownloadCardProps) => { - const { processes, startDownload } = useDownload(); - const router = useRouter(); - const { removeProcess, setProcesses } = useDownload(); - const [settings] = useSettings(); - const queryClient = useQueryClient(); - - const cancelJobMutation = useMutation({ - mutationFn: async (id: string) => { - if (!process) throw new Error("No active download"); - - if (settings?.downloadMethod === "optimized") { - try { - const tasks = await checkForExistingDownloads(); - for (const task of tasks) { - if (task.id === id) { - task.stop(); - } - } - } catch (e) { - throw e; - } finally { - await removeProcess(id); - await queryClient.refetchQueries({ queryKey: ["jobs"] }); - } - } else { - FFmpegKit.cancel(Number(id)); - setProcesses((prev) => prev.filter((p) => p.id !== id)); - } - }, - onSuccess: () => { - toast.success("Download canceled"); - }, - onError: (e) => { - console.error(e); - toast.error("Could not cancel download"); - }, - }); - - const eta = (p: JobStatus) => { - if (!p.speed || !p.progress) return null; - - const length = p?.item?.RunTimeTicks || 0; - const timeLeft = (length - length * (p.progress / 100)) / p.speed; - return formatTimeString(timeLeft, "tick"); - }; - - const base64Image = useMemo(() => { - return storage.getString(process.item.Id!); - }, []); - - return ( - router.push(`/(auth)/items/page?id=${process.item.Id}`)} - className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" - {...props} - > - {(process.status === "optimizing" || - process.status === "downloading") && ( - - )} - - - {base64Image && ( - - - - )} - - {process.item.Type} - {process.item.Name} - - {process.item.ProductionYear} - - - {process.progress === 0 ? ( - - ) : ( - {process.progress.toFixed(0)}% - )} - {process.speed && ( - {process.speed?.toFixed(2)}x - )} - {eta(process) && ( - ETA {eta(process)} - )} - - - - {process.status} - - - cancelJobMutation.mutate(process.id)} - className="ml-auto" - > - {cancelJobMutation.isPending ? ( - - ) : ( - - )} - - - {process.status === "completed" && ( - - - - )} - - - ); -}; diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx deleted file mode 100644 index 48a52a29..00000000 --- a/components/downloads/DownloadSize.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import React, { useEffect, useMemo, useState } from "react"; -import { TextProps } from "react-native"; - -interface DownloadSizeProps extends TextProps { - items: BaseItemDto[]; -} - -export const DownloadSize: React.FC = ({ - items, - ...props -}) => { - const { downloadedFiles, getDownloadedItemSize } = useDownload(); - const [size, setSize] = useState(); - - const itemIds = useMemo(() => items.map((i) => i.Id), [items]); - - useEffect(() => { - if (!downloadedFiles) return; - - let s = 0; - - for (const item of items) { - if (!item.Id) continue; - const size = getDownloadedItemSize(item.Id); - if (size) { - s += size; - } - } - setSize(s.bytesToReadable()); - }, [itemIds]); - - const sizeText = useMemo(() => { - if (!size) return "..."; - return size; - }, [size]); - - return ( - <> - - {sizeText} - - - ); -}; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx deleted file mode 100644 index e8387da5..00000000 --- a/components/downloads/EpisodeCard.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as Haptics from "expo-haptics"; -import React, { useCallback, useMemo } from "react"; -import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; -import { - ActionSheetProvider, - useActionSheet, -} from "@expo/react-native-action-sheet"; - -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; -import { Image } from "expo-image"; -import { Ionicons } from "@expo/vector-icons"; -import { Text } from "@/components/common/Text"; -import { runtimeTicksToSeconds } from "@/utils/time"; -import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { TouchableItemRouter } from "../common/TouchableItemRouter"; -import ContinueWatchingPoster from "../ContinueWatchingPoster"; - -interface EpisodeCardProps extends TouchableOpacityProps { - item: BaseItemDto; -} - -export const EpisodeCard: React.FC = ({ item, ...props }) => { - const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); - const { showActionSheetWithOptions } = useActionSheet(); - - const base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, [item]); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - - /** - * Handles deleting the file with haptic feedback. - */ - const handleDeleteFile = useCallback(() => { - if (item.Id) { - deleteFile(item.Id); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - }, [deleteFile, item.Id]); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - const cancelButtonIndex = 1; - - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - destructiveButtonIndex, - }, - (selectedIndex) => { - switch (selectedIndex) { - case destructiveButtonIndex: - // Delete - handleDeleteFile(); - break; - case cancelButtonIndex: - // Cancelled - break; - } - } - ); - }, [showActionSheetWithOptions, handleDeleteFile]); - - return ( - - - - - - - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - - - {runtimeTicksToSeconds(item.RunTimeTicks)} - - - - - - - {item.Overview} - - - ); -}; - -// Wrap the parent component with ActionSheetProvider -export const EpisodeCardWithActionSheet: React.FC = ( - props -) => ( - - - -); diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx deleted file mode 100644 index 3073bd0a..00000000 --- a/components/downloads/MovieCard.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { - ActionSheetProvider, - useActionSheet, -} from "@expo/react-native-action-sheet"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as Haptics from "expo-haptics"; -import React, { useCallback, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; - -import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { ItemCardText } from "../ItemCardText"; - -interface MovieCardProps { - item: BaseItemDto; -} - -/** - * MovieCard component displays a movie with action sheet options. - * @param {MovieCardProps} props - The component props. - * @returns {React.ReactElement} The rendered MovieCard component. - */ -export const MovieCard: React.FC = ({ item }) => { - const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); - const { showActionSheetWithOptions } = useActionSheet(); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - - const base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, []); - - /** - * Handles deleting the file with haptic feedback. - */ - const handleDeleteFile = useCallback(() => { - if (item.Id) { - deleteFile(item.Id); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - }, [deleteFile, item.Id]); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - const cancelButtonIndex = 1; - - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - destructiveButtonIndex, - }, - (selectedIndex) => { - switch (selectedIndex) { - case destructiveButtonIndex: - // Delete - handleDeleteFile(); - break; - case cancelButtonIndex: - // Cancelled - break; - } - } - ); - }, [showActionSheetWithOptions, handleDeleteFile]); - - return ( - - {base64Image ? ( - - - - ) : ( - - - - )} - - - - - - ); -}; - -// Wrap the parent component with ActionSheetProvider -export const MovieCardWithActionSheet: React.FC = (props) => ( - - - -); diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx deleted file mode 100644 index 4c6efa1f..00000000 --- a/components/downloads/SeriesCard.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import {TouchableOpacity, View} from "react-native"; -import { Text } from "../common/Text"; -import React, {useCallback, useMemo} from "react"; -import {storage} from "@/utils/mmkv"; -import {Image} from "expo-image"; -import {Ionicons} from "@expo/vector-icons"; -import {router} from "expo-router"; -import {DownloadSize} from "@/components/downloads/DownloadSize"; -import {useDownload} from "@/providers/DownloadProvider"; -import {useActionSheet} from "@expo/react-native-action-sheet"; - -export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => { - const { deleteItems } = useDownload(); - const { showActionSheetWithOptions } = useActionSheet(); - - const base64Image = useMemo(() => { - return storage.getString(items[0].SeriesId!); - }, []); - - const deleteSeries = useCallback( - async () => deleteItems(items), - [items] - ); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - - showActionSheetWithOptions({ - options, - destructiveButtonIndex, - }, - (selectedIndex) => { - if (selectedIndex == destructiveButtonIndex) { - deleteSeries(); - } - } - ); - }, [showActionSheetWithOptions, deleteSeries]); - - return ( - router.push(`/downloads/${items[0].SeriesId}`)} - onLongPress={showActionSheet} - > - {base64Image ? ( - - - - {items.length} - - - ) : ( - - - - )} - - - {items[0].SeriesName} - {items[0].ProductionYear} - - - - ); -}; diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 26a5f747..c64bdf4b 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -1,22 +1,21 @@ +import { + SeasonDropdown, + SeasonIndexState, +} from "@/components/series/SeasonDropdown"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { runtimeTicksToSeconds } from "@/utils/time"; +import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; -import { DownloadItems, DownloadSingleItem } from "../DownloadItem"; import { Loader } from "../Loader"; import { Text } from "../common/Text"; -import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; -import { - SeasonDropdown, - SeasonIndexState, -} from "@/components/series/SeasonDropdown"; -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; type Props = { item: BaseItemDto; @@ -143,19 +142,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { })); }} /> - {episodes?.length || 0 > 0 ? ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> - ) : null} {isFetching ? ( @@ -193,9 +179,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {runtimeTicksToSeconds(e.RunTimeTicks)} - - - = ({ ...props }) => { const [settings, updateSettings] = useSettings(); - const { setProcesses } = useDownload(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [marlinUrl, setMarlinUrl] = useState(""); - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - const queryClient = useQueryClient(); - /******************** - * Background task - *******************/ - const checkStatusAsync = async () => { - await BackgroundFetch.getStatusAsync(); - return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); - }; - - useEffect(() => { - (async () => { - const registered = await checkStatusAsync(); - - if (settings?.autoDownload === true && !registered) { - registerBackgroundFetchAsync(); - toast.success("Background downloads enabled"); - } else if (settings?.autoDownload === false && registered) { - unregisterBackgroundFetchAsync(); - toast.info("Background downloads disabled"); - } else if (settings?.autoDownload === true && registered) { - // Don't to anything - } else if (settings?.autoDownload === false && !registered) { - // Don't to anything - } else { - updateSettings({ autoDownload: false }); - } - })(); - }, [settings?.autoDownload]); - /********************** - *********************/ - const { data: mediaListCollections, isLoading: isLoadingMediaListCollections, @@ -460,183 +404,6 @@ export const SettingToggles: React.FC = ({ ...props }) => { - - - Downloads - - - - Download method - - Choose the download method to use. Optimized requires the - optimized server. - - - - - - - {settings.downloadMethod === "remux" - ? "Default" - : "Optimized"} - - - - - Methods - { - updateSettings({ downloadMethod: "remux" }); - setProcesses([]); - }} - > - Default - - { - updateSettings({ downloadMethod: "optimized" }); - setProcesses([]); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Optimized - - - - - - - Remux max download - - This is the total media you want to be able to download at the - same time. - - - - updateSettings({ - remuxConcurrentLimit: - value as Settings["remuxConcurrentLimit"], - }) - } - /> - - - - Auto download - - This will automatically download the media file when it's - finished optimizing on the server. - - - updateSettings({ autoDownload: value })} - /> - - - - - - - Optimized versions server - - - - Set the URL for the optimized versions server for downloads. - - - - - setOptimizedVersionsServerUrl(text)} - /> - - - - - - - ); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index d7386134..4110a7e5 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -109,7 +109,6 @@ export const Controls: React.FC = ({ setSubtitleTrack, setAudioTrack, stop, - offline = false, enableTrickplay = true, isVlc = false, }) => { @@ -124,7 +123,7 @@ export const Controls: React.FC = ({ calculateTrickplayUrl, trickplayInfo, prefetchAllTrickplayImages, - } = useTrickplay(item, !offline && enableTrickplay); + } = useTrickplay(item, enableTrickplay); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Infinity); @@ -142,7 +141,7 @@ export const Controls: React.FC = ({ }>(); const { showSkipButton, skipIntro } = useIntroSkipper( - offline ? undefined : item.Id, + item.Id, currentTime, seek, play, @@ -150,7 +149,7 @@ export const Controls: React.FC = ({ ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( - offline ? undefined : item.Id, + item.Id, currentTime, seek, play, @@ -547,7 +546,7 @@ export const Controls: React.FC = ({ pointerEvents={showControls ? "auto" : "none"} className={`flex flex-row items-center space-x-2 z-10 p-4 `} > - {item?.Type === "Episode" && !offline && ( + {item?.Type === "Episode" && ( { switchOnEpisodeMode(); @@ -557,7 +556,7 @@ export const Controls: React.FC = ({ )} - {previousItem && !offline && ( + {previousItem && ( = ({ )} - {nextItem && !offline && ( + {nextItem && ( = ({ item, close, goToItem }) => { {runtimeTicksToSeconds(_item.RunTimeTicks)} - - - = ({ showControls, - offline = false, }) => { const api = useAtomValue(apiAtom); const ControlContext = useControlContext(); @@ -54,14 +52,12 @@ const DropdownViewDirect: React.FC = ({ })) || []; // Combine embedded subs with external subs only if not offline - if (!offline) { - return [...embeddedSubs, ...externalSubs] as ( - | EmbeddedSubtitle - | ExternalSubtitle - )[]; - } + return [...embeddedSubs, ...externalSubs] as ( + | EmbeddedSubtitle + | ExternalSubtitle + )[]; return embeddedSubs as EmbeddedSubtitle[]; - }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]); + }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); const { subtitleIndex, audioIndex } = useLocalSearchParams<{ itemId: string; diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx index 8739b07a..8c6d2799 100644 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx @@ -12,7 +12,6 @@ import { SubtitleHelper } from "@/utils/SubtitleHelper"; interface DropdownViewProps { showControls: boolean; - offline?: boolean; // used to disable external subs for downloads } const DropdownView: React.FC = ({ showControls }) => { diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts deleted file mode 100644 index 4c630710..00000000 --- a/hooks/useDownloadedFileOpener.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { writeToLog } from "@/utils/log"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; -import { useCallback } from "react"; - -export const getDownloadedFileUrl = async (itemId: string): Promise => { - const directory = FileSystem.documentDirectory; - - if (!directory) { - throw new Error("Document directory is not available"); - } - - if (!itemId) { - throw new Error("Item ID is not available"); - } - - const files = await FileSystem.readDirectoryAsync(directory); - const path = itemId!; - const matchingFile = files.find((file) => file.startsWith(path)); - - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); - } - - return `${directory}${matchingFile}`; -}; - -export const useDownloadedFileOpener = () => { - const router = useRouter(); - const { setPlayUrl, setOfflineSettings } = usePlaySettings(); - - const openFile = useCallback( - async (item: BaseItemDto) => { - try { - // @ts-expect-error - router.push("/player/direct-player?offline=true&itemId=" + item.Id); - } catch (error) { - writeToLog("ERROR", "Error opening file", error); - console.error("Error opening file:", error); - } - }, - [setOfflineSettings, setPlayUrl, router] - ); - - return { openFile }; -}; diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts deleted file mode 100644 index 25492e33..00000000 --- a/hooks/useRemuxHlsToMp4.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getItemImage } from "@/utils/getItemImage"; -import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; -import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; -import { useAtomValue } from "jotai"; -import { useCallback } from "react"; -import { toast } from "sonner-native"; -import useImageStorage from "./useImageStorage"; -import useDownloadHelper from "@/utils/download"; -import { Api } from "@jellyfin/sdk"; -import { useSettings } from "@/utils/atoms/settings"; -import { JobStatus } from "@/utils/optimize-server"; - -const createFFmpegCommand = (url: string, output: string) => [ - "-y", // overwrite output files without asking - "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options - - // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html - "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist - "-multiple_requests 1", // http - "-tcp_nodelay 1", // http - // endregion ffmpeg protocol commands - - "-fflags +genpts", // format flags - `-i ${url}`, // infile - "-map 0:v -map 0:a", // select all streams for video & audio - "-c copy", // streamcopy, preventing transcoding - "-bufsize 25M", // amount of data processed before calculating current bitrate - "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output - output, -]; - -/** - * Custom hook for remuxing HLS to MP4 using FFmpeg. - * - * @param url - The URL of the HLS stream - * @param item - The BaseItemDto object representing the media item - * @returns An object with remuxing-related functions - */ -export const useRemuxHlsToMp4 = () => { - const api = useAtomValue(apiAtom); - const router = useRouter(); - const queryClient = useQueryClient(); - - const [settings] = useSettings(); - const { saveImage } = useImageStorage(); - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload(); - - const onSaveAssets = async (api: Api, item: BaseItemDto) => { - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - - await saveImage(item.Id, itemImage?.uri); - }; - - const completeCallback = useCallback( - async (session: FFmpegSession, item: BaseItemDto) => { - try { - console.log("completeCallback"); - const returnCode = await session.getReturnCode(); - - if (returnCode.isValueSuccess()) { - const stat = await session.getLastReceivedStatistics(); - await FileSystem.moveAsync({ - from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, - to: `${FileSystem.documentDirectory}${item.Id}.mp4` - }) - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - saveDownloadedItemInfo(item, stat.getSize()); - toast.success("Download completed"); - } - - setProcesses((prev) => { - return prev.filter((process) => process.itemId !== item.Id); - }); - } catch (e) { - console.error(e); - } - - console.log("completeCallback ~ end"); - }, - [processes, setProcesses] - ); - - const statisticsCallback = useCallback( - (statistics: Statistics, item: BaseItemDto) => { - const videoLength = - (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds - const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; - const totalFrames = videoLength * fps; - const processedFrames = statistics.getVideoFrameNumber(); - const speed = statistics.getSpeed(); - - const percentage = - totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0; - - if (!item.Id) throw new Error("Item is undefined"); - setProcesses((prev) => { - return prev.map((process) => { - if (process.itemId === item.Id) { - return { - ...process, - id: statistics.getSessionId().toString(), - progress: percentage, - speed: Math.max(speed, 0), - }; - } - return process; - }); - }); - }, - [setProcesses, completeCallback] - ); - - const startRemuxing = useCallback( - async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { - const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY); - if (!cacheDir.exists) { - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true}) - } - - const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4` - - if (!api) throw new Error("API is not defined"); - if (!item.Id) throw new Error("Item must have an Id"); - - // First lets save any important assets we want to present to the user offline - await onSaveAssets(api, item); - - toast.success(`Download started for ${item.Name}`, { - action: { - label: "Go to download", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - - try { - const job: JobStatus = { - id: "", - deviceId: "", - inputUrl: url, - item: item, - itemId: item.Id!, - outputPath: output, - progress: 0, - status: "downloading", - timestamp: new Date(), - }; - - writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); - setProcesses((prev) => [...prev, job]); - - await FFmpegKit.executeAsync( - createFFmpegCommand(url, output).join(" "), - (session) => completeCallback(session, item), - undefined, - (s) => statisticsCallback(s, item) - ); - } catch (e) { - const error = e as Error; - console.error("Failed to remux:", error); - writeErrorLog( - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, - Error: ${error.message}, Stack: ${error.stack}` - ); - setProcesses((prev) => { - return prev.filter((process) => process.itemId !== item.Id); - }); - throw error; // Re-throw the error to propagate it to the caller - } - }, - [settings, processes, setProcesses, completeCallback, statisticsCallback] - ); - - const cancelRemuxing = useCallback(() => { - FFmpegKit.cancel(); - setProcesses([]); - }, []); - - return { startRemuxing, cancelRemuxing }; -}; diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 75199b31..114c193d 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -7,21 +7,18 @@ interface UseWebSocketProps { isPlaying: boolean; togglePlay: () => void; stopPlayback: () => void; - offline: boolean; } export const useWebSocket = ({ isPlaying, togglePlay, stopPlayback, - offline, }: UseWebSocketProps) => { const router = useRouter(); const { ws } = useWebSocketContext(); useEffect(() => { if (!ws) return; - if (offline) return; ws.onmessage = (e) => { const json = JSON.parse(e.data); diff --git a/package.json b/package.json index 98827aa9..f7c98d31 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "axios": "^1.7.7", "expo": "~51.0.39", "expo-asset": "~10.0.10", - "expo-background-fetch": "~12.0.1", "expo-blur": "~13.0.2", "expo-brightness": "~12.0.1", "expo-build-properties": "~0.12.5", @@ -78,7 +77,6 @@ "react-native-edge-to-edge": "^1.1.1", "react-native-gesture-handler": "~2.16.1", "react-native-get-random-values": "^1.11.0", - "react-native-google-cast": "^4.8.3", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^2.5.2", "react-native-ios-utilities": "^4.5.1", diff --git a/plugins/withChangeNativeAndroidTextToWhite.js b/plugins/withChangeNativeAndroidTextToWhite.js deleted file mode 100644 index 95c2165a..00000000 --- a/plugins/withChangeNativeAndroidTextToWhite.js +++ /dev/null @@ -1,30 +0,0 @@ -const { readFileSync, writeFileSync } = require("fs"); -const { join } = require("path"); -const { withDangerousMod } = require("@expo/config-plugins"); - -const withChangeNativeAndroidTextToWhite = (expoConfig) => - withDangerousMod(expoConfig, [ - "android", - (modConfig) => { - if (modConfig.modRequest.platform === "android") { - const stylesXmlPath = join( - modConfig.modRequest.platformProjectRoot, - "app", - "src", - "main", - "res", - "values", - "styles.xml" - ); - - let stylesXml = readFileSync(stylesXmlPath, "utf8"); - - stylesXml = stylesXml.replace(/@android:color\/black/g, "@android:color/white"); - - writeFileSync(stylesXmlPath, stylesXml, { encoding: "utf8" }); - } - return modConfig; - }, - ]); - -module.exports = withChangeNativeAndroidTextToWhite; \ No newline at end of file diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js deleted file mode 100644 index 1970ceb7..00000000 --- a/plugins/withRNBackgroundDownloader.js +++ /dev/null @@ -1,48 +0,0 @@ -const { withAppDelegate } = require("@expo/config-plugins"); - -function withRNBackgroundDownloader(expoConfig) { - return withAppDelegate(expoConfig, async (appDelegateConfig) => { - const { modResults: appDelegate } = appDelegateConfig; - const appDelegateLines = appDelegate.contents.split("\n"); - - // Define the code to be added to AppDelegate.mm - const backgroundDownloaderImport = - "#import // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js"; - const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js -- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler -{ - [RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler]; -}`; - - // Find the index of the AppDelegate import statement - const importIndex = appDelegateLines.findIndex((line) => - /^#import "AppDelegate.h"/.test(line) - ); - - // Find the index of the last line before the @end statement - const endStatementIndex = appDelegateLines.findIndex((line) => - /@end/.test(line) - ); - - // Insert the import statement if it's not already present - if (!appDelegate.contents.includes(backgroundDownloaderImport)) { - appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport); - } - - // Insert the delegate method above the @end statement - if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) { - appDelegateLines.splice( - endStatementIndex, - 0, - backgroundDownloaderDelegate - ); - } - - // Update the contents of the AppDelegate file - appDelegate.contents = appDelegateLines.join("\n"); - - return appDelegateConfig; - }); -} - -module.exports = withRNBackgroundDownloader; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx deleted file mode 100644 index 78fbbe6f..00000000 --- a/providers/DownloadProvider.tsx +++ /dev/null @@ -1,716 +0,0 @@ -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import { useLog, writeToLog } from "@/utils/log"; -import { - cancelAllJobs, - cancelJobById, - deleteDownloadItemInfoFromDiskTmp, - getAllJobsByDeviceId, - getDownloadItemInfoFromDiskTmp, - JobStatus, -} from "@/utils/optimize-server"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { - checkForExistingDownloads, - completeHandler, - download, - setConfig, -} from "@kesha-antonov/react-native-background-downloader"; -import MMKV from "react-native-mmkv"; -import { - focusManager, - QueryClient, - QueryClientProvider, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import axios from "axios"; -import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; -import { atom, useAtom } from "jotai"; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { AppState, AppStateStatus, Platform } from "react-native"; -import { toast } from "sonner-native"; -import { apiAtom } from "./JellyfinProvider"; -import * as Notifications from "expo-notifications"; -import { getItemImage } from "@/utils/getItemImage"; -import useImageStorage from "@/hooks/useImageStorage"; -import { storage } from "@/utils/mmkv"; -import useDownloadHelper from "@/utils/download"; -import { FileInfo } from "expo-file-system"; -import * as Haptics from "expo-haptics"; -import * as Application from "expo-application"; - -export type DownloadedItem = { - item: Partial; - mediaSource: MediaSourceInfo; -}; - -export const processesAtom = atom([]); - -function onAppStateChange(status: AppStateStatus) { - focusManager.setFocused(status === "active"); -} - -const DownloadContext = createContext | null>(null); - -function useDownloadProvider() { - const queryClient = useQueryClient(); - const [settings] = useSettings(); - const router = useRouter(); - const [api] = useAtom(apiAtom); - const { logs } = useLog(); - - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveImage } = useImageStorage(); - - const [processes, setProcesses] = useAtom(processesAtom); - - const authHeader = useMemo(() => { - return api?.accessToken; - }, [api]); - - const { data: downloadedFiles, refetch } = useQuery({ - queryKey: ["downloadedItems"], - queryFn: getAllDownloadedItems, - staleTime: 0, - refetchOnMount: true, - refetchOnReconnect: true, - refetchOnWindowFocus: true, - }); - - useEffect(() => { - const subscription = AppState.addEventListener("change", onAppStateChange); - - return () => subscription.remove(); - }, []); - - useQuery({ - queryKey: ["jobs"], - queryFn: async () => { - const deviceId = await getOrSetDeviceId(); - const url = settings?.optimizedVersionsServerUrl; - - if ( - settings?.downloadMethod !== "optimized" || - !url || - !deviceId || - !authHeader - ) - return []; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }); - - const downloadingProcesses = processes - .filter((p) => p.status === "downloading") - .filter((p) => jobs.some((j) => j.id === p.id)); - - const updatedProcesses = jobs.filter( - (j) => !downloadingProcesses.some((p) => p.id === j.id) - ); - - setProcesses([...updatedProcesses, ...downloadingProcesses]); - - for (let job of jobs) { - const process = processes.find((p) => p.id === job.id); - if ( - process && - process.status === "optimizing" && - job.status === "completed" - ) { - if (settings.autoDownload) { - startDownload(job); - } else { - toast.info(`${job.item.Name} is ready to be downloaded`, { - action: { - label: "Go to downloads", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: `${job.item.Name} is ready to be downloaded`, - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - } - } - } - - return jobs; - }, - staleTime: 0, - refetchInterval: 2000, - enabled: settings?.downloadMethod === "optimized", - }); - - useEffect(() => { - const checkIfShouldStartDownload = async () => { - if (processes.length === 0) return; - await checkForExistingDownloads(); - }; - - checkIfShouldStartDownload(); - }, [settings, processes]); - - const removeProcess = useCallback( - async (id: string) => { - const deviceId = await getOrSetDeviceId(); - if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl) - return; - - try { - await cancelJobById({ - authHeader, - id, - url: settings?.optimizedVersionsServerUrl, - }); - } catch (error) { - console.error(error); - } - }, - [settings?.optimizedVersionsServerUrl, authHeader] - ); - - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; - - const startDownload = useCallback( - async (process: JobStatus) => { - if (!process?.item.Id || !authHeader) throw new Error("No item id"); - - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p - ) - ); - - setConfig({ - isLogsEnabled: true, - progressInterval: 500, - headers: { - Authorization: authHeader, - }, - }); - - toast.info(`Download started for ${process.item.Name}`, { - action: { - label: "Go to downloads", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - - const baseDirectory = FileSystem.documentDirectory; - - download({ - id: process.id, - url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${baseDirectory}/${process.item.Id}.mp4`, - }) - .begin(() => { - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p - ) - ); - }) - .progress((data) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: percent, - } - : p - ) - ); - }) - .done(async (doneHandler) => { - await saveDownloadedItemInfo( - process.item, - doneHandler.bytesDownloaded - ); - toast.success(`Download completed for ${process.item.Name}`, { - duration: 3000, - action: { - label: "Go to downloads", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - setTimeout(() => { - completeHandler(process.id); - removeProcess(process.id); - }, 1000); - }) - .error(async (error) => { - removeProcess(process.id); - completeHandler(process.id); - let errorMsg = ""; - if (error.errorCode === 1000) { - errorMsg = "No space left"; - } - if (error.errorCode === 404) { - errorMsg = "File not found on server"; - } - toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`); - writeToLog("ERROR", `Download failed for ${process.item.Name}`, { - error, - processDetails: { - id: process.id, - itemName: process.item.Name, - itemId: process.item.Id, - }, - }); - console.error("Error details:", { - errorCode: error.errorCode, - }); - }); - }, - [queryClient, settings?.optimizedVersionsServerUrl, authHeader] - ); - - const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { - if (!api || !item.Id || !authHeader) - throw new Error("startBackgroundDownload ~ Missing required params"); - - try { - const fileExtension = mediaSource.TranscodingContainer; - const deviceId = await getOrSetDeviceId(); - - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - await saveImage(item.Id, itemImage?.uri); - - const response = await axios.post( - settings?.optimizedVersionsServerUrl + "optimize-version", - { - url, - fileExtension, - deviceId, - itemId: item.Id, - item, - }, - { - headers: { - "Content-Type": "application/json", - Authorization: authHeader, - }, - } - ); - - if (response.status !== 201) { - throw new Error("Failed to start optimization job"); - } - - toast.success(`Queued ${item.Name} for optimization`, { - action: { - label: "Go to download", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - } catch (error) { - writeToLog("ERROR", "Error in startBackgroundDownload", error); - console.error("Error in startBackgroundDownload:", error); - if (axios.isAxiosError(error)) { - console.error("Axios error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - headers: error.response?.headers, - }); - toast.error( - `Failed to start download for ${item.Name}: ${error.message}` - ); - if (error.response) { - toast.error( - `Server responded with status ${error.response.status}` - ); - } else if (error.request) { - toast.error("No response received from server"); - } else { - toast.error("Error setting up the request"); - } - } else { - console.error("Non-Axios error:", error); - toast.error( - `Failed to start download for ${item.Name}: Unexpected error` - ); - } - } - }, - [settings?.optimizedVersionsServerUrl, authHeader] - ); - - const deleteAllFiles = async (): Promise => { - Promise.all([ - deleteLocalFiles(), - removeDownloadedItemsFromStorage(), - cancelAllServerJobs(), - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), - ]) - .then(() => - toast.success("All files, folders, and jobs deleted successfully") - ) - .catch((reason) => { - console.error("Failed to delete all files, folders, and jobs:", reason); - toast.error("An error occurred while deleting files and jobs"); - }); - }; - - const forEveryDocumentDirFile = async ( - includeMMKV: boolean = true, - ignoreList: string[] = [], - callback: (file: FileInfo) => void - ) => { - const baseDirectory = FileSystem.documentDirectory; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } - - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - for (const item of dirContents) { - // Exclude mmkv directory. - // Deleting this deletes all user information as well. Logout should handle this. - if ( - (item == "mmkv" && !includeMMKV) || - ignoreList.some((i) => item.includes(i)) - ) { - continue; - } - await FileSystem.getInfoAsync(`${baseDirectory}${item}`) - .then((itemInfo) => { - if (itemInfo.exists && !itemInfo.isDirectory) { - callback(itemInfo); - } - }) - .catch((e) => console.error(e)); - } - }; - - const deleteLocalFiles = async (): Promise => { - await forEveryDocumentDirFile(false, [], (file) => { - console.warn("Deleting file", file.uri); - FileSystem.deleteAsync(file.uri, { idempotent: true }); - }); - }; - - const removeDownloadedItemsFromStorage = async () => { - // delete any saved images first - Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")]) - .then(() => storage.delete("downloadedItems")) - .catch((reason) => { - console.error("Failed to remove downloadedItems from storage:", reason); - throw reason; - }); - }; - - const cancelAllServerJobs = async (): Promise => { - if (!authHeader) { - throw new Error("No auth header available"); - } - if (!settings?.optimizedVersionsServerUrl) { - console.error("No server URL configured"); - return; - } - - const deviceId = await getOrSetDeviceId(); - if (!deviceId) { - throw new Error("Failed to get device ID"); - } - - try { - await cancelAllJobs({ - authHeader, - url: settings.optimizedVersionsServerUrl, - deviceId, - }); - } catch (error) { - console.error("Failed to cancel all server jobs:", error); - throw error; - } - }; - - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - const directory = FileSystem.documentDirectory; - - if (!directory) { - console.error("Document directory not found"); - return; - } - const dirContents = await FileSystem.readDirectoryAsync(directory); - - for (const item of dirContents) { - const itemNameWithoutExtension = item.split(".")[0]; - if (itemNameWithoutExtension === id) { - const filePath = `${directory}${item}`; - await FileSystem.deleteAsync(filePath, { idempotent: true }); - break; - } - } - - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - let items = JSON.parse(downloadedItems) as DownloadedItem[]; - items = items.filter((item) => item.item.Id !== id); - storage.set("downloadedItems", JSON.stringify(items)); - } - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - } catch (error) { - console.error( - `Failed to delete file and storage entry for ID ${id}:`, - error - ); - } - }; - - const deleteItems = async (items: BaseItemDto[]) => { - Promise.all( - items.map((i) => { - if (i.Id) return deleteFile(i.Id); - return; - }) - ).then(() => - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - ); - }; - - const cleanCacheDirectory = async () => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY - ); - if (cacheDir.exists) { - const cachedFiles = await FileSystem.readDirectoryAsync( - APP_CACHE_DOWNLOAD_DIRECTORY - ); - let position = 0; - const batchSize = 3; - - // batching promise.all to avoid OOM - while (position < cachedFiles.length) { - const itemsForBatch = cachedFiles.slice(position, position + batchSize); - await Promise.all( - itemsForBatch.map(async (file) => { - const info = await FileSystem.getInfoAsync( - `${APP_CACHE_DOWNLOAD_DIRECTORY}${file}` - ); - if (info.exists) { - await FileSystem.deleteAsync(info.uri, { idempotent: true }); - return Promise.resolve(file); - } - return Promise.reject(); - }) - ); - - position += batchSize; - } - } - }; - - const deleteFileByType = async (type: BaseItemDto["Type"]) => { - await Promise.all( - downloadedFiles - ?.filter((file) => file.item.Type == type) - ?.flatMap((file) => { - const promises = []; - if (type == "Episode" && file.item.SeriesId) - promises.push(deleteFile(file.item.SeriesId)); - promises.push(deleteFile(file.item.Id!)); - return promises; - }) || [] - ); - }; - - const appSizeUsage = useMemo(async () => { - const sizes: number[] = - downloadedFiles?.map((d) => { - return getDownloadedItemSize(d.item.Id!!); - }) || []; - - await forEveryDocumentDirFile( - true, - getAllDownloadedItems().map((d) => d.item.Id!!), - (file) => { - if (file.exists) { - sizes.push(file.size); - } - } - ).catch((e) => { - console.error(e); - }); - - return sizes.reduce((sum, size) => sum + size, 0); - }, [logs, downloadedFiles, forEveryDocumentDirFile]); - - function getDownloadedItem(itemId: string): DownloadedItem | null { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - const items: DownloadedItem[] = JSON.parse(downloadedItems); - const item = items.find((i) => i.item.Id === itemId); - return item || null; - } - return null; - } catch (error) { - console.error(`Failed to retrieve item with ID ${itemId}:`, error); - return null; - } - } - - function getAllDownloadedItems(): DownloadedItem[] { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - return JSON.parse(downloadedItems) as DownloadedItem[]; - } else { - return []; - } - } catch (error) { - console.error("Failed to retrieve downloaded items:", error); - return []; - } - } - - function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) { - try { - const downloadedItems = storage.getString("downloadedItems"); - let items: DownloadedItem[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id); - - const data = getDownloadItemInfoFromDiskTmp(item.Id!); - - if (!data?.mediaSource) - throw new Error( - "Media source not found in tmp storage. Did you forget to save it before starting download?" - ); - - const newItem = { item, mediaSource: data.mediaSource }; - - if (existingItemIndex !== -1) { - items[existingItemIndex] = newItem; - } else { - items.push(newItem); - } - - deleteDownloadItemInfoFromDiskTmp(item.Id!); - - storage.set("downloadedItems", JSON.stringify(items)); - storage.set("downloadedItemSize-" + item.Id, size.toString()); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - refetch(); - } catch (error) { - console.error( - "Failed to save downloaded item information with media source:", - error - ); - } - } - - function getDownloadedItemSize(itemId: string): number { - const size = storage.getString("downloadedItemSize-" + itemId); - return size ? parseInt(size) : 0; - } - - return { - processes, - startBackgroundDownload, - downloadedFiles, - deleteAllFiles, - deleteFile, - deleteItems, - saveDownloadedItemInfo, - removeProcess, - setProcesses, - startDownload, - getDownloadedItem, - deleteFileByType, - appSizeUsage, - getDownloadedItemSize, - APP_CACHE_DOWNLOAD_DIRECTORY, - cleanCacheDirectory, - }; -} - -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadProviderValue = useDownloadProvider(); - - return ( - - {children} - - ); -} - -export function useDownload() { - const context = useContext(DownloadContext); - if (context === null) { - throw new Error("useDownload must be used within a DownloadProvider"); - } - return context; -} diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts deleted file mode 100644 index 70f85de9..00000000 --- a/utils/atoms/queue.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; -import {JobStatus} from "@/utils/optimize-server"; -import {processesAtom} from "@/providers/DownloadProvider"; -import {useSettings} from "@/utils/atoms/settings"; - -export interface Job { - id: string; - item: BaseItemDto; - execute: () => void | Promise; -} - -export const runningAtom = atom(false); - -export const queueAtom = atom([]); - -export const queueActions = { - enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => { - const updatedQueue = [...queue, ...job]; - console.info("Enqueueing job", job, updatedQueue); - setQueue(updatedQueue); - }, - processJob: async ( - queue: Job[], - setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void - ) => { - const [job, ...rest] = queue; - - console.info("Processing job", job); - - setProcessing(true); - - // Allow job to execute so that it gets added as a processes first BEFORE updating new queue - try { - await job.execute(); - } finally { - setQueue(rest); - } - - console.info("Job done", job); - - setProcessing(false); - }, - clear: ( - setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void - ) => { - setQueue([]); - setProcessing(false); - }, -}; - -export const useJobProcessor = () => { - const [queue, setQueue] = useAtom(queueAtom); - const [running, setRunning] = useAtom(runningAtom); - const [processes] = useAtom(processesAtom); - const [settings] = useSettings(); - - useEffect(() => { - if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) { - console.info("Processing queue", queue); - queueActions.processJob(queue, setQueue, setRunning); - } - }, [processes, queue, running, setQueue, setRunning]); -}; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index c37dd4eb..f38cee36 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -8,13 +8,6 @@ import { SubtitlePlaybackMode, } from "@jellyfin/sdk/lib/generated-client"; -export type DownloadQuality = "original" | "high" | "low"; - -export type DownloadOption = { - label: string; - value: DownloadQuality; -}; - export const ScreenOrientationEnum: Record< ScreenOrientation.OrientationLock, string @@ -31,21 +24,6 @@ export const ScreenOrientationEnum: Record< [ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown", }; -export const DownloadOptions: DownloadOption[] = [ - { - label: "Original quality", - value: "original", - }, - { - label: "High quality", - value: "high", - }, - { - label: "Small file size", - value: "low", - }, -]; - export type LibraryOptions = { display: "row" | "list"; cardStyle: "compact" | "detailed"; @@ -68,7 +46,6 @@ export type Settings = { searchEngine: "Marlin" | "Jellyfin"; marlinServerUrl?: string; openInVLC?: boolean; - downloadQuality?: DownloadOption; libraryOptions: LibraryOptions; defaultAudioLanguage: CultureDto | null; playDefaultAudioTrack: boolean; @@ -81,8 +58,6 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - downloadMethod: "optimized" | "remux"; - autoDownload: boolean; showCustomMenuLinks: boolean; subtitleSize: number; remuxConcurrentLimit: 1 | 2 | 3 | 4; @@ -100,7 +75,6 @@ const loadSettings = (): Settings => { searchEngine: "Jellyfin", marlinServerUrl: "", openInVLC: false, - downloadQuality: DownloadOptions[0], libraryOptions: { display: "list", cardStyle: "detailed", @@ -119,8 +93,6 @@ const loadSettings = (): Settings => { forwardSkipTime: 30, rewindSkipTime: 10, optimizedVersionsServerUrl: null, - downloadMethod: "remux", - autoDownload: false, showCustomMenuLinks: false, subtitleSize: Platform.OS === "ios" ? 60 : 100, remuxConcurrentLimit: 1, diff --git a/utils/download.ts b/utils/download.ts deleted file mode 100644 index 94a06b7a..00000000 --- a/utils/download.ts +++ /dev/null @@ -1,33 +0,0 @@ -import useImageStorage from "@/hooks/useImageStorage"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; -import { storage } from "@/utils/mmkv"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useAtom } from "jotai"; - -const useDownloadHelper = () => { - const [api] = useAtom(apiAtom); - const { saveImage } = useImageStorage(); - - const saveSeriesPrimaryImage = async (item: BaseItemDto) => { - console.log(`Attempting to save primary image for item: ${item.Id}`); - if ( - item.Type === "Episode" && - item.SeriesId && - !storage.getString(item.SeriesId) - ) { - console.log(`Saving primary image for series: ${item.SeriesId}`); - await saveImage( - item.SeriesId, - getPrimaryImageUrlById({ api, id: item.SeriesId }) - ); - console.log(`Primary image saved for series: ${item.SeriesId}`); - } else { - console.log(`Skipping primary image save for item: ${item.Id}`); - } - }; - - return { saveSeriesPrimaryImage }; -}; - -export default useDownloadHelper; diff --git a/utils/log.tsx b/utils/log.tsx index 7c432406..18fcf344 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -1,7 +1,7 @@ import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { storage } from "./mmkv"; -import {useQuery} from "@tanstack/react-query"; -import React, {createContext, useContext} from "react"; +import { useQuery } from "@tanstack/react-query"; +import React, { createContext, useContext } from "react"; type LogLevel = "INFO" | "WARN" | "ERROR"; @@ -19,10 +19,9 @@ const mmkvStorage = createJSONStorage(() => ({ })); const logsAtom = atomWithStorage("logs", [], mmkvStorage); -const LogContext = createContext | null>(null); -const DownloadContext = createContext | null>(null); +const LogContext = createContext | null>( + null +); function useLogProvider() { const { data: logs } = useQuery({ @@ -32,11 +31,10 @@ function useLogProvider() { }); return { - logs - } + logs, + }; } - export const writeToLog = (level: LogLevel, message: string, data?: any) => { const newEntry: LogEntry = { timestamp: new Date().toISOString(), @@ -55,8 +53,10 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => { storage.set("logs", JSON.stringify(recentLogs)); }; -export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data); -export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data); +export const writeInfoLog = (message: string, data?: any) => + writeToLog("INFO", message, data); +export const writeErrorLog = (message: string, data?: any) => + writeToLog("ERROR", message, data); export const readFromLog = (): LogEntry[] => { const logs = storage.getString("logs"); @@ -75,14 +75,10 @@ export function useLog() { return context; } -export function LogProvider({children}: { children: React.ReactNode }) { +export function LogProvider({ children }: { children: React.ReactNode }) { const provider = useLogProvider(); - return ( - - {children} - - ) + return {children}; } export default logsAtom; diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts deleted file mode 100644 index 61d17a9a..00000000 --- a/utils/optimize-server.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { itemRouter } from "@/components/common/TouchableItemRouter"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import axios from "axios"; -import { writeToLog } from "./log"; -import { DownloadedItem } from "@/providers/DownloadProvider"; -import { MMKV } from "react-native-mmkv"; - -interface IJobInput { - deviceId?: string | null; - authHeader?: string | null; - url?: string | null; -} - -export interface JobStatus { - id: string; - status: - | "queued" - | "optimizing" - | "completed" - | "failed" - | "cancelled" - | "downloading"; - progress: number; - outputPath: string; - inputUrl: string; - deviceId: string; - itemId: string; - item: BaseItemDto; - speed?: number; - timestamp: Date; - base64Image?: string; -} - -/** - * Fetches all jobs for a specific device. - * - * @param {IGetAllDeviceJobs} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch jobs for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to an array of job statuses. - * - * @throws {Error} Throws an error if the API request fails or returns a non-200 status code. - */ -export async function getAllJobsByDeviceId({ - deviceId, - authHeader, - url, -}: IJobInput): Promise { - const statusResponse = await axios.get(`${url}all-jobs`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - if (statusResponse.status !== 200) { - console.error( - statusResponse.status, - statusResponse.data, - statusResponse.statusText - ); - throw new Error("Failed to fetch job status"); - } - - return statusResponse.data; -} - -interface ICancelJob { - authHeader: string; - url: string; - id: string; -} - -export async function cancelJobById({ - authHeader, - url, - id, -}: ICancelJob): Promise { - const statusResponse = await axios.delete(`${url}cancel-job/${id}`, { - headers: { - Authorization: authHeader, - }, - }); - if (statusResponse.status !== 200) { - throw new Error("Failed to cancel process"); - } - - return true; -} - -export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { - if (!deviceId) return false; - if (!authHeader) return false; - if (!url) return false; - - try { - await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }).then((jobs) => { - jobs.forEach((job) => { - cancelJobById({ - authHeader, - url, - id: job.id, - }); - }); - }); - } catch (error) { - writeToLog("ERROR", "Failed to cancel all jobs", error); - console.error(error); - return false; - } - - return true; -} - -/** - * Fetches statistics for a specific device. - * - * @param {IJobInput} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch statistics for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to the statistics data or null if the request fails. - * - * @throws {Error} Throws an error if any required parameter is missing. - */ -export async function getStatistics({ - authHeader, - url, - deviceId, -}: IJobInput): Promise { - if (!deviceId || !authHeader || !url) { - return null; - } - - try { - const statusResponse = await axios.get(`${url}statistics`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - - return statusResponse.data; - } catch (error) { - console.error("Failed to fetch statistics:", error); - return null; - } -} - -/** - * Saves the download item info to disk - this data is used temporarily to fetch additional download information - * in combination with the optimize server. This is used to not have to send all item info to the optimize server. - * - * @param {BaseItemDto} item - The item to save. - * @param {MediaSourceInfo} mediaSource - The media source of the item. - * @param {string} url - The URL of the item. - * @return {boolean} A promise that resolves when the item info is saved. - */ -export function saveDownloadItemInfoToDiskTmp( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - url: string -): boolean { - try { - const storage = new MMKV(); - - const downloadInfo = JSON.stringify({ - item, - mediaSource, - url, - }); - - storage.set(`tmp_download_info_${item.Id}`, downloadInfo); - - return true; - } catch (error) { - console.error("Failed to save download item info to disk:", error); - throw error; - } -} - -/** - * Retrieves the download item info from disk. - * - * @param {string} itemId - The ID of the item to retrieve. - * @return {{ - * item: BaseItemDto; - * mediaSource: MediaSourceInfo; - * url: string; - * } | null} The retrieved download item info or null if not found. - */ -export function getDownloadItemInfoFromDiskTmp(itemId: string): { - item: BaseItemDto; - mediaSource: MediaSourceInfo; - url: string; -} | null { - try { - const storage = new MMKV(); - const rawInfo = storage.getString(`tmp_download_info_${itemId}`); - - if (rawInfo) { - return JSON.parse(rawInfo); - } - return null; - } catch (error) { - console.error("Failed to retrieve download item info from disk:", error); - return null; - } -} - -/** - * Deletes the download item info from disk. - * - * @param {string} itemId - The ID of the item to delete. - * @return {boolean} True if the item info was successfully deleted, false otherwise. - */ -export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean { - try { - const storage = new MMKV(); - storage.delete(`tmp_download_info_${itemId}`); - return true; - } catch (error) { - console.error("Failed to delete download item info from disk:", error); - return false; - } -}