import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import download from "@/utils/profiles/download"; import Ionicons from "@expo/vector-icons/Ionicons"; import { BottomSheetBackdrop, 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, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { Text } from "./common/Text"; 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 { processes, startBackgroundDownload, downloadedFiles } = useDownload(); //const { startRemuxing } = useRemuxHlsToMp4(); 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 usingOptimizedServer = useMemo( () => settings?.downloadMethod === DownloadMethod.Optimized, [settings], ); 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 allItemsDownloaded = useMemo(() => { if (items.length === 0) return false; return itemsNotDownloaded.length === 0; }, [items, itemsNotDownloaded]); const itemsProcesses = useMemo( () => processes?.filter((p) => itemIds.includes(p.item.Id)), [processes, itemIds], ); const progress = useMemo(() => { if (itemIds.length === 1) return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); return ( ((itemIds.length - queue.filter((q) => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100 ); }, [queue, itemsProcesses, itemIds]); const itemsQueued = useMemo(() => { return ( itemsNotDownloaded.length > 0 && itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id)) ); }, [queue, itemsNotDownloaded]); const navigateToDownloads = () => router.push("/downloads"); const onDownloadedPress = () => { const firstItem = items?.[0]; router.push( firstItem.Type !== "Episode" ? "/downloads" : ({ pathname: `/downloads/${firstItem.SeriesId}`, params: { episodeSeasonIndex: firstItem.ParentIndexNumber, }, } as Href), ); }; const acceptDownloadOptions = useCallback(() => { if (userCanDownload === true) { if (itemsNotDownloaded.some((i) => !i.Id)) { throw new Error("No item id"); } closeModal(); initiateDownload(...itemsNotDownloaded); } else { toast.error( t("home.downloads.toasts.you_are_not_allowed_to_download_files"), ); } }, [ queue, setQueue, itemsNotDownloaded, usingOptimizedServer, userCanDownload, maxBitrate, selectedMediaSource, selectedAudioStream, selectedSubtitleStream, ]); const initiateDownload = useCallback( async (...items: BaseItemDto[]) => { if ( !api || !user?.Id || items.some((p) => !p.Id) || (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) ) { throw new Error( "DownloadItem ~ initiateDownload: No api or user or item", ); } let mediaSource = selectedMediaSource; let audioIndex: number | undefined = selectedAudioStream; let subtitleIndex: number | undefined = selectedSubtitleStream; for (const item of items) { if (itemsNotDownloaded.length > 1) { const defaults = getDefaultPlaySettings(item, settings!); mediaSource = defaults.mediaSource; audioIndex = defaults.audioIndex; subtitleIndex = defaults.subtitleIndex; } const res = await getStreamUrl({ api, item, startTimeTicks: 0, userId: user?.Id, audioStreamIndex: audioIndex, maxStreamingBitrate: maxBitrate.value, mediaSourceId: mediaSource?.Id, subtitleStreamIndex: subtitleIndex, deviceProfile: download, download: true, // deviceId: mediaSource?.Id, }); if (!res) { Alert.alert( t("home.downloads.something_went_wrong"), t("home.downloads.could_not_get_stream_url_from_jellyfin"), ); continue; } const { mediaSource: source, url } = res; if (!url || !source) throw new Error("No url"); saveDownloadItemInfoToDiskTmp(item, source, url); await startBackgroundDownload(url, item, source, maxBitrate); } }, [ api, user?.Id, itemsNotDownloaded, selectedMediaSource, selectedAudioStream, selectedSubtitleStream, settings, maxBitrate, usingOptimizedServer, startBackgroundDownload, ], ); 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: itemsNotDownloaded.length, })} {itemsNotDownloaded.length === 1 && ( <> {selectedMediaSource && ( )} )} {usingOptimizedServer ? t("item_card.download.using_optimized_server") : t("item_card.download.using_default_method")} ); }; export const DownloadSingleItem: React.FC<{ size?: "default" | "large"; item: BaseItemDto; }> = ({ item, size = "default" }) => { if (Platform.isTV) return; return ( ( )} DownloadedIconComponent={() => ( )} /> ); };