import Ionicons from "@expo/vector-icons/Ionicons"; import { BottomSheetBackdrop, type BottomSheetBackdropProps, BottomSheetModal, BottomSheetView, } from "@gorhom/bottom-sheet"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { type Href, router, useFocusEffect } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; interface DownloadProps extends ViewProps { items: BaseItemDto[]; MissingDownloadIconComponent: () => React.ReactElement; DownloadedIconComponent: () => React.ReactElement; title?: string; subtitle?: string; size?: "default" | "large"; } export const DownloadItems: React.FC = ({ items, MissingDownloadIconComponent, DownloadedIconComponent, title = "Download", subtitle = "", size = "default", ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [queue, _setQueue] = useAtom(queueAtom); const [settings] = useSettings(); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const { processes, startBackgroundDownload, getDownloadedItems } = useDownload(); const downloadedFiles = getDownloadedItems(); const [selectedMediaSource, setSelectedMediaSource] = useState< MediaSourceInfo | undefined | null >(undefined); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); const [selectedSubtitleStream, setSelectedSubtitleStream] = useState(0); const [maxBitrate, setMaxBitrate] = useState( settings?.defaultBitrate ?? { key: "Max", value: undefined, }, ); const userCanDownload = useMemo( () => user?.Policy?.EnableContentDownloading, [user], ); const bottomSheetModalRef = useRef(null); const handlePresentModalPress = useCallback(() => { bottomSheetModalRef.current?.present(); }, []); const handleSheetChanges = useCallback((_index: number) => {}, []); const closeModal = useCallback(() => { bottomSheetModalRef.current?.dismiss(); }, []); const itemIds = useMemo(() => items.map((i) => i.Id), [items]); const itemsNotDownloaded = useMemo( () => items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), [items, downloadedFiles], ); const itemsToDownload = useMemo(() => { if (downloadUnwatchedOnly) { return itemsNotDownloaded.filter((item) => !item.UserData?.Played); } return itemsNotDownloaded; }, [itemsNotDownloaded, downloadUnwatchedOnly]); const allItemsDownloaded = useMemo(() => { if (items.length === 0) return false; return itemsNotDownloaded.length === 0; }, [items, itemsNotDownloaded]); const itemsProcesses = useMemo( () => processes?.filter((p) => itemIds.includes(p.item.Id)), [processes, itemIds], ); const progress = useMemo(() => { if (itemIds.length === 1) return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); return ( ((itemIds.length - queue.filter((q) => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100 ); }, [queue, itemsProcesses, itemIds]); const itemsQueued = useMemo(() => { return ( itemsNotDownloaded.length > 0 && itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id)) ); }, [queue, itemsNotDownloaded]); const navigateToDownloads = () => router.push("/downloads"); const onDownloadedPress = () => { const firstItem = items?.[0]; router.push( firstItem.Type !== "Episode" ? "/downloads" : ({ pathname: `/downloads/${firstItem.SeriesId}`, params: { episodeSeasonIndex: firstItem.ParentIndexNumber, }, } as Href), ); }; const initiateDownload = useCallback( async (...items: BaseItemDto[]) => { if ( !api || !user?.Id || items.some((p) => !p.Id) || (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) ) { throw new Error( "DownloadItem ~ initiateDownload: No api or user or item", ); } const downloadDetailsPromises = items.map(async (item) => { const { mediaSource, audioIndex, subtitleIndex } = itemsNotDownloaded.length > 1 ? getDefaultPlaySettings(item, settings!) : { mediaSource: selectedMediaSource, audioIndex: selectedAudioStream, subtitleIndex: selectedSubtitleStream, }; const downloadDetails = await getDownloadUrl({ api, item, userId: user.Id!, mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, subtitleStreamIndex: subtitleIndex ?? -1, maxBitrate, deviceId: api.deviceInfo.id, }); return { url: downloadDetails?.url, item, mediaSource: downloadDetails?.mediaSource, }; }); const downloadDetails = await Promise.all(downloadDetailsPromises); for (const { url, item, mediaSource } of downloadDetails) { if (!url) { Alert.alert( t("home.downloads.something_went_wrong"), t("home.downloads.could_not_get_stream_url_from_jellyfin"), ); continue; } if (!mediaSource) { console.error(`Could not get download URL for ${item.Name}`); toast.error( t("Could not get download URL for {{itemName}}", { itemName: item.Name, }), ); continue; } await startBackgroundDownload(url, item, mediaSource, maxBitrate); } }, [ api, user?.Id, itemsNotDownloaded, selectedMediaSource, selectedAudioStream, selectedSubtitleStream, settings, maxBitrate, startBackgroundDownload, ], ); const acceptDownloadOptions = useCallback(() => { if (userCanDownload === true) { if (itemsToDownload.some((i) => !i.Id)) { throw new Error("No item id"); } closeModal(); initiateDownload(...itemsToDownload); } else { toast.error( t("home.downloads.toasts.you_are_not_allowed_to_download_files"), ); } }, [closeModal, initiateDownload, itemsToDownload, userCanDownload]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( ), [], ); useFocusEffect( useCallback(() => { if (!settings) return; if (itemsNotDownloaded.length !== 1) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(items[0], settings); setSelectedMediaSource(mediaSource ?? undefined); setSelectedAudioStream(audioIndex ?? 0); setSelectedSubtitleStream(subtitleIndex ?? -1); setMaxBitrate(bitrate); }, [items, itemsNotDownloaded, settings]), ); const renderButtonContent = () => { if (processes.length > 0 && itemsProcesses.length > 0) { return progress === 0 ? ( ) : ( ); } if (itemsQueued) { return ; } if (allItemsDownloaded) { return ; } return ; }; const onButtonPress = () => { if (processes && itemsProcesses.length > 0) { navigateToDownloads(); } else if (itemsQueued) { navigateToDownloads(); } else if (allItemsDownloaded) { onDownloadedPress(); } else { handlePresentModalPress(); } }; return ( {renderButtonContent()} {title} {subtitle || t("item_card.download.download_x_item", { item_count: itemsToDownload.length, })} {itemsNotDownloaded.length > 1 && ( {t("item_card.download.download_unwatched_only")} )} {itemsNotDownloaded.length === 1 && ( <> {selectedMediaSource && ( )} )} ); }; export const DownloadSingleItem: React.FC<{ size?: "default" | "large"; item: BaseItemDto; }> = ({ item, size = "default" }) => { if (Platform.isTV) return; return ( ( )} DownloadedIconComponent={() => ( )} /> ); };