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,
- }}
- />
- null,
- }}
- />
-
-
-
-
-
+
+
+
+ null,
}}
- closeButton
/>
-
-
-
+ 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;
- }
-}