diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx index 0784ac9e..397df9de 100644 --- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx +++ b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx @@ -83,8 +83,8 @@ export default function page() { } - {groupBySeason.map((episode) => ( - + {groupBySeason.map((episode, index) => ( + ))} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 1f1fa74f..7f169b6c 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -63,12 +63,13 @@ export default function page() { Queue and downloads will be lost on app restart - {queue.map((q) => ( + {queue.map((q, index) => ( router.push(`/(auth)/items/page?id=${q.item.Id}`) } className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" + key={index} > {q.item.Name} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index edef436c..a8b4e065 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -8,13 +8,17 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById" import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams } from "expo-router"; +import {useLocalSearchParams, useNavigation} from "expo-router"; import { useAtom } from "jotai"; -import React from "react"; -import { useEffect, useMemo } from "react"; +import React, {useEffect} from "react"; +import { useMemo } from "react"; import { View } from "react-native"; +import {DownloadItems} from "@/components/DownloadItem"; +import {MaterialCommunityIcons} from "@expo/vector-icons"; +import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api"; const page: React.FC = () => { + const navigation = useNavigation(); const params = useLocalSearchParams(); const { id: seriesId, seasonIndex } = params as { id: string; @@ -56,7 +60,43 @@ const page: React.FC = () => { [item] ); - if (!item || !backdropUrl) return null; + const {data: allEpisodes, isLoading} = useQuery({ + queryKey: ["AllEpisodes", item?.Id], + queryFn: async () => { + const res = await getTvShowsApi(api!).getEpisodes({ + seriesId: item?.Id!, + userId: user?.Id!, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview"], + }); + return res?.data.Items || [] + }, + enabled: !!api && !!user?.Id && !!item?.Id + }); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + (!isLoading && allEpisodes && allEpisodes.length > 0) && ( + + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + + ) + ) + }) + }, [allEpisodes, isLoading]); + + if (!item || !backdropUrl) + return null; + return ( React.ReactElement; + DownloadedIconComponent: () => React.ReactElement; } -export const DownloadItem: React.FC = ({ item, ...props }) => { +export const DownloadItems: React.FC = ({ + items, + MissingDownloadIconComponent, + DownloadedIconComponent, + ...props +}) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); - const { processes, startBackgroundDownload } = useDownload(); + const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { startRemuxing } = useRemuxHlsToMp4(); - const [selectedMediaSource, setSelectedMediaSource] = useState< - MediaSourceInfo | undefined | null - >(undefined); + const [selectedMediaSource, setSelectedMediaSource] = useState(undefined); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); + const [selectedSubtitleStream, setSelectedSubtitleStream] = useState(0); const [maxBitrate, setMaxBitrate] = useState({ key: "Max", value: undefined, }); - useFocusEffect( - useCallback(() => { - if (!settings) return; - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(item, settings); - - // 4. Set states - setSelectedMediaSource(mediaSource ?? undefined); - setSelectedAudioStream(audioIndex ?? 0); - setSelectedSubtitleStream(subtitleIndex ?? -1); - setMaxBitrate(bitrate); - }, [item, settings]) - ); - - const userCanDownload = useMemo(() => { - return user?.Policy?.EnableContentDownloading; - }, [user]); + const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]); + const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]); /** * Bottom sheet @@ -89,70 +77,154 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { bottomSheetModalRef.current?.dismiss(); }, []); + // region computed + const itemIds = useMemo(() => items.map(i => i.Id), [items]); + const pendingItems = useMemo(() => + items.filter(i => !downloadedFiles?.some(f => f.item.Id === i.Id)), + [items, downloadedFiles] + ); + const isDownloaded = useMemo(() => { + if (!downloadedFiles) + return false; + return pendingItems.length == 0; + }, [downloadedFiles, pendingItems]); + + const itemsProcesses = useMemo(() => + processes?.filter(p => itemIds.includes(p.item.Id)), + [processes, itemIds] + ); + + const progress = useMemo(() => { + if (itemIds.length == 1) + return itemsProcesses.reduce((acc, p) => acc + p.progress, 0) + return ((itemIds.length - queue.filter(q => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100 + }, + [queue, itemsProcesses, itemIds] + ); + + const itemsQueued = useMemo(() => { + return pendingItems.length > 0 && pendingItems.every(p => queue.some(q => p.Id == q.item.Id)) + }, + [queue, pendingItems] + ); + // endregion computed + + // region helper functions + const navigateToDownloads = () => router.push("/downloads"); + + const onDownloadedPress = () => { + const firstItem = items?.[0] + router.push( + firstItem.Type !== "Episode" + ? "/downloads" + : { + pathname: `/downloads/${firstItem.SeriesId}`, + params: { + episodeSeasonIndex: firstItem.ParentIndexNumber + } + } as Href + ); + } + + const acceptDownloadOptions = useCallback(() => { + if (userCanDownload === true) { + if (pendingItems.some(i => !i.Id)) { + throw new Error("No item id"); + } + closeModal(); + + if (usingOptimizedServer) + initiateDownload(...pendingItems); + else { + queueActions.enqueue( + queue, + setQueue, + ...pendingItems.map(item => ({ + id: item.Id!, + execute: async () => await initiateDownload(item), + item, + })) + ) + } + } else { + toast.error("You are not allowed to download files."); + } + }, [ + queue, + setQueue, + pendingItems, + usingOptimizedServer, + userCanDownload, + + // Need to be reference at the time async lambda is created for initiateDownload + maxBitrate, + selectedMediaSource, + selectedAudioStream, + selectedSubtitleStream + ]) + /** * Start download */ - const initiateDownload = useCallback(async () => { - if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) { - throw new Error( - "DownloadItem ~ initiateDownload: No api or user or item" - ); + const initiateDownload = useCallback(async (...items: BaseItemDto[]) => { + if (!api || !user?.Id || items.some(p => !p.Id) || (pendingItems.length === 1 && !selectedMediaSource?.Id)) { + throw new Error("DownloadItem ~ initiateDownload: No api or user or item"); } + let mediaSource = selectedMediaSource + let audioIndex: number | undefined = selectedAudioStream + let subtitleIndex: number | undefined = selectedSubtitleStream - const res = await getStreamUrl({ - api, - item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: selectedAudioStream, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: selectedMediaSource.Id, - subtitleStreamIndex: selectedSubtitleStream, - deviceProfile: download, - }); + for (const item of items) { + if (pendingItems.length > 1) { + ({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(item, settings!)); + } - if (!res) { - Alert.alert( - "Something went wrong", - "Could not get stream url from Jellyfin" - ); - return; - } + const res = await getStreamUrl({ + api, + item, + startTimeTicks: 0, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: maxBitrate.value, + mediaSourceId: mediaSource?.Id, + subtitleStreamIndex: subtitleIndex, + deviceProfile: download, + }); - const { mediaSource, url } = res; + if (!res) { + Alert.alert( + "Something went wrong", + "Could not get stream url from Jellyfin" + ); + continue; + } - if (!url || !mediaSource) throw new Error("No url"); + const {mediaSource: source, url} = res; - saveDownloadItemInfoToDiskTmp(item, mediaSource, url); + if (!url || !source) throw new Error("No url"); - if (settings?.downloadMethod === "optimized") { - return await startBackgroundDownload(url, item, mediaSource); - } else { - return await startRemuxing(item, url, mediaSource); + saveDownloadItemInfoToDiskTmp(item, source, url); + + if (usingOptimizedServer) { + await startBackgroundDownload(url, item, source); + } else { + await startRemuxing(item, url, source); + } } }, [ api, - item, - startBackgroundDownload, user?.Id, + pendingItems, selectedMediaSource, selectedAudioStream, selectedSubtitleStream, + settings, maxBitrate, - settings?.downloadMethod, + usingOptimizedServer, + startBackgroundDownload, + startRemuxing, ]); - /** - * Check if item is downloaded - */ - const { downloadedFiles } = useDownload(); - - const isDownloaded = useMemo(() => { - if (!downloadedFiles) return false; - - return downloadedFiles.some((file) => file.item.Id === item.Id); - }, [downloadedFiles, item.Id]); - const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( = ({ item, ...props }) => { ), [] ); + // endregion helper functions - const process = useMemo(() => { - if (!processes) return null; + // Allow to select & set settings for single download + useFocusEffect( + useCallback(() => { + if (!settings) return; + if (pendingItems.length !== 1) return; + const { bitrate, mediaSource, audioIndex, subtitleIndex } = + getDefaultPlaySettings(items[0], settings); - return processes.find((process) => process?.item?.Id === item.Id); - }, [processes, item.Id]); + // 4. Set states + setSelectedMediaSource(mediaSource ?? undefined); + setSelectedAudioStream(audioIndex ?? 0); + setSelectedSubtitleStream(subtitleIndex ?? -1); + setMaxBitrate(bitrate); + }, [items, pendingItems, settings]) + ); return ( - {process && process?.item.Id === item.Id ? ( - { - router.push("/downloads"); - }} - > - {process.progress === 0 ? ( + {processes && itemsProcesses.length > 0 ? ( + + {progress === 0 ? ( ) : ( = ({ item, ...props }) => { )} - ) : queue.some((i) => i.id === item.Id) ? ( - { - router.push("/downloads"); - }} - > + ) : itemsQueued ? ( + ) : isDownloaded ? ( - { - router.push( - item.Type !== "Episode" - ? "/downloads" - : { - pathname: `/downloads/${item.SeriesId}`, - params: { - episodeSeasonIndex: item.ParentIndexNumber - } - } as Href - ); - }} - > - + + {DownloadedIconComponent()} ) : ( - + {MissingDownloadIconComponent()} )} = ({ item, ...props }) => { setMaxBitrate(val)} + onChange={setMaxBitrate} selected={maxBitrate} /> - - {selectedMediaSource && ( - - + - - + {selectedMediaSource && ( + + + + + )} + )} - {settings?.downloadMethod === "optimized" ? ( - Using optimized server - ) : ( - Using default method - )} + + {usingOptimizedServer ? "Using optimized server" : "Using default method"} + @@ -308,3 +352,17 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { ); }; + +export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { + return ( + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + ) +} diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 627c3a92..cab8e641 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -1,6 +1,6 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { DownloadItem } from "@/components/DownloadItem"; +import {DownloadSingleItem} from "@/components/DownloadItem"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlayButton } from "@/components/PlayButton"; @@ -87,7 +87,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( {item.Type !== "Program" && ( - + )} diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 1fa3b3b7..d968532b 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -6,13 +6,14 @@ import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; -import { DownloadItem } from "../DownloadItem"; +import {DownloadItems, DownloadSingleItem} from "../DownloadItem"; import { Loader } from "../Loader"; import { Text } from "../common/Text"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown"; +import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons"; type Props = { item: BaseItemDto; @@ -108,16 +109,27 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { minHeight: 144 * nrOfEpisodes, }} > - { - setSeasonIndexState((prev) => ({ - ...prev, - [item.Id ?? ""]: season.IndexNumber, - })); - }} /> + + { + setSeasonIndexState((prev) => ({ + ...prev, + [item.Id ?? ""]: season.IndexNumber, + })); + }} /> + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + {isFetching ? ( = ({ item, initialSeasonIndex }) => { - + diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 8bd45ffa..8ec21e65 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -13,8 +13,8 @@ export const runningAtom = atom(false); export const queueAtom = atom([]); export const queueActions = { - enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => { - const updatedQueue = [...queue, job]; + enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => { + const updatedQueue = [...queue, ...job]; console.info("Enqueueing job", job, updatedQueue); setQueue(updatedQueue); }, diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index 4dc27afd..d82b387f 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -10,8 +10,8 @@ interface PlaySettings { item: BaseItemDto; bitrate: (typeof BITRATES)[0]; mediaSource?: MediaSourceInfo | null; - audioIndex?: number | null; - subtitleIndex?: number | null; + audioIndex?: number | undefined; + subtitleIndex?: number | undefined; } export function getDefaultPlaySettings(