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