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
+43 -7
View File
@@ -3,10 +3,12 @@ import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types";
import {
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
} from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
} from "./ui";
interface Props {
@@ -16,9 +18,15 @@ interface Props {
onAppearance: (a: Appearance) => void;
quality: Quality;
onQuality: (q: Quality) => void;
streamQuality: Quality;
onStreamQuality: (q: Quality) => void;
onError: (message: string) => void;
}
const SELECT =
`w-full ${CONTROL_H} cursor-pointer rounded-lg border border-slate-300 bg-white px-2 ` +
"text-[12px] outline-none dark:border-slate-700 dark:bg-slate-800";
function StatusRow({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
@@ -35,7 +43,8 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
}
export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
onClose, onImported, appearance, onAppearance, quality, onQuality,
streamQuality, onStreamQuality, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
@@ -96,7 +105,17 @@ export default function Settings({
>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button>
<button
onClick={onClose}
title="Close settings"
aria-label="Close settings"
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900
dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
@@ -135,9 +154,7 @@ export default function Settings({
<select
value={quality}
onChange={(e) => onQuality(e.target.value as Quality)}
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5
text-[13px] outline-none cursor-pointer
dark:border-slate-700 dark:bg-slate-800"
className={SELECT}
>
{QUALITIES.map((q) => (
<option key={q.value} value={q.value}>
@@ -146,6 +163,25 @@ export default function Settings({
))}
</select>
</label>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Streaming</span>
<select
value={streamQuality}
onChange={(e) => onStreamQuality(e.target.value as Quality)}
className={SELECT}
>
{STREAM_QUALITIES.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
Streaming quality applies when you play something you have not downloaded.
Best lets the player adapt to your connection; a fixed height pins it.
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">