Files
FlightTube/src/components/ui.tsx
T
vincent 9bb7b71225 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.
2026-08-29 11:28:05 +02:00

198 lines
6.6 KiB
TypeScript

/**
* The design system's component vocabulary, in one place. Every other file
* composes these rather than re-spelling the class strings.
*/
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";
export const HELP =
"text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const PANEL =
"bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-800";
export const SECTION =
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
`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 =
`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-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 font-semibold text-white ` +
"hover:bg-sky-400 disabled:opacity-40";
export const BTN_DANGER =
`${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 =
`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";
/** Reads as a link; sits at the edge of a group. */
export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/** The only spinning thing in the app; used where a wait has no known length. */
export function Spinner({ className = "size-4" }: { className?: string }) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function SectionHeading({
step,
children,
}: {
step?: number;
children: ReactNode;
}) {
return (
<h2 className={`flex items-center gap-2 ${HEADING}`}>
{step !== undefined && (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-sky-500 text-[9px] font-bold leading-none text-white">
{step}
</span>
)}
{children}
</h2>
);
}
/** Bordered container, borderless children — the group's outline does the framing. */
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div
role="group"
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;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={
"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 " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{o.label}
</button>
);
})}
</div>
);
}
/**
* Success confirmation of something that already happened, so it never asks to
* be dismissed. Making someone dismiss a box to be told it worked is a bug.
*/
export function Toast({ message }: { message: string | null }) {
return (
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[80] flex flex-col items-center gap-2">
{message && (
<div
role="status"
className="pointer-events-auto rounded-full border border-slate-300 bg-white px-4 py-2
text-[12.5px] shadow-xl transition-opacity duration-300
dark:border-slate-700 dark:bg-slate-800"
>
{message}
</div>
)}
</div>
);
}
/** A modal for anything needing a decision or reporting a failure. */
export function Dialog({
title,
children,
onCancel,
confirmLabel,
onConfirm,
destructive,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
confirmLabel?: string;
onConfirm?: () => void;
destructive?: boolean;
wide?: boolean;
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-lg max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
{onConfirm ? "Cancel" : "Close"}
</button>
{onConfirm && (
<button
onClick={onConfirm}
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
>
{confirmLabel ?? "Continue"}
</button>
)}
</div>
</div>
</div>
);
}