diff --git a/app.json b/app.json
index 36c126c2..c7d3db86 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.15.0",
+ "version": "0.16.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -19,7 +19,7 @@
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
- "UIBackgroundModes": ["audio"],
+ "UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 41,
+ "versionCode": 42,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -43,11 +43,6 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
},
- "web": {
- "bundler": "metro",
- "output": "static",
- "favicon": "./assets/images/favicon.png"
- },
"plugins": [
"expo-router",
"expo-font",
@@ -82,7 +77,7 @@
"expo-build-properties",
{
"ios": {
- "deploymentTarget": "14.0"
+ "deploymentTarget": "15.6"
},
"android": {
"android": {
diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx
index 706e3581..30ba6352 100644
--- a/app/(auth)/(tabs)/(home)/downloads.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads.tsx
@@ -1,32 +1,23 @@
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 { Loader } from "@/components/Loader";
-import { runningProcesses } from "@/utils/atoms/downloads";
+import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
+import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
-import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
- const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
+ const { removeProcess, downloadedFiles } = useDownload();
- const { data: downloadedFiles, isLoading } = useQuery({
- queryKey: ["downloaded_files", process?.item.Id],
- queryFn: async () =>
- JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) || "[]"
- ) as BaseItemDto[],
- staleTime: 0,
- });
+ const [settings] = useSettings();
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
@@ -43,27 +34,8 @@ const downloads: React.FC = () => {
return Object.values(series);
}, [downloadedFiles]);
- const eta = useMemo(() => {
- const length = process?.item?.RunTimeTicks || 0;
-
- if (!process?.speed || !process?.progress) return "";
-
- const timeLeft =
- (length - length * (process.progress / 100)) / process.speed;
-
- return formatNumber(timeLeft / 10000);
- }, [process]);
-
const insets = useSafeAreaInsets();
- if (isLoading) {
- return (
-
-
-
- );
- }
-
return (
{
paddingBottom: 100,
}}
>
-
-
-
- Queue
-
- {queue.map((q) => (
-
- 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"
- >
-
- {q.item.Name}
- {q.item.Type}
-
+
+
+ {settings?.downloadMethod === "remux" && (
+
+ Queue
+
+ Queue and downloads will be lost on app restart
+
+
+ {queue.map((q) => (
{
- setQueue((prev) => prev.filter((i) => i.id !== q.id));
- }}
+ 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"
>
-
-
-
- ))}
-
-
- {queue.length === 0 && (
- No items in queue
- )}
-
-
-
- Active download
- {process?.item ? (
-
- router.push(`/(auth)/items/page?id=${process.item.Id}`)
- }
- className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
- >
-
- {process.item.Name}
-
- {process.item.Type}
-
-
-
- {process.progress.toFixed(0)}%
-
-
- {process.speed?.toFixed(2)}x
-
- ETA {eta}
+ {q.item.Name}
+ {q.item.Type}
-
-
- {
- FFmpegKit.cancel();
- setProcess(null);
- }}
- >
-
-
-
-
- ) : (
- No active downloads
- )}
-
+ {
+ removeProcess(q.id);
+ setQueue((prev) => {
+ if (!prev) return [];
+ return [...prev.filter((i) => i.id !== q.id)];
+ });
+ }}
+ >
+
+
+
+ ))}
+
+
+ {queue.length === 0 && (
+ No items in queue
+ )}
+
+ )}
+
+
+
{movies.length > 0 && (
-
- Movies
+
+ Movies
{movies?.length}
- {movies?.map((item: BaseItemDto) => (
-
-
+
+
+ {movies?.map((item: BaseItemDto) => (
+
+
+
+ ))}
- ))}
+
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
))}
+ {downloadedFiles?.length === 0 && (
+
+ No downloaded items
+
+ )}
);
};
export default downloads;
-
-/*
- * Format a number (Date.getTime) to a human readable string ex. 2m 34s
- * @param {number} num - The number to format
- *
- * @returns {string} - The formatted string
- */
-const formatNumber = (num: number) => {
- const minutes = Math.floor(num / 60000);
- const seconds = ((num % 60000) / 1000).toFixed(0);
- return `${minutes}m ${seconds}s`;
-};
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index c60a99a1..d4a56945 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
+import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
@@ -26,7 +27,6 @@ import {
ActivityIndicator,
Platform,
RefreshControl,
- SafeAreaView,
ScrollView,
TouchableOpacity,
View,
@@ -192,18 +192,24 @@ export default function index() {
const refetch = useCallback(async () => {
setLoading(true);
- await queryClient.refetchQueries({ queryKey: ["userViews"] });
- await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
- await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
- await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
- await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
- await queryClient.refetchQueries({ queryKey: ["suggestions"] });
- await queryClient.refetchQueries({
- queryKey: ["sf_promoted"],
- });
- await queryClient.refetchQueries({
- queryKey: ["sf_carousel"],
- });
+ await queryClient.invalidateQueries();
+ // await queryClient.invalidateQueries({ queryKey: ["userViews"] });
+ // await queryClient.invalidateQueries({ queryKey: ["resumeItems"] });
+ // await queryClient.invalidateQueries({ queryKey: ["continueWatching"] });
+ // await queryClient.invalidateQueries({ queryKey: ["nextUp-all"] });
+ // await queryClient.invalidateQueries({
+ // queryKey: ["recentlyAddedInMovies"],
+ // });
+ // await queryClient.invalidateQueries({
+ // queryKey: ["recentlyAddedInTVShows"],
+ // });
+ // await queryClient.invalidateQueries({ queryKey: ["suggestions"] });
+ // await queryClient.invalidateQueries({
+ // queryKey: ["sf_promoted"],
+ // });
+ // await queryClient.invalidateQueries({
+ // queryKey: ["sf_carousel"],
+ // });
setLoading(false);
}, [queryClient, user?.Id]);
@@ -397,13 +403,34 @@ export default function index() {
}
key={"home"}
contentContainerStyle={{
+ flexDirection: "column",
paddingLeft: insets.left,
paddingRight: insets.right,
+ paddingTop: 8,
+ paddingBottom: 8,
+ rowGap: 8,
+ }}
+ style={{
+ marginBottom: TAB_HEIGHT,
}}
- className="flex flex-col space-y-4 mb-20"
>
+
+ (
+ await getItemsApi(api).getResumeItems({
+ userId: user?.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ })
+ ).data.Items || []
+ }
+ orientation={"horizontal"}
+ />
+
-
-
- (
- await getItemsApi(api).getResumeItems({
- userId: user?.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- })
).data.Items || []
}
orientation={"horizontal"}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index cf3d44ec..7077a16f 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -2,22 +2,20 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
-import { useFiles } from "@/hooks/useFiles";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
-import { Ionicons } from "@expo/vector-icons";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
-import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
export default function settings() {
const { logout } = useJellyfin();
- const { deleteAllFiles } = useFiles();
+ const { deleteAllFiles } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -41,7 +39,6 @@ export default function settings() {
code: text,
userId: user?.Id,
});
- console.log(res.status, res.statusText, res.data);
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
@@ -69,12 +66,20 @@ export default function settings() {
}}
>
+ {/* */}
Information
+
@@ -87,18 +92,6 @@ export default function settings() {
-
- Tests
-
-
-
Account and storage
@@ -108,10 +101,17 @@ export default function settings() {
+
+ {settings?.downloadMethod === "optimized" ? (
+ Using optimized server
+ ) : (
+ Using default method
+ )}
+
diff --git a/components/FullScreenMusicPlayer.tsx b/components/FullScreenMusicPlayer.tsx
new file mode 100644
index 00000000..94c6b57a
--- /dev/null
+++ b/components/FullScreenMusicPlayer.tsx
@@ -0,0 +1,544 @@
+import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
+import { useCreditSkipper } from "@/hooks/useCreditSkipper";
+import { useIntroSkipper } from "@/hooks/useIntroSkipper";
+import { useTrickplay } from "@/hooks/useTrickplay";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { usePlayback } from "@/providers/PlaybackProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import { writeToLog } from "@/utils/log";
+import orientationToOrientationLock from "@/utils/OrientationLockConverter";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { formatTimeString, ticksToSeconds } from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useRouter, useSegments } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
+import { useAtom } from "jotai";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Alert,
+ BackHandler,
+ Dimensions,
+ Pressable,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { Slider } from "react-native-awesome-slider";
+import {
+ runOnJS,
+ useAnimatedReaction,
+ useSharedValue,
+} from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Video, { OnProgressData } from "react-native-video";
+import { Text } from "./common/Text";
+import { itemRouter } from "./common/TouchableItemRouter";
+import { Loader } from "./Loader";
+
+const windowDimensions = Dimensions.get("window");
+const screenDimensions = Dimensions.get("screen");
+
+export const FullScreenMusicPlayer: React.FC = () => {
+ const {
+ currentlyPlaying,
+ pauseVideo,
+ playVideo,
+ stopPlayback,
+ setIsPlaying,
+ isPlaying,
+ videoRef,
+ onProgress,
+ setIsBuffering,
+ } = usePlayback();
+
+ const [settings] = useSettings();
+ const [api] = useAtom(apiAtom);
+ const router = useRouter();
+ const segments = useSegments();
+ const insets = useSafeAreaInsets();
+
+ const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
+
+ const [showControls, setShowControls] = useState(true);
+ const [isBuffering, setIsBufferingState] = useState(true);
+
+ // Seconds
+ const [currentTime, setCurrentTime] = useState(0);
+ const [remainingTime, setRemainingTime] = useState(0);
+
+ const isSeeking = useSharedValue(false);
+
+ const cacheProgress = useSharedValue(0);
+ const progress = useSharedValue(0);
+ const min = useSharedValue(0);
+ const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
+
+ const [dimensions, setDimensions] = useState({
+ window: windowDimensions,
+ screen: screenDimensions,
+ });
+
+ useEffect(() => {
+ const subscription = Dimensions.addEventListener(
+ "change",
+ ({ window, screen }) => {
+ setDimensions({ window, screen });
+ }
+ );
+ return () => subscription?.remove();
+ });
+
+ const from = useMemo(() => segments[2], [segments]);
+
+ const updateTimes = useCallback(
+ (currentProgress: number, maxValue: number) => {
+ const current = ticksToSeconds(currentProgress);
+ const remaining = ticksToSeconds(maxValue - current);
+
+ setCurrentTime(current);
+ setRemainingTime(remaining);
+ },
+ []
+ );
+
+ const { showSkipButton, skipIntro } = useIntroSkipper(
+ currentlyPlaying?.item.Id,
+ currentTime,
+ videoRef
+ );
+
+ const { showSkipCreditButton, skipCredit } = useCreditSkipper(
+ currentlyPlaying?.item.Id,
+ currentTime,
+ videoRef
+ );
+
+ useAnimatedReaction(
+ () => ({
+ progress: progress.value,
+ max: max.value,
+ isSeeking: isSeeking.value,
+ }),
+ (result) => {
+ if (result.isSeeking === false) {
+ runOnJS(updateTimes)(result.progress, result.max);
+ }
+ },
+ [updateTimes]
+ );
+
+ useEffect(() => {
+ const backAction = () => {
+ if (currentlyPlaying) {
+ Alert.alert("Hold on!", "Are you sure you want to exit?", [
+ {
+ text: "Cancel",
+ onPress: () => null,
+ style: "cancel",
+ },
+ {
+ text: "Yes",
+ onPress: () => {
+ stopPlayback();
+ router.back();
+ },
+ },
+ ]);
+ return true;
+ }
+ return false;
+ };
+
+ const backHandler = BackHandler.addEventListener(
+ "hardwareBackPress",
+ backAction
+ );
+
+ return () => backHandler.remove();
+ }, [currentlyPlaying, stopPlayback, router]);
+
+ const poster = useMemo(() => {
+ if (!currentlyPlaying?.item || !api) return "";
+ return currentlyPlaying.item.Type === "Audio"
+ ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: currentlyPlaying.item,
+ quality: 70,
+ width: 200,
+ });
+ }, [currentlyPlaying?.item, api]);
+
+ const videoSource = useMemo(() => {
+ if (!api || !currentlyPlaying || !poster) return null;
+ const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
+ ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+ return {
+ uri: currentlyPlaying.url,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
+ title: currentlyPlaying.item?.Name || "Unknown",
+ description: currentlyPlaying.item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: currentlyPlaying.item?.Album ?? undefined,
+ },
+ };
+ }, [currentlyPlaying, api, poster]);
+
+ useEffect(() => {
+ if (currentlyPlaying) {
+ progress.value =
+ currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
+ max.value = currentlyPlaying.item.RunTimeTicks || 0;
+ setShowControls(true);
+ playVideo();
+ }
+ }, [currentlyPlaying]);
+
+ const toggleControls = () => setShowControls(!showControls);
+
+ const handleVideoProgress = useCallback(
+ (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ setIsBufferingState(data.playableDuration === 0);
+ setIsBuffering(data.playableDuration === 0);
+ onProgress(data);
+ },
+ [onProgress, setIsBuffering, isSeeking]
+ );
+
+ const handleVideoError = useCallback(
+ (e: any) => {
+ console.log(e);
+ writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
+ Alert.alert("Error", "Cannot play this video file.");
+ setIsPlaying(false);
+ },
+ [setIsPlaying]
+ );
+
+ const handlePlayPause = useCallback(() => {
+ if (isPlaying) pauseVideo();
+ else playVideo();
+ }, [isPlaying, pauseVideo, playVideo]);
+
+ const handleSliderComplete = (value: number) => {
+ progress.value = value;
+ isSeeking.value = false;
+ videoRef.current?.seek(value / 10000000);
+ };
+
+ const handleSliderChange = (value: number) => {};
+
+ const handleSliderStart = useCallback(() => {
+ if (showControls === false) return;
+ isSeeking.value = true;
+ }, []);
+
+ const handleSkipBackward = useCallback(async () => {
+ if (!settings) return;
+ try {
+ const curr = await videoRef.current?.getCurrentPosition();
+ if (curr !== undefined) {
+ videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
+ }
+ } catch (error) {
+ writeToLog("ERROR", "Error seeking video backwards", error);
+ }
+ }, [settings]);
+
+ const handleSkipForward = useCallback(async () => {
+ if (!settings) return;
+ try {
+ const curr = await videoRef.current?.getCurrentPosition();
+ if (curr !== undefined) {
+ videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
+ }
+ } catch (error) {
+ writeToLog("ERROR", "Error seeking video forwards", error);
+ }
+ }, [settings]);
+
+ const handleGoToPreviousItem = useCallback(() => {
+ if (!previousItem || !from) return;
+ const url = itemRouter(previousItem, from);
+ stopPlayback();
+ // @ts-ignore
+ router.push(url);
+ }, [previousItem, from, stopPlayback, router]);
+
+ const handleGoToNextItem = useCallback(() => {
+ if (!nextItem || !from) return;
+ const url = itemRouter(nextItem, from);
+ stopPlayback();
+ // @ts-ignore
+ router.push(url);
+ }, [nextItem, from, stopPlayback, router]);
+
+ if (!currentlyPlaying) return null;
+
+ return (
+
+
+ {videoSource && (
+ <>
+
+
+
+
+
+
+ {(showControls || isBuffering) && (
+
+ )}
+
+ {isBuffering && (
+
+
+
+ )}
+
+ {showSkipButton && (
+
+
+ Skip Intro
+
+
+ )}
+
+ {showSkipCreditButton && (
+
+
+ Skip Credits
+
+
+ )}
+
+ {showControls && (
+ <>
+
+ {
+ stopPlayback();
+ router.back();
+ }}
+ className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
+ >
+
+
+
+
+
+
+ {currentlyPlaying.item?.Name}
+ {currentlyPlaying.item?.Type === "Episode" && (
+
+ {currentlyPlaying.item.SeriesName}
+
+ )}
+ {currentlyPlaying.item?.Type === "Movie" && (
+
+ {currentlyPlaying.item?.ProductionYear}
+
+ )}
+ {currentlyPlaying.item?.Type === "Audio" && (
+
+ {currentlyPlaying.item?.Album}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatTimeString(currentTime)}
+
+
+ -{formatTimeString(remainingTime)}
+
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx
index 54a1db7f..d19a65eb 100644
--- a/components/FullScreenVideoPlayer.tsx
+++ b/components/FullScreenVideoPlayer.tsx
@@ -66,6 +66,9 @@ export const FullScreenVideoPlayer: React.FC = () => {
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
+ const [orientation, setOrientation] = useState(
+ ScreenOrientation.OrientationLock.UNKNOWN
+ );
// Seconds
const [currentTime, setCurrentTime] = useState(0);
@@ -84,14 +87,29 @@ export const FullScreenVideoPlayer: React.FC = () => {
});
useEffect(() => {
- const subscription = Dimensions.addEventListener(
+ const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
- return () => subscription?.remove();
- });
+
+ const orientationSubscription =
+ ScreenOrientation.addOrientationChangeListener((event) => {
+ setOrientation(
+ orientationToOrientationLock(event.orientationInfo.orientation)
+ );
+ });
+
+ ScreenOrientation.getOrientationAsync().then((orientation) => {
+ setOrientation(orientationToOrientationLock(orientation));
+ });
+
+ return () => {
+ dimensionsSubscription.remove();
+ orientationSubscription.remove();
+ };
+ }, []);
const from = useMemo(() => segments[2], [segments]);
@@ -162,31 +180,6 @@ export const FullScreenVideoPlayer: React.FC = () => {
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
- const [orientation, setOrientation] = useState(
- ScreenOrientation.OrientationLock.UNKNOWN
- );
-
- /**
- * Event listener for orientation
- */
- useEffect(() => {
- const subscription = ScreenOrientation.addOrientationChangeListener(
- (event) => {
- setOrientation(
- orientationToOrientationLock(event.orientationInfo.orientation)
- );
- }
- );
-
- ScreenOrientation.getOrientationAsync().then((orientation) => {
- setOrientation(orientationToOrientationLock(orientation));
- });
-
- return () => {
- subscription.remove();
- };
- }, []);
-
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
@@ -232,6 +225,7 @@ export const FullScreenVideoPlayer: React.FC = () => {
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
+ playVideo();
}
}, [currentlyPlaying]);
diff --git a/components/ItemCardText.tsx b/components/ItemCardText.tsx
index 84c8e416..25de16bf 100644
--- a/components/ItemCardText.tsx
+++ b/components/ItemCardText.tsx
@@ -10,7 +10,7 @@ type ItemCardProps = {
export const ItemCardText: React.FC = ({ item }) => {
return (
-
+
{item.Type === "Episode" ? (
<>
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 3afdca8b..47f97e9a 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -118,8 +118,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
itemId: id,
});
- console.log("itemID", res?.Id);
-
return res;
},
enabled: !!id && !!api,
@@ -127,6 +125,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
const [localItem, setLocalItem] = useState(item);
+ useImageColors(item);
useEffect(() => {
if (item) {
@@ -170,7 +169,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
- }, [item]);
+ }, [item, orientation]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
@@ -236,18 +235,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
- const themeImageColorSource = useMemo(() => {
- if (!api || !item) return;
- return getItemImage({
- item,
- api,
- variant: "Primary",
- quality: 80,
- width: 300,
- });
- }, [api, item]);
-
- useImageColors(themeImageColorSource?.uri);
const loading = useMemo(() => {
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
@@ -276,7 +263,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
{localItem && (
= React.memo(({ id }) => {
)}
-
+
diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx
index 74d29725..24fb297a 100644
--- a/components/ItemHeader.tsx
+++ b/components/ItemHeader.tsx
@@ -4,6 +4,7 @@ import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
+import React from "react";
interface Props extends ViewProps {
item?: BaseItemDto | null;
diff --git a/components/ListItem.tsx b/components/ListItem.tsx
index b7b4dd9c..3dd3b799 100644
--- a/components/ListItem.tsx
+++ b/components/ListItem.tsx
@@ -23,7 +23,11 @@ export const ListItem: React.FC> = ({
>
{title}
- {subTitle && {subTitle}}
+ {subTitle && (
+
+ {subTitle}
+
+ )}
{iconAfter}
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index d36554f3..a50249c5 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -163,7 +163,6 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
});
break;
case 1:
- console.log("Device");
setCurrentlyPlayingState({ item, url });
router.push("/play");
break;
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index b3b55ee9..375cf22b 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -21,11 +21,17 @@ export const PlayedStatus: React.FC = ({ item, ...props }) => {
const invalidateQueries = () => {
queryClient.invalidateQueries({
- queryKey: ["item"],
+ queryKey: ["item", item.Id],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
+ queryClient.invalidateQueries({
+ queryKey: ["continueWatching"],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["nextUp-all"],
+ });
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx
index bd885fec..6679453f 100644
--- a/components/common/HorrizontalScroll.tsx
+++ b/components/common/HorrizontalScroll.tsx
@@ -98,7 +98,6 @@ export const HorizontalScroll = forwardRef<
)}
- {...props}
/>
);
}
diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx
index bf4c0f1a..9e38bc06 100644
--- a/components/common/ItemImage.tsx
+++ b/components/common/ItemImage.tsx
@@ -22,7 +22,6 @@ interface Props extends ImageProps {
| "Thumb";
quality?: number;
width?: number;
- useThemeColor?: boolean;
onError?: () => void;
}
@@ -31,7 +30,6 @@ export const ItemImage: React.FC = ({
variant = "Primary",
quality = 90,
width = 1000,
- useThemeColor = false,
onError,
...props
}) => {
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
new file mode 100644
index 00000000..2f68689f
--- /dev/null
+++ b/components/downloads/ActiveDownloads.tsx
@@ -0,0 +1,191 @@
+import { Text } from "@/components/common/Text";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { JobStatus } from "@/utils/optimize-server";
+import { formatTimeString } from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "expo-router";
+import { FFmpegKit } from "ffmpeg-kit-react-native";
+import { useAtom } from "jotai";
+import {
+ ActivityIndicator,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewProps,
+} from "react-native";
+import { toast } from "sonner-native";
+import { Button } from "../Button";
+import { Image } from "expo-image";
+import { useMemo } from "react";
+import { storage } from "@/utils/mmkv";
+
+interface Props extends ViewProps {}
+
+export const ActiveDownloads: React.FC = ({ ...props }) => {
+ const { processes, startDownload } = useDownload();
+ if (processes?.length === 0)
+ return (
+
+ Active download
+ No active downloads
+
+ );
+
+ return (
+
+ Active downloads
+
+ {processes?.map((p) => (
+
+ ))}
+
+
+ );
+};
+
+interface DownloadCardProps extends TouchableOpacityProps {
+ process: JobStatus;
+}
+
+const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
+ const { processes, startDownload } = useDownload();
+ const router = useRouter();
+ const { removeProcess, setProcesses } = useDownload();
+ const [settings] = useSettings();
+ const queryClient = useQueryClient();
+
+ const cancelJobMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (!process) throw new Error("No active download");
+
+ if (settings?.downloadMethod === "optimized") {
+ try {
+ const tasks = await checkForExistingDownloads();
+ for (const task of tasks) {
+ if (task.id === id) {
+ task.stop();
+ }
+ }
+ } catch (e) {
+ throw e;
+ } finally {
+ await removeProcess(id);
+ await queryClient.refetchQueries({ queryKey: ["jobs"] });
+ }
+ } else {
+ FFmpegKit.cancel();
+ setProcesses((prev) => prev.filter((p) => p.id !== id));
+ }
+ },
+ onSuccess: () => {
+ toast.success("Download canceled");
+ },
+ onError: (e) => {
+ console.log(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, true);
+ };
+
+ const base64Image = useMemo(() => {
+ return storage.getString(process.item.Id!);
+ }, []);
+
+ return (
+ router.push(`/(auth)/items/page?id=${process.item.Id}`)}
+ className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
+ {...props}
+ >
+ {(process.status === "optimizing" ||
+ process.status === "downloading") && (
+
+ )}
+
+
+ {base64Image && (
+
+
+
+ )}
+
+ {process.item.Type}
+ {process.item.Name}
+
+ {process.item.ProductionYear}
+
+
+ {process.progress === 0 ? (
+
+ ) : (
+ {process.progress.toFixed(0)}%
+ )}
+ {process.speed && (
+ {process.speed?.toFixed(2)}x
+ )}
+ {eta(process) && (
+ ETA {eta(process)}
+ )}
+
+
+
+ {process.status}
+
+
+ cancelJobMutation.mutate(process.id)}
+ className="ml-auto"
+ >
+ {cancelJobMutation.isPending ? (
+
+ ) : (
+
+ )}
+
+
+ {process.status === "completed" && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 92ac061b..dc867516 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -1,39 +1,41 @@
-import React, { useCallback } from "react";
-import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
-import * as FileSystem from "expo-file-system";
-import { useAtom } from "jotai";
+import React, { useCallback, useMemo, useRef } from "react";
+import { TouchableOpacity, View } from "react-native";
+import {
+ ActionSheetProvider,
+ useActionSheet,
+} from "@expo/react-native-action-sheet";
+import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
-import { useFiles } from "@/hooks/useFiles";
-import { useSettings } from "@/utils/atoms/settings";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { useRouter } from "expo-router";
+import { useDownload } from "@/providers/DownloadProvider";
+import { storage } from "@/utils/mmkv";
+import { Image } from "expo-image";
+import { ItemCardText } from "../ItemCardText";
+import { Ionicons } from "@expo/vector-icons";
interface EpisodeCardProps {
item: BaseItemDto;
}
/**
- * EpisodeCard component displays an episode with context menu options.
+ * EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
- const router = useRouter();
+ const { deleteFile } = useDownload();
+ const { openFile } = useFileOpener();
+ const { showActionSheetWithOptions } = useActionSheet();
- const { startDownloadedFilePlayback } = usePlayback();
+ const base64Image = useMemo(() => {
+ return storage.getString(item.Id!);
+ }, []);
- const handleOpenFile = useCallback(async () => {
- startDownloadedFilePlayback({
- item,
- url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
- });
- router.push("/play");
- }, [item, startDownloadedFilePlayback]);
+ const handleOpenFile = useCallback(() => {
+ openFile(item);
+ }, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
@@ -45,43 +47,70 @@ export const EpisodeCard: React.FC = ({ item }) => {
}
}, [deleteFile, item.Id]);
- const contextMenuOptions = [
- {
- label: "Delete",
- onSelect: handleDeleteFile,
- destructive: true,
- },
- ];
+ const showActionSheet = useCallback(() => {
+ const options = ["Delete", "Cancel"];
+ const destructiveButtonIndex = 0;
+ const cancelButtonIndex = 1;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ destructiveButtonIndex,
+ },
+ (selectedIndex) => {
+ switch (selectedIndex) {
+ case destructiveButtonIndex:
+ // Delete
+ handleDeleteFile();
+ break;
+ case cancelButtonIndex:
+ // Cancelled
+ break;
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
-
-
- {item.Name}
- Episode {item.IndexNumber}
-
-
-
- {contextMenuOptions.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
+
+ {base64Image ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
);
};
+
+// Wrap the parent component with ActionSheetProvider
+export const EpisodeCardWithActionSheet: React.FC = (
+ props
+) => (
+
+
+
+);
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 0943a89f..54381c08 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -1,38 +1,43 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
-import React, { useCallback } from "react";
+import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
-import * as ContextMenu from "zeego/context-menu";
+import {
+ ActionSheetProvider,
+ useActionSheet,
+} from "@expo/react-native-action-sheet";
-import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { useRouter } from "expo-router";
+import { useFileOpener } 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 { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
item: BaseItemDto;
}
/**
- * MovieCard component displays a movie with context menu options.
+ * MovieCard component displays a movie with action sheet options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
- const router = useRouter();
- const { startDownloadedFilePlayback } = usePlayback();
+ const { deleteFile } = useDownload();
+ const { openFile } = useFileOpener();
+ const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
- startDownloadedFilePlayback({
- item,
- url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
- });
- router.push("/play");
- }, [item, startDownloadedFilePlayback]);
+ openFile(item);
+ }, [item, openFile]);
+
+ const base64Image = useMemo(() => {
+ return storage.getString(item.Id!);
+ }, []);
/**
* Handles deleting the file with haptic feedback.
@@ -44,48 +49,64 @@ export const MovieCard: React.FC = ({ item }) => {
}
}, [deleteFile, item.Id]);
- const contextMenuOptions = [
- {
- label: "Delete",
- onSelect: handleDeleteFile,
- destructive: true,
- },
- ];
+ const showActionSheet = useCallback(() => {
+ const options = ["Delete", "Cancel"];
+ const destructiveButtonIndex = 0;
+ const cancelButtonIndex = 1;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ destructiveButtonIndex,
+ },
+ (selectedIndex) => {
+ switch (selectedIndex) {
+ case destructiveButtonIndex:
+ // Delete
+ handleDeleteFile();
+ break;
+ case cancelButtonIndex:
+ // Cancelled
+ break;
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
-
-
- {item.Name}
-
- {item.ProductionYear}
-
- {runtimeTicksToMinutes(item.RunTimeTicks)}
-
-
-
-
-
- {contextMenuOptions.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
+
+ {base64Image ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
);
};
+
+// Wrap the parent component with ActionSheetProvider
+export const MovieCardWithActionSheet: React.FC = (props) => (
+
+
+
+);
diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index c010ca04..5fe67611 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { View } from "react-native";
+import { ScrollView, View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import { Text } from "../common/Text";
import { useMemo } from "react";
@@ -22,26 +22,32 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
);
}, [items]);
+ const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
+ return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
+ };
+
return (
-
- {items[0].SeriesName}
+
+ {items[0].SeriesName}
{items.length}
- TV-Series
+ TV-Series
{groupBySeason.map((seasonItems, seasonIndex) => (
-
+
{seasonItems[0].SeasonName}
- {seasonItems.map((item, index) => (
-
-
+
+
+ {seasonItems.sort(sortByIndex)?.map((item, index) => (
+
+ ))}
- ))}
+
))}
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index e8420d72..0d47d112 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -6,12 +6,13 @@ import {
type QueryFunction,
type QueryKey,
} from "@tanstack/react-query";
-import { View, ViewProps } from "react-native";
+import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
+import { FlashList } from "@shopify/flash-list";
interface Props extends ViewProps {
title?: string | null;
@@ -39,40 +40,70 @@ export const ScrollingCollectionList: React.FC = ({
if (disabled || !title) return null;
return (
-
+
{title}
- (
-
- {item.Type === "Episode" && orientation === "horizontal" && (
-
- )}
- {item.Type === "Episode" && orientation === "vertical" && (
-
- )}
- {item.Type === "Movie" && orientation === "horizontal" && (
-
- )}
- {item.Type === "Movie" && orientation === "vertical" && (
-
- )}
- {item.Type === "Series" && }
-
-
- )}
- />
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Nisi mollit voluptate amet.
+
+
+
+
+ Lorem ipsum
+
+
+
+ ))}
+
+ ) : (
+
+
+ {data?.map((item, index) => (
+
+ {item.Type === "Episode" && orientation === "horizontal" && (
+
+ )}
+ {item.Type === "Episode" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "horizontal" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Series" && }
+
+
+ ))}
+
+
+ )}
);
};
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index 76ed9f73..12dcba1d 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -8,6 +8,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
+import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
@@ -35,7 +36,7 @@ export const SongsListItem: React.FC = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
-
+ const router = useRouter();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
@@ -123,6 +124,7 @@ export const SongsListItem: React.FC = ({
item,
url,
});
+ router.push("/play-music");
}
};
diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx
index 056e2c30..46776fb7 100644
--- a/components/posters/MoviePoster.tsx
+++ b/components/posters/MoviePoster.tsx
@@ -36,7 +36,7 @@ const MoviePoster: React.FC = ({
}, [item]);
return (
-
+
= ({
width: "100%",
}}
/>
-
{showProgress && progress > 0 && (
diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx
index dbadcdce..e551624a 100644
--- a/components/posters/SeriesPoster.tsx
+++ b/components/posters/SeriesPoster.tsx
@@ -32,7 +32,7 @@ const SeriesPoster: React.FC = ({ item }) => {
}, [item]);
return (
-
+
= ({ item }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
- aspectRatio: "10/15",
+ height: "100%",
width: "100%",
}}
/>
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index 0c6f9a0e..b0da7661 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -198,11 +198,11 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
key={e.Id}
className="flex flex-col mb-4"
>
-
-
+
+
@@ -217,7 +217,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
-
+
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
index a4b17367..0688226a 100644
--- a/components/settings/SettingToggles.tsx
+++ b/components/settings/SettingToggles.tsx
@@ -1,41 +1,85 @@
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useDownload } from "@/providers/DownloadProvider";
import {
- DefaultLanguageOption,
- DownloadOptions,
- ScreenOrientationEnum,
- useSettings,
-} from "@/utils/atoms/settings";
+ apiAtom,
+ getOrSetDeviceId,
+ userAtom,
+} from "@/providers/JellyfinProvider";
+import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
+import {
+ BACKGROUND_FETCH_TASK,
+ registerBackgroundFetchAsync,
+ unregisterBackgroundFetchAsync,
+} from "@/utils/background-tasks";
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 { useEffect, useState } from "react";
import {
+ ActivityIndicator,
Linking,
Switch,
TouchableOpacity,
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 { Input } from "../common/Input";
-import { useState } from "react";
-import { Button } from "../Button";
import { MediaToggles } from "./MediaToggles";
-import * as ScreenOrientation from "expo-screen-orientation";
+import axios from "axios";
+import { getStatistics } from "@/utils/optimize-server";
interface Props extends ViewProps {}
export const SettingToggles: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
+ const { setProcesses } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState("");
+ const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
+ useState(settings?.optimizedVersionsServerUrl || "");
const queryClient = useQueryClient();
+ /********************
+ * Background task
+ *******************/
+ const checkStatusAsync = async () => {
+ await BackgroundFetch.getStatusAsync();
+ return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
+ };
+
+ useEffect(() => {
+ (async () => {
+ const registered = await checkStatusAsync();
+
+ if (settings?.autoDownload === true && !registered) {
+ registerBackgroundFetchAsync();
+ toast.success("Background downlodas 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,
@@ -308,9 +352,9 @@ export const SettingToggles: React.FC = ({ ...props }) => {
Device profile
@@ -362,6 +406,7 @@ export const SettingToggles: React.FC = ({ ...props }) => {
+
= ({ ...props }) => {
{settings.searchEngine === "Marlin" && (
- <>
-
-
- setMarlinUrl(text)}
- />
-
-
+
+
+ setMarlinUrl(text)}
+ />
+
+
+ {settings.marlinServerUrl && (
- {settings.marlinServerUrl}
+ Current: {settings.marlinServerUrl}
- >
+ )}
)}
+
+
+ Downloads
+
+
+
+ Download method
+
+ Choose the download method to use. Optimized requires the
+ optimized server.
+
+
+
+
+
+
+ {settings.downloadMethod === "remux"
+ ? "Default"
+ : "Optimized"}
+
+
+
+
+ Methods
+ {
+ updateSettings({ downloadMethod: "remux" });
+ setProcesses([]);
+ }}
+ >
+ Default
+
+ {
+ updateSettings({ downloadMethod: "optimized" });
+ setProcesses([]);
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ }}
+ >
+ Optimized
+
+
+
+
+
+
+ Auto download
+
+ This will automatically download the media file when it's
+ finished optimizing on the server.
+
+
+ updateSettings({ autoDownload: value })}
+ />
+
+
+
+
+
+
+ Optimized versions server
+
+
+
+ Set the URL for the optimized versions server for downloads.
+
+
+
+
+ setOptimizedVersionsServerUrl(text)}
+ />
+
+
+
+
+
+
);
};
diff --git a/constants/Values.ts b/constants/Values.ts
new file mode 100644
index 00000000..4c3e4d81
--- /dev/null
+++ b/constants/Values.ts
@@ -0,0 +1,3 @@
+import { Platform } from "react-native";
+
+export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;
diff --git a/eas.json b/eas.json
index 1bfada25..4c03966e 100644
--- a/eas.json
+++ b/eas.json
@@ -1,6 +1,7 @@
{
"cli": {
- "version": ">= 9.1.0"
+ "version": ">= 9.1.0",
+ "appVersionSource": "local"
},
"build": {
"development": {
@@ -21,13 +22,13 @@
}
},
"production": {
- "channel": "0.15.0",
+ "channel": "0.16.0",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.15.0",
+ "channel": "0.16.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useDownloadMedia.ts b/hooks/useDownloadMedia.ts
deleted file mode 100644
index 61351e2d..00000000
--- a/hooks/useDownloadMedia.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-import { useAtom } from "jotai";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import * as FileSystem from "expo-file-system";
-import { Api } from "@jellyfin/sdk";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { runningProcesses } from "@/utils/atoms/downloads";
-
-/**
- * Custom hook for downloading media using the Jellyfin API.
- *
- * @param api - The Jellyfin API instance
- * @param userId - The user ID
- * @returns An object with download-related functions and state
- */
-export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
- const [isDownloading, setIsDownloading] = useState(false);
- const [error, setError] = useState(null);
- const [_, setProgress] = useAtom(runningProcesses);
- const downloadResumableRef = useRef(
- null,
- );
-
- const downloadMedia = useCallback(
- async (item: BaseItemDto | null): Promise => {
- if (!item?.Id || !api || !userId) {
- setError("Invalid item or API");
- return false;
- }
-
- setIsDownloading(true);
- setError(null);
- setProgress({ item, progress: 0 });
-
- try {
- const filename = item.Id;
- const fileUri = `${FileSystem.documentDirectory}${filename}`;
- const url = `${api.basePath}/Items/${item.Id}/File`;
-
- downloadResumableRef.current = FileSystem.createDownloadResumable(
- url,
- fileUri,
- {
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- },
- (downloadProgress) => {
- const currentProgress =
- downloadProgress.totalBytesWritten /
- downloadProgress.totalBytesExpectedToWrite;
- setProgress({ item, progress: currentProgress * 100 });
- },
- );
-
- const res = await downloadResumableRef.current.downloadAsync();
-
- if (!res?.uri) {
- throw new Error("Download failed: No URI returned");
- }
-
- await updateDownloadedFiles(item);
-
- setIsDownloading(false);
- setProgress(null);
- return true;
- } catch (error) {
- console.error("Error downloading media:", error);
- setError("Failed to download media");
- setIsDownloading(false);
- setProgress(null);
- return false;
- }
- },
- [api, userId, setProgress],
- );
-
- const cancelDownload = useCallback(async (): Promise => {
- if (!downloadResumableRef.current) return;
-
- try {
- await downloadResumableRef.current.pauseAsync();
- setIsDownloading(false);
- setError("Download cancelled");
- setProgress(null);
- downloadResumableRef.current = null;
- } catch (error) {
- console.error("Error cancelling download:", error);
- setError("Failed to cancel download");
- }
- }, [setProgress]);
-
- return { downloadMedia, isDownloading, error, cancelDownload };
-};
-
-/**
- * Updates the list of downloaded files in AsyncStorage.
- *
- * @param item - The item to add to the downloaded files list
- */
-async function updateDownloadedFiles(item: BaseItemDto): Promise {
- try {
- const currentFiles: BaseItemDto[] = JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) ?? "[]",
- );
- const updatedFiles = [
- ...currentFiles.filter((file) => file.Id !== item.Id),
- item,
- ];
- await AsyncStorage.setItem(
- "downloaded_files",
- JSON.stringify(updatedFiles),
- );
- } catch (error) {
- console.error("Error updating downloaded files:", error);
- }
-}
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
new file mode 100644
index 00000000..f41e1f53
--- /dev/null
+++ b/hooks/useDownloadedFileOpener.ts
@@ -0,0 +1,55 @@
+// hooks/useFileOpener.ts
+
+import { usePlayback } from "@/providers/PlaybackProvider";
+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 useFileOpener = () => {
+ const router = useRouter();
+ const { startDownloadedFilePlayback } = usePlayback();
+
+ const openFile = useCallback(
+ async (item: BaseItemDto) => {
+ const directory = FileSystem.documentDirectory;
+
+ if (!directory) {
+ throw new Error("Document directory is not available");
+ }
+
+ if (!item.Id) {
+ throw new Error("Item ID is not available");
+ }
+
+ try {
+ const files = await FileSystem.readDirectoryAsync(directory);
+ for (let f of files) {
+ console.log(f);
+ }
+ const path = item.Id!;
+ const matchingFile = files.find((file) => file.startsWith(path));
+
+ if (!matchingFile) {
+ throw new Error(`No file found for item ${path}`);
+ }
+
+ const url = `${directory}${matchingFile}`;
+
+ console.log("Opening " + url);
+
+ startDownloadedFilePlayback({
+ item,
+ url,
+ });
+ router.push("/play");
+ } catch (error) {
+ console.error("Error opening file:", error);
+ // Handle the error appropriately, e.g., show an error message to the user
+ }
+ },
+ [startDownloadedFilePlayback]
+ );
+
+ return { openFile };
+};
diff --git a/hooks/useFiles.ts b/hooks/useFiles.ts
deleted file mode 100644
index 01d249b1..00000000
--- a/hooks/useFiles.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQueryClient } from "@tanstack/react-query";
-import * as FileSystem from "expo-file-system";
-
-/**
- * Custom hook for managing downloaded files.
- * @returns An object with functions to delete individual files and all files.
- */
-export const useFiles = () => {
- const queryClient = useQueryClient();
-
- /**
- * Deletes all downloaded files and clears the download record.
- */
- const deleteAllFiles = async (): Promise => {
- const directoryUri = FileSystem.documentDirectory;
- if (!directoryUri) {
- console.error("Document directory is undefined");
- return;
- }
-
- try {
- const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
- await Promise.all(
- fileNames.map((item) =>
- FileSystem.deleteAsync(`${directoryUri}/${item}`, {
- idempotent: true,
- })
- )
- );
- await AsyncStorage.removeItem("downloaded_files");
- queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
- queryClient.invalidateQueries({ queryKey: ["downloaded"] });
- } catch (error) {
- console.error("Failed to delete all files:", error);
- }
- };
-
- /**
- * Deletes a specific file and updates the download record.
- * @param id - The ID of the file to delete.
- */
- const deleteFile = async (id: string): Promise => {
- if (!id) {
- console.error("Invalid file ID");
- return;
- }
-
- try {
- await FileSystem.deleteAsync(
- `${FileSystem.documentDirectory}/${id}.mp4`,
- { idempotent: true }
- );
-
- const currentFiles = await getDownloadedFiles();
- const updatedFiles = currentFiles.filter((f) => f.Id !== id);
-
- await AsyncStorage.setItem(
- "downloaded_files",
- JSON.stringify(updatedFiles)
- );
-
- queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
- } catch (error) {
- console.error(`Failed to delete file with ID ${id}:`, error);
- }
- };
-
- return { deleteFile, deleteAllFiles };
-};
-
-/**
- * Retrieves the list of downloaded files from AsyncStorage.
- * @returns An array of BaseItemDto objects representing downloaded files.
- */
-async function getDownloadedFiles(): Promise {
- try {
- const filesJson = await AsyncStorage.getItem("downloaded_files");
- return filesJson ? JSON.parse(filesJson) : [];
- } catch (error) {
- console.error("Failed to retrieve downloaded files:", error);
- return [];
- }
-}
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
index 3541c09d..9d0a3264 100644
--- a/hooks/useImageColors.ts
+++ b/hooks/useImageColors.ts
@@ -1,46 +1,95 @@
-import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ adjustToNearBlack,
+ calculateTextColor,
+ isCloseToBlack,
+ itemThemeColorAtom,
+} from "@/utils/atoms/primaryColor";
+import { getItemImage } from "@/utils/getItemImage";
+import { storage } from "@/utils/mmkv";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors";
-export const useImageColors = (
- uri: string | undefined | null,
- disabled = false
-) => {
+/**
+ * Custom hook to extract and manage image colors for a given item.
+ *
+ * @param item - The BaseItemDto object representing the item.
+ * @param disabled - A boolean flag to disable color extraction.
+ *
+ */
+export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
+ const [api] = useAtom(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
+ const source = useMemo(() => {
+ if (!api || !item) return;
+ return getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 80,
+ width: 300,
+ });
+ }, [api, item]);
+
useEffect(() => {
if (disabled) return;
- if (uri) {
- getColors(uri, {
+ if (source?.uri) {
+ // Check if colors are already cached in storage
+ const _primary = storage.getString(`${source.uri}-primary`);
+ const _text = storage.getString(`${source.uri}-text`);
+
+ // If colors are cached, use them and exit
+ if (_primary && _text) {
+ console.info("[useImageColors] Using cached colors for performance.");
+ setPrimaryColor({
+ primary: _primary,
+ text: _text,
+ });
+ return;
+ }
+
+ // Extract colors from the image
+ getColors(source.uri, {
fallback: "#fff",
cache: true,
- key: uri,
+ key: source.uri,
})
.then((colors) => {
let primary: string = "#fff";
- let average: string = "#fff";
- let secondary: string = "#fff";
+ let text: string = "#000";
+ // Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
- average = colors.average;
- secondary = colors.muted;
} else if (colors.platform === "ios") {
primary = colors.primary;
- secondary = colors.secondary;
- average = colors.background;
}
+ // Adjust the primary color if it's too close to black
+ if (primary && isCloseToBlack(primary)) {
+ primary = adjustToNearBlack(primary);
+ }
+
+ // Calculate the text color based on the primary color
+ if (primary) text = calculateTextColor(primary);
+
setPrimaryColor({
primary,
- secondary,
- average,
+ text,
});
+
+ // Cache the colors in storage
+ if (source.uri && primary) {
+ storage.set(`${source.uri}-primary`, primary);
+ storage.set(`${source.uri}-text`, text);
+ }
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
- }, [uri, setPrimaryColor, disabled]);
+ }, [source?.uri, setPrimaryColor, disabled]);
};
diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts
new file mode 100644
index 00000000..ad271f07
--- /dev/null
+++ b/hooks/useImageStorage.ts
@@ -0,0 +1,89 @@
+import { useState, useCallback } from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import * as FileSystem from "expo-file-system";
+import { storage } from "@/utils/mmkv";
+
+const useImageStorage = () => {
+ const saveBase64Image = useCallback(async (base64: string, key: string) => {
+ try {
+ // Save the base64 string to AsyncStorage
+ storage.set(key, base64);
+ console.log("Image saved successfully");
+ } catch (error) {
+ console.error("Error saving image:", error);
+ throw error;
+ }
+ }, []);
+
+ const image2Base64 = useCallback(async (url?: string | null) => {
+ if (!url) return null;
+
+ let blob: Blob;
+ try {
+ // Fetch the data from the URL
+ const response = await fetch(url);
+ blob = await response.blob();
+ } catch (error) {
+ console.warn("Error fetching image:", error);
+ return null;
+ }
+
+ // Create a FileReader instance
+ const reader = new FileReader();
+
+ // Convert blob to base64
+ return new Promise((resolve, reject) => {
+ reader.onloadend = () => {
+ if (typeof reader.result === "string") {
+ // Extract the base64 string (remove the data URL prefix)
+ const base64 = reader.result.split(",")[1];
+ resolve(base64);
+ } else {
+ reject(new Error("Failed to convert image to base64"));
+ }
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ }, []);
+
+ const saveImage = useCallback(
+ async (key?: string | null, imageUrl?: string | null) => {
+ if (!imageUrl || !key) {
+ console.warn("Invalid image URL or key");
+ return;
+ }
+
+ try {
+ const base64Image = await image2Base64(imageUrl);
+ if (!base64Image || base64Image.length === 0) {
+ console.warn("Failed to convert image to base64");
+ return;
+ }
+ saveBase64Image(base64Image, key);
+ } catch (error) {
+ console.warn("Error saving image:", error);
+ }
+ },
+ []
+ );
+
+ const loadImage = useCallback(async (key: string) => {
+ try {
+ // Retrieve the base64 string from AsyncStorage
+ const base64Image = storage.getString(key);
+ if (base64Image !== null) {
+ // Set the loaded image state
+ return `data:image/jpeg;base64,${base64Image}`;
+ }
+ return null;
+ } catch (error) {
+ console.error("Error loading image:", error);
+ throw error;
+ }
+ }, []);
+
+ return { saveImage, loadImage, saveBase64Image, image2Base64 };
+};
+
+export default useImageStorage;
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index be78aed1..091325fb 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -4,10 +4,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner-native";
+import { useDownload } from "@/providers/DownloadProvider";
+import { useRouter } from "expo-router";
+import { JobStatus } from "@/utils/optimize-server";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -17,8 +19,9 @@ import { toast } from "sonner-native";
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
- const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
+ const { saveDownloadedItemInfo, setProcesses } = useDownload();
+ const router = useRouter();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -29,8 +32,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const startRemuxing = useCallback(
async (url: string) => {
- toast.success("Download started", {
- invert: true,
+ if (!item.Id) throw new Error("Item must have an Id");
+
+ toast.success(`Download started for ${item.Name}`, {
+ action: {
+ label: "Go to download",
+ onClick: () => {
+ router.push("/downloads");
+ toast.dismiss();
+ },
+ },
});
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
@@ -41,7 +52,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
);
try {
- setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
+ setProcesses((prev) => [
+ ...prev,
+ {
+ id: "",
+ deviceId: "",
+ inputUrl: "",
+ item,
+ itemId: item.Id,
+ outputPath: "",
+ progress: 0,
+ status: "downloading",
+ timestamp: new Date(),
+ } as JobStatus,
+ ]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
@@ -56,11 +80,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
- setProgress((prev) =>
- prev?.item.Id === item.Id!
- ? { ...prev, progress: percentage, speed }
- : prev
- );
+ if (!item.Id) throw new Error("Item is undefined");
+ setProcesses((prev) => {
+ return prev.map((process) => {
+ if (process.itemId === item.Id) {
+ return {
+ ...process,
+ progress: percentage,
+ speed: Math.max(speed, 0),
+ };
+ }
+ return process;
+ });
+ });
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
@@ -70,11 +102,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
- await updateDownloadedFiles(item);
+ if (!item) throw new Error("Item is undefined");
+ await saveDownloadedItemInfo(item);
+ toast.success("Download completed");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
+ await queryClient.invalidateQueries({
+ queryKey: ["downloadedItems"],
+ });
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
@@ -90,63 +127,35 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
resolve();
}
- setProgress(null);
+ setProcesses((prev) => {
+ return prev.filter((process) => process.itemId !== item.Id);
+ });
} catch (error) {
reject(error);
}
});
});
-
- await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
- await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
- setProgress(null);
+ setProcesses((prev) => {
+ return prev.filter((process) => process.itemId !== item.Id);
+ });
throw error; // Re-throw the error to propagate it to the caller
}
},
- [output, item, setProgress]
+ [output, item]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
- setProgress(null);
- writeToLog(
- "INFO",
- `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
- );
- }, [item.Name, setProgress]);
+ setProcesses((prev) => {
+ return prev.filter((process) => process.itemId !== item.Id);
+ });
+ }, [item.Name]);
return { startRemuxing, cancelRemuxing };
};
-
-/**
- * Updates the list of downloaded files in AsyncStorage.
- *
- * @param item - The item to add to the downloaded files list
- */
-async function updateDownloadedFiles(item: BaseItemDto): Promise {
- try {
- const currentFiles: BaseItemDto[] = JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) || "[]"
- );
- const updatedFiles = [
- ...currentFiles.filter((i) => i.Id !== item.Id),
- item,
- ];
- await AsyncStorage.setItem(
- "downloaded_files",
- JSON.stringify(updatedFiles)
- );
- } catch (error) {
- console.error("Error updating downloaded files:", error);
- writeToLog(
- "ERROR",
- `Failed to update downloaded files for item: ${item.Name}`
- );
- }
-}
diff --git a/package.json b/package.json
index a05b69c3..ea67341b 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@expo/vector-icons": "^14.0.3",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
+ "@kesha-antonov/react-native-background-downloader": "^3.2.1",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.3",
@@ -29,7 +30,8 @@
"@types/lodash": "^4.17.9",
"@types/uuid": "^10.0.0",
"axios": "^1.7.7",
- "expo": "~51.0.34",
+ "expo": "~51.0.36",
+ "expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
@@ -43,13 +45,15 @@
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-network": "~6.0.1",
+ "expo-notifications": "~0.28.18",
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
- "expo-updates": "~0.25.25",
+ "expo-task-manager": "~11.8.2",
+ "expo-updates": "~0.25.26",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.10.0",
@@ -57,24 +61,25 @@
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
- "react-native": "0.74.5",
+ "react-native": "~0.75.0",
"react-native-awesome-slider": "^2.5.3",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
- "react-native-gesture-handler": "~2.16.1",
+ "react-native-gesture-handler": "~2.18.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.1",
"react-native-ios-utilities": "^4.4.5",
- "react-native-reanimated": "~3.10.1",
+ "react-native-mmkv": "^2.12.2",
+ "react-native-reanimated": "~3.15.0",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",
- "react-native-screens": "3.31.1",
+ "react-native-screens": "~3.34.0",
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
- "react-native-video": "^6.6.2",
+ "react-native-video": "^6.6.3",
"react-native-web": "~0.19.10",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
@@ -93,5 +98,15 @@
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
- "private": true
+ "private": true,
+ "expo": {
+ "install": {
+ "exclude": [
+ "react-native@~0.74.0",
+ "react-native-reanimated@~3.10.0",
+ "react-native-gesture-handler@~2.16.1",
+ "react-native-screens@~3.31.1"
+ ]
+ }
+ }
}
diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js
new file mode 100644
index 00000000..1970ceb7
--- /dev/null
+++ b/plugins/withRNBackgroundDownloader.js
@@ -0,0 +1,48 @@
+const { withAppDelegate } = require("@expo/config-plugins");
+
+function withRNBackgroundDownloader(expoConfig) {
+ return withAppDelegate(expoConfig, async (appDelegateConfig) => {
+ const { modResults: appDelegate } = appDelegateConfig;
+ const appDelegateLines = appDelegate.contents.split("\n");
+
+ // Define the code to be added to AppDelegate.mm
+ const backgroundDownloaderImport =
+ "#import // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
+ const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
+- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
+{
+ [RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
+}`;
+
+ // Find the index of the AppDelegate import statement
+ const importIndex = appDelegateLines.findIndex((line) =>
+ /^#import "AppDelegate.h"/.test(line)
+ );
+
+ // Find the index of the last line before the @end statement
+ const endStatementIndex = appDelegateLines.findIndex((line) =>
+ /@end/.test(line)
+ );
+
+ // Insert the import statement if it's not already present
+ if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
+ appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
+ }
+
+ // Insert the delegate method above the @end statement
+ if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
+ appDelegateLines.splice(
+ endStatementIndex,
+ 0,
+ backgroundDownloaderDelegate
+ );
+ }
+
+ // Update the contents of the AppDelegate file
+ appDelegate.contents = appDelegateLines.join("\n");
+
+ return appDelegateConfig;
+ });
+}
+
+module.exports = withRNBackgroundDownloader;
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
new file mode 100644
index 00000000..1e342994
--- /dev/null
+++ b/providers/DownloadProvider.tsx
@@ -0,0 +1,560 @@
+import { useSettings } from "@/utils/atoms/settings";
+import { getOrSetDeviceId } from "@/utils/device";
+import { writeToLog } from "@/utils/log";
+import {
+ cancelAllJobs,
+ cancelJobById,
+ getAllJobsByDeviceId,
+ JobStatus,
+} from "@/utils/optimize-server";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ checkForExistingDownloads,
+ completeHandler,
+ download,
+ setConfig,
+} from "@kesha-antonov/react-native-background-downloader";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+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 { useAtom } from "jotai";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { AppState, AppStateStatus } 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";
+
+function onAppStateChange(status: AppStateStatus) {
+ focusManager.setFocused(status === "active");
+}
+
+const DownloadContext = createContext | null>(null);
+
+function useDownloadProvider() {
+ const queryClient = useQueryClient();
+ const [settings] = useSettings();
+ const router = useRouter();
+ const [api] = useAtom(apiAtom);
+
+ const { loadImage, saveImage, image2Base64, saveBase64Image } =
+ useImageStorage();
+
+ const [processes, setProcesses] = useState([]);
+
+ 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,
+ });
+
+ // Local downloading processes that are still valid
+ 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]);
+
+ // Go though new jobs and compare them to old jobs
+ // if new job is now completed, start download.
+ 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.log(error);
+ }
+ },
+ [settings?.optimizedVersionsServerUrl, authHeader]
+ );
+
+ const startDownload = useCallback(
+ async (process: JobStatus) => {
+ if (!process?.item.Id || !authHeader) throw new Error("No item id");
+
+ console.log("[0] Setting process to downloading");
+ 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;
+ console.log("Download progress:", percent);
+ setProcesses((prev) =>
+ prev.map((p) =>
+ p.id === process.id
+ ? {
+ ...p,
+ speed: undefined,
+ status: "downloading",
+ progress: percent,
+ }
+ : p
+ )
+ );
+ })
+ .done(async () => {
+ await saveDownloadedItemInfo(process.item);
+ 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, fileExtension: string) => {
+ if (!api || !item.Id || !authHeader)
+ throw new Error("startBackgroundDownload ~ Missing required params");
+
+ try {
+ const deviceId = await getOrSetDeviceId();
+ 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) {
+ 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 => {
+ try {
+ await deleteLocalFiles();
+ await removeDownloadedItemsFromStorage();
+ await cancelAllServerJobs();
+ queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
+ toast.success("All files, folders, and jobs deleted successfully");
+ } catch (error) {
+ console.error("Failed to delete all files, folders, and jobs:", error);
+ toast.error("An error occurred while deleting files and jobs");
+ }
+ };
+
+ const deleteLocalFiles = async (): Promise => {
+ const baseDirectory = FileSystem.documentDirectory;
+ if (!baseDirectory) {
+ throw new Error("Base directory not found");
+ }
+
+ const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
+ for (const item of dirContents) {
+ const itemPath = `${baseDirectory}${item}`;
+ const itemInfo = await FileSystem.getInfoAsync(itemPath);
+ if (itemInfo.exists) {
+ if (itemInfo.isDirectory) {
+ await FileSystem.deleteAsync(itemPath, { idempotent: true });
+ } else {
+ await FileSystem.deleteAsync(itemPath, { idempotent: true });
+ }
+ }
+ }
+ };
+
+ const removeDownloadedItemsFromStorage = async (): Promise => {
+ try {
+ await AsyncStorage.removeItem("downloadedItems");
+ } catch (error) {
+ console.error(
+ "Failed to remove downloadedItems from AsyncStorage:",
+ error
+ );
+ throw error;
+ }
+ };
+
+ const cancelAllServerJobs = async (): Promise => {
+ if (!authHeader) {
+ throw new Error("No auth header available");
+ }
+ if (!settings?.optimizedVersionsServerUrl) {
+ throw new Error("No server URL configured");
+ }
+
+ const deviceId = await getOrSetDeviceId();
+ if (!deviceId) {
+ throw new Error("Failed to get device ID");
+ }
+
+ try {
+ await cancelAllJobs({
+ authHeader,
+ url: settings.optimizedVersionsServerUrl,
+ deviceId,
+ });
+ } catch (error) {
+ console.error("Failed to cancel all server jobs:", error);
+ throw error;
+ }
+ };
+
+ const deleteFile = async (id: string): Promise => {
+ if (!id) {
+ console.error("Invalid file ID");
+ return;
+ }
+
+ try {
+ const directory = FileSystem.documentDirectory;
+
+ if (!directory) {
+ console.error("Document directory not found");
+ return;
+ }
+ const dirContents = await FileSystem.readDirectoryAsync(directory);
+
+ for (const item of dirContents) {
+ const itemNameWithoutExtension = item.split(".")[0];
+ if (itemNameWithoutExtension === id) {
+ const filePath = `${directory}${item}`;
+ await FileSystem.deleteAsync(filePath, { idempotent: true });
+ console.log(`Successfully deleted file: ${item}`);
+ break;
+ }
+ }
+
+ const downloadedItems = await AsyncStorage.getItem("downloadedItems");
+ if (downloadedItems) {
+ let items = JSON.parse(downloadedItems);
+ items = items.filter((item: any) => item.Id !== id);
+ await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
+ }
+
+ queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
+
+ console.log(
+ `Successfully deleted file and AsyncStorage entry for ID ${id}`
+ );
+ } catch (error) {
+ console.error(
+ `Failed to delete file and AsyncStorage entry for ID ${id}:`,
+ error
+ );
+ }
+ };
+
+ async function getAllDownloadedItems(): Promise {
+ try {
+ const downloadedItems = await AsyncStorage.getItem("downloadedItems");
+ if (downloadedItems) {
+ return JSON.parse(downloadedItems) as BaseItemDto[];
+ } else {
+ return [];
+ }
+ } catch (error) {
+ console.error("Failed to retrieve downloaded items:", error);
+ return [];
+ }
+ }
+
+ async function saveDownloadedItemInfo(item: BaseItemDto) {
+ try {
+ const downloadedItems = await AsyncStorage.getItem("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);
+ }
+
+ await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
+ await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
+ refetch();
+ } catch (error) {
+ console.error("Failed to save downloaded item information:", error);
+ }
+ }
+
+ return {
+ processes,
+ startBackgroundDownload,
+ downloadedFiles,
+ deleteAllFiles,
+ deleteFile,
+ saveDownloadedItemInfo,
+ removeProcess,
+ setProcesses,
+ startDownload,
+ };
+}
+
+export function DownloadProvider({ children }: { children: React.ReactNode }) {
+ const downloadProviderValue = useDownloadProvider();
+ const queryClient = new QueryClient();
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDownload() {
+ const context = useContext(DownloadContext);
+ if (context === null) {
+ throw new Error("useDownload must be used within a DownloadProvider");
+ }
+ return context;
+}
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 2ae72c73..bcd0fb07 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -40,17 +40,6 @@ const JellyfinContext = createContext(
undefined
);
-const getOrSetDeviceId = async () => {
- let deviceId = await AsyncStorage.getItem("deviceId");
-
- if (!deviceId) {
- deviceId = uuid.v4() as string;
- await AsyncStorage.setItem("deviceId", deviceId);
- }
-
- return deviceId;
-};
-
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
@@ -63,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.15.0" },
+ clientInfo: { name: "Streamyfin", version: "0.16.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -97,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
- }, DeviceId="${deviceId}", Version="0.15.0"`,
+ }, DeviceId="${deviceId}", Version="0.16.0"`,
};
}, [deviceId]);
@@ -269,10 +258,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
],
queryFn: async () => {
try {
- const token = await AsyncStorage.getItem("token");
- const serverUrl = await AsyncStorage.getItem("serverUrl");
+ const token = await getTokenFromStoraage();
+ const serverUrl = await getServerUrlFromStorage();
const user = JSON.parse(
- (await AsyncStorage.getItem("user")) as string
+ (await getUserFromStorage()) as string
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {
@@ -331,3 +320,26 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
}
}, [user, segments, loading]);
}
+
+export async function getTokenFromStoraage() {
+ return await AsyncStorage.getItem("token");
+}
+
+export async function getUserFromStorage() {
+ return await AsyncStorage.getItem("user");
+}
+
+export async function getServerUrlFromStorage() {
+ return await AsyncStorage.getItem("serverUrl");
+}
+
+export async function getOrSetDeviceId() {
+ let deviceId = await AsyncStorage.getItem("deviceId");
+
+ if (!deviceId) {
+ deviceId = uuid.v4() as string;
+ await AsyncStorage.setItem("deviceId", deviceId);
+ }
+
+ return deviceId;
+}
diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx
index 22c54631..f3569a03 100644
--- a/providers/PlaybackProvider.tsx
+++ b/providers/PlaybackProvider.tsx
@@ -11,6 +11,7 @@ import React, {
import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
+import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
@@ -20,15 +21,12 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
+import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
-import {
- parseM3U8ForSubtitles,
- SubtitleTrack,
-} from "@/utils/hls/parseM3U8ForSubtitles";
export type CurrentlyPlayingState = {
url: string;
@@ -70,6 +68,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const router = useRouter();
+
const videoRef = useRef(null);
const [settings] = useSettings();
@@ -137,6 +137,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
api,
itemId: state.item.Id,
sessionId: res.data.PlaySessionId,
+ deviceProfile: settings?.deviceProfile,
});
setSession(res.data);
@@ -326,6 +327,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
+ router.canGoBack() && router.back();
} else if (command === "Mute") {
console.log("Command ~ Mute");
setVolume(0);
diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts
index 143345f0..e69de29b 100644
--- a/utils/atoms/downloads.ts
+++ b/utils/atoms/downloads.ts
@@ -1,11 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { atom } from "jotai";
-
-export type ProcessItem = {
- item: BaseItemDto;
- progress: number;
- speed?: number;
- startTime?: Date;
-};
-
-export const runningProcesses = atom(null);
diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts
index 1e1ebdf8..4422dee9 100644
--- a/utils/atoms/primaryColor.ts
+++ b/utils/atoms/primaryColor.ts
@@ -2,12 +2,10 @@ import { atom, useAtom } from "jotai";
interface ThemeColors {
primary: string;
- secondary: string;
- average: string;
text: string;
}
-const calculateTextColor = (backgroundColor: string): string => {
+export const calculateTextColor = (backgroundColor: string): string => {
// Convert hex to RGB
const r = parseInt(backgroundColor.slice(1, 3), 16);
const g = parseInt(backgroundColor.slice(3, 5), 16);
@@ -48,7 +46,7 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
-const isCloseToBlack = (color: string): boolean => {
+export const isCloseToBlack = (color: string): boolean => {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
@@ -57,33 +55,13 @@ const isCloseToBlack = (color: string): boolean => {
return r < 20 && g < 20 && b < 20;
};
-const adjustToNearBlack = (color: string): string => {
+export const adjustToNearBlack = (color: string): string => {
return "#212121"; // A very dark gray, almost black
};
-const baseThemeColorAtom = atom({
+export const itemThemeColorAtom = atom({
primary: "#FFFFFF",
- secondary: "#000000",
- average: "#888888",
text: "#000000",
});
-export const itemThemeColorAtom = atom(
- (get) => get(baseThemeColorAtom),
- (get, set, update: Partial) => {
- const currentColors = get(baseThemeColorAtom);
- let newColors = { ...currentColors, ...update };
-
- // Adjust primary color if it's too close to black
- if (newColors.primary && isCloseToBlack(newColors.primary)) {
- newColors.primary = adjustToNearBlack(newColors.primary);
- }
-
- // Recalculate text color if primary color changes
- if (update.primary) newColors.text = calculateTextColor(newColors.primary);
-
- set(baseThemeColorAtom, newColors);
- }
-);
-
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts
index 09bccb37..2950d55a 100644
--- a/utils/atoms/queue.ts
+++ b/utils/atoms/queue.ts
@@ -1,5 +1,7 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import AsyncStorage from "@react-native-async-storage/async-storage";
import { atom, useAtom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
import { useEffect } from "react";
export interface Job {
@@ -8,8 +10,9 @@ export interface Job {
execute: () => void | Promise;
}
+export const runningAtom = atom(false);
+
export const queueAtom = atom([]);
-export const isProcessingAtom = atom(false);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
@@ -20,7 +23,7 @@ export const queueActions = {
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void,
+ setProcessing: (processing: boolean) => void
) => {
const [job, ...rest] = queue;
setQueue(rest);
@@ -28,13 +31,17 @@ export const queueActions = {
console.info("Processing job", job);
setProcessing(true);
+
+ // Excute the function assiociated with the job.
await job.execute();
+
console.info("Job done", job);
+
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void,
+ setProcessing: (processing: boolean) => void
) => {
setQueue([]);
setProcessing(false);
@@ -43,12 +50,12 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
- const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
+ const [running, setRunning] = useAtom(runningAtom);
useEffect(() => {
- if (queue.length > 0 && !isProcessing) {
+ if (queue.length > 0 && !running) {
console.info("Processing queue", queue);
- queueActions.processJob(queue, setQueue, setProcessing);
+ queueActions.processJob(queue, setQueue, setRunning);
}
- }, [queue, isProcessing, setQueue, setProcessing]);
+ }, [queue, running, setQueue, setRunning]);
};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index d4d8356a..cabfc8cf 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -54,7 +54,7 @@ export type DefaultLanguageOption = {
label: string;
};
-type Settings = {
+export type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
usePopularPlugin?: boolean;
@@ -72,6 +72,9 @@ type Settings = {
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
+ optimizedVersionsServerUrl?: string | null;
+ downloadMethod: "optimized" | "remux";
+ autoDownload: boolean;
};
/**
*
@@ -106,6 +109,9 @@ const loadSettings = async (): Promise => {
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,
+ optimizedVersionsServerUrl: null,
+ downloadMethod: "remux",
+ autoDownload: false,
};
try {
diff --git a/utils/background-tasks.ts b/utils/background-tasks.ts
new file mode 100644
index 00000000..1d7f0a70
--- /dev/null
+++ b/utils/background-tasks.ts
@@ -0,0 +1,23 @@
+import * as BackgroundFetch from "expo-background-fetch";
+
+export const BACKGROUND_FETCH_TASK = "background-fetch";
+
+export async function registerBackgroundFetchAsync() {
+ try {
+ BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
+ minimumInterval: 60 * 1, // 1 minutes
+ stopOnTerminate: false, // android only,
+ startOnBoot: false, // android only
+ });
+ } catch (error) {
+ console.log("Error registering background fetch task", error);
+ }
+}
+
+export async function unregisterBackgroundFetchAsync() {
+ try {
+ BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
+ } catch (error) {
+ console.log("Error unregistering background fetch task", error);
+ }
+}
diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts
index 337c355c..df13af1f 100644
--- a/utils/jellyfin/media/getStreamUrl.ts
+++ b/utils/jellyfin/media/getStreamUrl.ts
@@ -109,5 +109,12 @@ export const getStreamUrl = async ({
if (!url) throw new Error("No url");
+ console.log(
+ mediaSource.VideoType,
+ mediaSource.Container,
+ mediaSource.TranscodingContainer,
+ mediaSource.TranscodingSubProtocol
+ );
+
return url;
};
diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts
index 45e71ec6..d015482a 100644
--- a/utils/jellyfin/playstate/reportPlaybackProgress.ts
+++ b/utils/jellyfin/playstate/reportPlaybackProgress.ts
@@ -1,6 +1,7 @@
import { Api } from "@jellyfin/sdk";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
+import { Settings } from "@/utils/atoms/settings";
interface ReportPlaybackProgressParams {
api?: Api | null;
@@ -8,6 +9,7 @@ interface ReportPlaybackProgressParams {
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
+ deviceProfile?: Settings["deviceProfile"];
}
/**
@@ -22,6 +24,7 @@ export const reportPlaybackProgress = async ({
itemId,
positionTicks,
IsPaused = false,
+ deviceProfile,
}: ReportPlaybackProgressParams): Promise => {
if (!api || !sessionId || !itemId || !positionTicks) {
return;
@@ -34,6 +37,7 @@ export const reportPlaybackProgress = async ({
api,
itemId,
sessionId,
+ deviceProfile,
});
} catch (error) {
console.error("Failed to post capabilities.", error);
diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts
index ccb068c2..b7a3e795 100644
--- a/utils/jellyfin/session/capabilities.ts
+++ b/utils/jellyfin/session/capabilities.ts
@@ -1,16 +1,17 @@
+import { Settings } from "@/utils/atoms/settings";
+import ios from "@/utils/profiles/ios";
+import native from "@/utils/profiles/native";
+import old from "@/utils/profiles/old";
import { Api } from "@jellyfin/sdk";
-import {
- SessionApi,
- SessionApiPostCapabilitiesRequest,
-} from "@jellyfin/sdk/lib/generated-client/api/session-api";
-import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { AxiosError, AxiosResponse } from "axios";
+import { useMemo } from "react";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
+ deviceProfile: Settings["deviceProfile"];
}
/**
@@ -23,16 +24,26 @@ export const postCapabilities = async ({
api,
itemId,
sessionId,
+ deviceProfile,
}: PostCapabilitiesParams): Promise => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing parameters for marking item as not played");
}
+ let profile: any = ios;
+
+ if (deviceProfile === "Native") {
+ profile = native;
+ }
+ if (deviceProfile === "Old") {
+ profile = old;
+ }
+
try {
const d = api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
- playableMediaTypes: ["Audio", "Video", "Audio"],
+ playableMediaTypes: ["Audio", "Video"],
supportedCommands: [
"PlayState",
"Play",
@@ -45,6 +56,7 @@ export const postCapabilities = async ({
],
supportsMediaControl: true,
id: sessionId,
+ DeviceProfile: profile,
},
{
headers: getAuthHeaders(api),
diff --git a/utils/mmkv.ts b/utils/mmkv.ts
new file mode 100644
index 00000000..25706d78
--- /dev/null
+++ b/utils/mmkv.ts
@@ -0,0 +1,3 @@
+import { MMKV } from "react-native-mmkv";
+
+export const storage = new MMKV();
diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts
new file mode 100644
index 00000000..b7bb10fe
--- /dev/null
+++ b/utils/optimize-server.ts
@@ -0,0 +1,154 @@
+import { itemRouter } from "@/components/common/TouchableItemRouter";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import axios from "axios";
+
+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: Partial;
+ speed?: number;
+ timestamp: Date;
+ base64Image?: string;
+}
+
+/**
+ * Fetches all jobs for a specific device.
+ *
+ * @param {IGetAllDeviceJobs} params - The parameters for the API request.
+ * @param {string} params.deviceId - The ID of the device to fetch jobs for.
+ * @param {string} params.authHeader - The authorization header for the API request.
+ * @param {string} params.url - The base URL for the API endpoint.
+ *
+ * @returns {Promise} A promise that resolves to an array of job statuses.
+ *
+ * @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
+ */
+export async function getAllJobsByDeviceId({
+ deviceId,
+ authHeader,
+ url,
+}: IJobInput): Promise {
+ const statusResponse = await axios.get(`${url}all-jobs`, {
+ headers: {
+ Authorization: authHeader,
+ },
+ params: {
+ deviceId,
+ },
+ });
+ if (statusResponse.status !== 200) {
+ console.error(
+ statusResponse.status,
+ statusResponse.data,
+ statusResponse.statusText
+ );
+ throw new Error("Failed to fetch job status");
+ }
+
+ return statusResponse.data;
+}
+
+interface ICancelJob {
+ authHeader: string;
+ url: string;
+ id: string;
+}
+
+export async function cancelJobById({
+ authHeader,
+ url,
+ id,
+}: ICancelJob): Promise {
+ const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
+ headers: {
+ Authorization: authHeader,
+ },
+ });
+ if (statusResponse.status !== 200) {
+ throw new Error("Failed to cancel process");
+ }
+
+ return true;
+}
+
+export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
+ if (!deviceId) return false;
+ if (!authHeader) return false;
+ if (!url) return false;
+
+ try {
+ await getAllJobsByDeviceId({
+ deviceId,
+ authHeader,
+ url,
+ }).then((jobs) => {
+ jobs.forEach((job) => {
+ cancelJobById({
+ authHeader,
+ url,
+ id: job.id,
+ });
+ });
+ });
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Fetches statistics for a specific device.
+ *
+ * @param {IJobInput} params - The parameters for the API request.
+ * @param {string} params.deviceId - The ID of the device to fetch statistics for.
+ * @param {string} params.authHeader - The authorization header for the API request.
+ * @param {string} params.url - The base URL for the API endpoint.
+ *
+ * @returns {Promise} A promise that resolves to the statistics data or null if the request fails.
+ *
+ * @throws {Error} Throws an error if any required parameter is missing.
+ */
+export async function getStatistics({
+ authHeader,
+ url,
+ deviceId,
+}: IJobInput): Promise {
+ if (!deviceId || !authHeader || !url) {
+ return null;
+ }
+
+ try {
+ const statusResponse = await axios.get(`${url}statistics`, {
+ headers: {
+ Authorization: authHeader,
+ },
+ params: {
+ deviceId,
+ },
+ });
+
+ return statusResponse.data;
+ } catch (error) {
+ console.error("Failed to fetch statistics:", error);
+ return null;
+ }
+}