Every control in the app now shares one height (CONTROL_H), with width still growing to fit its label. The player transport is larger, Back is an icon, and the footer buttons match the prev/next size. Player chrome — transport and edge arrows — fades after 2.6s of inactivity and never hides while paused. The title-bar strip collapses in macOS window fullscreen, where the traffic lights are gone and it was just a blank white bar. Streaming quality is now selectable alongside download quality. A cap is applied by rewriting YouTube's HLS master playlist down to the best variant at or below the chosen height, keeping its audio group. That playlist is served from a small loopback HTTP server: Safari's native HLS is backed by AVFoundation, which cannot read blob: or custom-scheme URLs, so a Blob URL silently fails to play. Settings closes with an icon button and its selects match the shared control height.
80 lines
3.1 KiB
TypeScript
80 lines
3.1 KiB
TypeScript
import type { LiveDownload } from "../hooks/useDownloads";
|
|
import type { FeedItem } from "../types";
|
|
import { humanEta } from "./format";
|
|
import { CONTROL_H } from "./ui";
|
|
|
|
interface Props {
|
|
item: FeedItem;
|
|
live?: LiveDownload;
|
|
online: boolean;
|
|
onDownload: () => void;
|
|
onCancel: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const CHIP =
|
|
`inline-flex ${CONTROL_H} shrink-0 cursor-pointer items-center justify-center rounded-lg ` +
|
|
"px-2.5 text-[12px] font-medium";
|
|
|
|
export default function DownloadButton({
|
|
item, live, online, onDownload, onCancel, onDelete,
|
|
}: Props) {
|
|
const state = live?.state ?? item.state ?? null;
|
|
const pct = live?.pct ?? item.pct ?? null;
|
|
const error = live?.error ?? item.error ?? null;
|
|
|
|
if (state === "done") {
|
|
// Neutral until hovered, then reveals red — destruction is never
|
|
// pre-coloured on a control that is not currently destructive.
|
|
return (
|
|
<button onClick={onDelete} title="Delete the downloaded file"
|
|
className={`${CHIP} group border border-slate-300 text-slate-500
|
|
hover:border-red-500 hover:text-red-600
|
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
|
|
<span className="group-hover:hidden">Saved</span>
|
|
<span className="hidden group-hover:inline">Delete</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
if (state === "running" || state === "queued") {
|
|
const known = state === "running" && pct != null;
|
|
return (
|
|
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
|
|
className={`${CHIP} group relative w-[104px] overflow-hidden border border-slate-300
|
|
text-slate-500 hover:border-red-500 hover:text-red-600
|
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
|
|
dark:hover:text-red-400`}>
|
|
{/* Progress fills the chip itself, so the control stays one line tall. */}
|
|
<span
|
|
className="absolute inset-y-0 left-0 bg-sky-500/15 transition-[width] duration-100"
|
|
style={{ width: known ? `${pct}%` : "35%" }}
|
|
/>
|
|
<span className="relative hidden group-hover:inline">Cancel</span>
|
|
<span className="relative inline font-mono tabular-nums group-hover:hidden">
|
|
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const failed = state === "failed";
|
|
return (
|
|
<button onClick={onDownload} disabled={!online}
|
|
title={
|
|
!online
|
|
? "You are offline — downloading needs a connection"
|
|
: failed && error
|
|
? error
|
|
: "Download for offline viewing"
|
|
}
|
|
className={`${CHIP} border disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
failed
|
|
? "border-red-500/60 text-red-600 hover:bg-red-500/5 dark:text-red-400"
|
|
: "border-slate-300 hover:border-sky-500 hover:text-sky-600 dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400"
|
|
}`}>
|
|
{failed ? "Retry" : "Download"}
|
|
</button>
|
|
);
|
|
}
|