feat: custom transport bar, live refresh, collapsible sidebar

Replace WebKit's native video controls with our own. WebKit puts
fullscreen, Picture-in-Picture and volume as overlay buttons in the
video's top corners with no way to move them; owning the bar is the only
way to get every control into one strip along the bottom. Right-click on
the player is suppressed — its menu acted on a video the app does not
control.

One spinner now covers both waits: resolving the stream URL and
buffering it. It no longer sticks after a resume-seek, which fires
'waiting' after 'playing'.

Also: Open on YouTube and a new Download are real buttons in the player;
refresh is a compact icon; Settings is a cog; the subscription sidebar
collapses and reappears on a left-edge hover; the feed auto-refreshes
every 10 minutes while online, skipped while the player is open; the
title-bar strip takes the panel colour, since it sits above panels
rather than the page; and downloaded-only, hide-Shorts and the sidebar
state are remembered between launches.
This commit is contained in:
vincent
2026-08-29 11:03:29 +02:00
parent 61db10b2c8
commit 211823f265
6 changed files with 448 additions and 56 deletions
+35 -8
View File
@@ -1,4 +1,4 @@
import { BTN_PRIMARY, INPUT, Segmented } from "./ui";
import { INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid";
@@ -19,6 +19,8 @@ interface Props {
resultCount: number;
view: ViewMode;
onView: (v: ViewMode) => void;
sidebarHidden: boolean;
onShowSidebar: () => void;
}
/** Neutral outline until active; active is the one filled state. */
@@ -54,6 +56,7 @@ export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
@@ -65,6 +68,21 @@ export default function TopBar({
dark:border-slate-800 dark:bg-slate-900/95"
>
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
{sidebarHidden && (
<button
onClick={onShowSidebar}
title="Show subscriptions"
aria-label="Show subscriptions"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
border border-slate-300 text-slate-500 hover:border-sky-500
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
dark:hover:border-sky-500 dark:hover:text-sky-400"
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
@@ -120,13 +138,22 @@ export default function TopBar({
</button>
<button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos from every channel" : "Refreshing needs a connection"}
className={`${BTN_PRIMARY} cursor-pointer py-1.5 font-mono text-[11px] tabular-nums`}>
{refreshing
? refreshProgress
? `${refreshProgress.done}/${refreshProgress.total}`
: "Refreshing"
: "Refresh"}
title={
online
? refreshProgress
? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
: "Fetch the latest videos from every channel"
: "Refreshing needs a connection"
}
aria-label="Refresh"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
bg-sky-500 text-white hover:bg-sky-400 disabled:cursor-not-allowed
disabled:opacity-40">
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</svg>
</button>
</div>