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
+22 -9
View File
@@ -4,6 +4,17 @@
*/
import type { ReactNode } from "react";
/**
* Every control in the app is this tall. Width still grows with the label —
* only the height is fixed, so a row of mixed buttons lines up.
*/
export const CONTROL_H = "h-[30px]";
/** Square version, for icon-only buttons. */
export const ICON_BTN =
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
"disabled:cursor-not-allowed disabled:opacity-40";
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
@@ -14,27 +25,29 @@ export const PANEL =
export const SECTION =
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " +
`w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed";
const BTN_BASE =
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
"disabled:cursor-not-allowed";
export const BTN =
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` +
`${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` +
`${BTN_BASE} bg-sky-500 px-3 font-semibold text-white ` +
"hover:bg-sky-400 disabled:opacity-40";
export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`;
`${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME =
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " +
`inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
@@ -85,7 +98,7 @@ export function Segmented<T extends string>({
return (
<div
role="group"
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700"
className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
>
{options.map((o) => {
const active = o.value === value;
@@ -94,7 +107,7 @@ export function Segmented<T extends string>({
key={o.value}
onClick={() => onChange(o.value)}
className={
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " +
"h-full rounded-md px-2.5 text-[12px] font-medium transition-colors cursor-pointer " +
(active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
@@ -172,7 +185,7 @@ export function Dialog({
{onConfirm && (
<button
onClick={onConfirm}
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`}
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
>
{confirmLabel ?? "Continue"}
</button>