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 770f9413..f0485123 100755 Binary files a/bun.lockb and b/bun.lockb differ 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; - } -}