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