All files / src/components/downloads DownloadButton.tsx

94.11% Statements 32/34
96.87% Branches 31/32
88.88% Functions 8/9
96.66% Lines 29/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                    12x                                               12x                     1949x 1949x       1949x 1949x 65x 65x     1949x   1949x 38x   38x 24x 13x     1949x   1949x                                                         7x 7x   1x                       13x 13x                         5x           12x 1949x   71x           22x           58x   4x   1794x      
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useState } from "react";
import { Download, CloudDownload, CircleCheck, LoaderCircle, CircleAlert } from "lucide-react";
import { ConfirmDialog } from "./ConfirmDialog";
import { isMobilePlatform } from "@/lib/platform";
import type { DownloadIconState } from "@/lib/types";
 
/** The mobile apps can't run the ffmpeg sidecar, so they have no downloads. */
const DOWNLOADS_SUPPORTED = !isMobilePlatform();
 
interface DownloadButtonProps {
	/**
	 * The item the button is for. When this changes, a confirm left open closes,
	 * so it can't stop or remove the next item's download, and focus leaves the
	 * button, so Space can't start one.
	 */
	itemId?: string;
	state: DownloadIconState;
	/** Start (idle/partial). For a series or season this downloads all missing episodes.
	 *  Any large-batch confirm is handled by the parent. */
	onStart: () => void;
	/** Stop in-progress (downloading). */
	onStop: () => void;
	stopCount?: number;
	/** Remove the finished downloads. If it rejects, the user sees the error. */
	onRemove: () => Promise<unknown> | undefined;
	removeSummary?: string;
	/** Render a text label beside the icon (info page only). */
	showLabel?: boolean;
	className?: string;
}
 
export const DownloadButton = ({
	itemId,
	state,
	onStart,
	onStop,
	stopCount,
	onRemove,
	removeSummary,
	showLabel = false,
	className = "",
}: DownloadButtonProps) => {
	const [confirm, setConfirm] = useState<null | "stop" | "remove">(null);
	const [removeError, setRemoveError] = useState<string | null>(null);
	// A confirm left open for the last item closes during render, not in an
	// effect, so it never shows for the new one. A removal still running can
	// still report a failure.
	const [confirmItemId, setConfirmItemId] = useState(itemId);
	if (confirmItemId !== itemId) {
		setConfirmItemId(itemId);
		setConfirm(null);
	}
 
	Iif (!DOWNLOADS_SUPPORTED) return null;
 
	const handleClick = (e: React.MouseEvent) => {
		e.stopPropagation();
		// "failed" behaves like idle: clicking retries the download.
		if (state === "idle" || state === "partial" || state === "failed") onStart();
		else if (state === "downloading") setConfirm("stop");
		else setConfirm("remove"); // complete → remove
	};
 
	const { Icon, color, label } = iconFor(state);
 
	return (
		<>
			{/* Keyed by item, so each item gets a new button and the old one
			    takes focus with it. A dialog hands focus back on close only to
			    an element still on the page, so neither a confirm nor the
			    failure notice can put focus on the new button, where Space
			    would press it for the new item. */}
			<button
				key={itemId}
				onClick={handleClick}
				aria-label={label}
				className={`flex items-center gap-1.5 ${className}`}
			>
				<Icon
					className={`h-3.5 w-3.5 ${color} ${state === "downloading" ? "animate-pulse" : ""}`}
				/>
				{showLabel && <span className={`text-xs ${color}`}>{label}</span>}
			</button>
 
			<ConfirmDialog
				open={confirm === "stop"}
				title="Stop download?"
				message={
					stopCount && stopCount > 1
						? `Stop ${stopCount} downloads? Partial files are discarded.`
						: "Stop this download? The partial file is discarded."
				}
				confirmLabel="Stop"
				onConfirm={() => {
					setConfirm(null);
					onStop();
				}}
				onCancel={() => setConfirm(null)}
			/>
			<ConfirmDialog
				open={confirm === "remove"}
				title="Remove download?"
				message={
					removeSummary
						? `Remove ${removeSummary}? This deletes the downloaded file(s).`
						: "Remove this download? This deletes the downloaded file."
				}
				confirmLabel="Remove"
				onConfirm={() => {
					setConfirm(null);
					onRemove()?.catch((err: unknown) => setRemoveError(String(err)));
				}}
				onCancel={() => setConfirm(null)}
			/>
			<ConfirmDialog
				open={removeError !== null}
				title={
					removeSummary
						? "Couldn't remove every download"
						: "Couldn't remove the download"
				}
				message={removeError ?? ""}
				confirmLabel="OK"
				onConfirm={() => setRemoveError(null)}
			/>
		</>
	);
};
 
const iconFor = (state: DownloadIconState) => {
	switch (state) {
		case "complete":
			return {
				Icon: CircleCheck,
				color: "text-emerald-500",
				label: "Downloaded",
			};
		case "partial":
			return {
				Icon: CloudDownload,
				color: "text-amber-400",
				label: "Partially downloaded",
			};
		case "downloading":
			return { Icon: LoaderCircle, color: "text-blue-400", label: "Downloading" };
		case "failed":
			return { Icon: CircleAlert, color: "text-red-500", label: "Download failed — retry" };
		default:
			return { Icon: Download, color: "text-muted-foreground", label: "Download" };
	}
};