fix: remove anything regarding downloads

This commit is contained in:
Fredrik Burmester
2025-01-02 09:44:32 +01:00
parent 4b81dff0be
commit 349a86bcfb
37 changed files with 101 additions and 3599 deletions

View File

@@ -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": {

View File

@@ -31,18 +31,6 @@ export default function IndexLayout() {
),
}}
/>
<Stack.Screen
name="downloads/index"
options={{
title: "Downloads",
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
}}
/>
<Stack.Screen
name="settings"
options={{

View File

@@ -1,132 +0,0 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
);
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<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
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 (
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

View File

@@ -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<BottomSheetModal>(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: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity>
)
})
}, [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 (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
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}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
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(),
},
]
);
}

View File

@@ -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<boolean | null>(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: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [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 (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">

View File

@@ -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() {
<SettingToggles />
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
/>
{size && (
<Text>
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
{size.total?.bytesToReadable()}
</Text>
)}
</View>
<Button color="red" onPress={onDeleteClicked}>
Delete all downloaded files
</Button>
<Button color="red" onPress={onClearLogsClicked}>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>
<View className="flex flex-col space-y-2">

View File

@@ -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 && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
title="Download Series"
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
</View>
),
});
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl) return null;
return (

View File

@@ -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<VlcPlayerViewRef>(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}

View File

@@ -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<Settings> = 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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
@@ -314,62 +75,60 @@ function Layout() {
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/trailer/page"
options={{
headerShown: false,
presentation: "modal",
title: "",
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/trailer/page"
options={{
headerShown: false,
presentation: "modal",
title: "",
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
@@ -380,24 +139,3 @@ function Layout() {
</GestureHandlerRootView>
);
}
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);
}
}

View File

@@ -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";

BIN
bun.lockb

Binary file not shown.

View File

@@ -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<DownloadProps> = ({
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<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
[settings]
);
const bottomSheetModalRef = useRef<BottomSheetModal>(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) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
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 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
};
const onButtonPress = () => {
if (processes && itemsProcesses.length > 0) {
navigateToDownloads();
} else if (itemsQueued) {
navigateToDownloads();
} else if (allItemsDownloaded) {
onDownloadedPress();
} else {
handlePresentModalPress();
}
};
return (
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
{renderButtonContent()}
</RoundButton>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={setMaxBitrate}
selected={maxBitrate}
/>
{itemsNotDownloaded.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)}
</View>
<Button
className="mt-auto"
onPress={acceptDownloadOptions}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer
? "Using optimized server"
: "Using default method"}
</Text>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};
export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
return (
<DownloadItems
size={size}
title="Download Episode"
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);
};

View File

@@ -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(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
</View>
)}

View File

@@ -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> = ({ ...props }) => {
const { processes } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text>
<Text className="opacity-50">No active downloads</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>
);
};
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 (
<TouchableOpacity
onPress={() => 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") && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
)}
<View className="px-3 py-1.5 flex flex-col w-full">
<View className="flex flex-row items-center w-full">
{base64Image && (
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
)}
<View className="shrink mb-1">
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<Text className="font-semibold shrink">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.ProductionYear}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text>
)}
</View>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs capitalize">{process.status}</Text>
</View>
</View>
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className="ml-auto"
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons name="close" size={24} color="red" />
)}
</TouchableOpacity>
</View>
{process.status === "completed" && (
<View className="flex flex-row mt-4 space-x-4">
<Button
onPress={() => {
startDownload(process);
}}
className="w-full"
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -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<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const [size, setSize] = useState<string | undefined>();
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 (
<>
<Text className="text-xs text-neutral-500" {...props}>
{sizeText}
</Text>
</>
);
};

View File

@@ -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<EpisodeCardProps> = ({ 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 (
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
key={item.Id}
className="flex flex-col mb-4"
>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
</View>
<View className="shrink">
<Text numberOfLines={2} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
<DownloadSize items={[item]} />
</View>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.Overview}
</Text>
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
props
) => (
<ActionSheetProvider>
<EpisodeCard {...props} />
</ActionSheetProvider>
);

View File

@@ -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<MovieCardProps> = ({ 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 (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<View className="w-28">
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
<ActionSheetProvider>
<MovieCard {...props} />
</ActionSheetProvider>
);

View File

@@ -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 (
<TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<View className="w-28 mt-2 flex flex-col">
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
<DownloadSize items={items} />
</View>
</TouchableOpacity>
);
};

View File

@@ -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<Props> = ({ item, initialSeasonIndex }) => {
}));
}}
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
) : null}
</View>
<View className="px-4 flex flex-col mt-4">
{isFetching ? (
@@ -193,9 +179,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className="self-start ml-auto -mt-0.5">
<DownloadSingleItem item={e} />
</View>
</View>
<Text

View File

@@ -1,27 +1,10 @@
import { useDownload } from "@/providers/DownloadProvider";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import {
ScreenOrientationEnum,
Settings,
useSettings,
} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useState } from "react";
import {
Linking,
Switch,
@@ -29,66 +12,27 @@ import {
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
import { Stepper } from "@/components/inputs/Stepper";
import { MediaProvider } from "./MediaContext";
import { SubtitleToggles } from "./SubtitleToggles";
import { AudioToggles } from "./AudioToggles";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { ListItem } from "@/components/ListItem";
import { JellyseerrSettings } from "./Jellyseerr";
import { MediaProvider } from "./MediaContext";
import { MediaToggles } from "./MediaToggles";
import { SubtitleToggles } from "./SubtitleToggles";
interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(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> = ({ ...props }) => {
</View>
</View>
</View>
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Downloads</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download method</Text>
<Text className="text-xs opacity-50">
Choose the download method to use. Optimized requires the
optimized server.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the
same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) =>
updateSettings({
remuxConcurrentLimit:
value as Settings["remuxConcurrentLimit"],
})
}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink">
This will automatically download the media file when it's
finished optimizing on the server.
</Text>
</View>
<Switch
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col bg-neutral-900 px-4 py-4">
<View className="flex flex-col shrink mb-2">
<View className="flex flex-row justify-between items-center">
<Text className="font-semibold">
Optimized versions server
</Text>
</View>
<Text className="text-xs opacity-50">
Set the URL for the optimized versions server for downloads.
</Text>
</View>
<View></View>
<View className="flex flex-col">
<Input
placeholder="Optimized versions server URL..."
value={optimizedVersionsServerUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
/>
<Button
color="purple"
className="h-12 mt-2"
onPress={async () => {
updateSettings({
optimizedVersionsServerUrl:
optimizedVersionsServerUrl.length === 0
? null
: optimizedVersionsServerUrl.endsWith("/")
? optimizedVersionsServerUrl
: optimizedVersionsServerUrl + "/",
});
const res = await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: await getOrSetDeviceId(),
});
if (res) {
toast.success("Connected");
} else toast.error("Could not connect");
}}
>
Save
</Button>
</View>
</View>
</View>
</View>
</View>
<JellyseerrSettings />
</View>
);

View File

@@ -109,7 +109,6 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
@@ -124,7 +123,7 @@ export const Controls: React.FC<Props> = ({
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<Props> = ({
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
offline ? undefined : item.Id,
item.Id,
currentTime,
seek,
play,
@@ -150,7 +149,7 @@ export const Controls: React.FC<Props> = ({
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
offline ? undefined : item.Id,
item.Id,
currentTime,
seek,
play,
@@ -547,7 +546,7 @@ export const Controls: React.FC<Props> = ({
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{item?.Type === "Episode" && !offline && (
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
@@ -557,7 +556,7 @@ export const Controls: React.FC<Props> = ({
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && !offline && (
{previousItem && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -566,7 +565,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
)}
{nextItem && !offline && (
{nextItem && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"

View File

@@ -11,7 +11,6 @@ import { Ionicons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
@@ -233,9 +232,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
<View className="self-start mt-2">
<DownloadSingleItem item={_item} />
</View>
<Text
numberOfLines={5}
className="text-xs text-neutral-500 shrink"

View File

@@ -11,12 +11,10 @@ import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
@@ -54,14 +52,12 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
})) || [];
// 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;

View File

@@ -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<DropdownViewProps> = ({ showControls }) => {

View File

@@ -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<string> => {
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 };
};

View File

@@ -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 };
};

View File

@@ -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);

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 <RNBackgroundDownloader.h> // 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;

View File

@@ -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<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | 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<JobStatus[]>(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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -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<void>;
}
export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
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<JobStatus[]>(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]);
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
const LogContext = createContext<ReturnType<typeof useLogProvider> | 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 (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
return <LogContext.Provider value={provider}>{children}</LogContext.Provider>;
}
export default logsAtom;

View File

@@ -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<JobStatus[]>} 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<JobStatus[]> {
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<boolean> {
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<any | null>} 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<any | null> {
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;
}
}