feat: uniform control height, chrome auto-hide, streaming quality

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.
This commit is contained in:
vincent
2026-08-29 11:28:05 +02:00
parent 211823f265
commit 9bb7b71225
16 changed files with 503 additions and 92 deletions
+16 -17
View File
@@ -1,6 +1,7 @@
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import { humanEta } from "./format";
import { CONTROL_H } from "./ui";
interface Props {
item: FeedItem;
@@ -11,7 +12,9 @@ interface Props {
onDelete: () => void;
}
const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer";
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,
@@ -38,22 +41,18 @@ export default function DownloadButton({
const known = state === "running" && pct != null;
return (
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
className={`${CHIP} group w-[104px] 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="hidden group-hover:block">Cancel</span>
<span className="block group-hover:hidden">
<span className="mb-1 block font-mono tabular-nums">
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
</span>
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<span
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${
known ? "" : "animate-pulse"
}`}
style={{ width: known ? `${pct}%` : "35%" }}
/>
</span>
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>
);