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
+83 -16
View File
@@ -16,12 +16,24 @@ import { useFeed } from "./hooks/useFeed";
import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types";
const TOAST_MS = 2400;
/** How often to pull new videos while online, so the feed stays live. */
const AUTO_REFRESH_MS = 10 * 60 * 1000;
function remembered(key: string): boolean {
try {
return localStorage.getItem(`flighttube.${key}`) === "1";
} catch {
return false;
}
}
export default function App() {
const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(false);
const [hideShorts, setHideShorts] = useState(false);
const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => {
try {
return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list";
@@ -62,10 +74,13 @@ export default function App() {
try {
localStorage.setItem("flighttube.view", view);
localStorage.setItem("flighttube.quality", quality);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch {
/* storage blocked */
}
}, [view, quality]);
}, [view, quality, downloadedOnly, hideShorts, sidebarHidden]);
// Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle.
@@ -165,6 +180,16 @@ export default function App() {
if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null);
}, [items, playingIndex]);
// Keep the feed live while online. Skipped whenever a refresh is already
// running or the player is open, so it never yanks the list under you.
useEffect(() => {
if (!online) return;
const id = setInterval(() => {
if (!refreshing && playingIndex == null) void doRefresh();
}, AUTO_REFRESH_MS);
return () => clearInterval(id);
}, [online, refreshing, playingIndex, doRefresh]);
const emptyMessage = () => {
if (loading) return "Loading…";
if (channels.length === 0)
@@ -177,23 +202,52 @@ export default function App() {
return (
<div className="flex h-screen flex-col">
{/* The title bar is transparent, so the webview paints this strip itself
in the page background — the macOS traffic lights sit on top of it.
Without the drag region the strip would be dead space. */}
{/* The webview paints the title bar itself. It sits directly above the
sidebar and top bar, so it takes the panel colour, not the page's. */}
<div
data-tauri-drag-region
className="h-9 shrink-0 bg-slate-100 dark:bg-slate-950"
className="h-9 shrink-0 bg-white dark:bg-slate-900"
/>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<Sidebar
channels={channels}
activeChannel={channelId}
onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
/>
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
{/* With the sidebar hidden, a thin strip along the left edge brings it
back on hover. */}
{sidebarHidden && (
<div
onMouseEnter={() => setSidebarPeek(true)}
className="absolute inset-y-0 left-0 z-20 w-3"
aria-hidden
/>
)}
{sidebarHidden && sidebarPeek && (
<div
onMouseLeave={() => setSidebarPeek(false)}
className="absolute inset-y-0 left-0 z-30 w-[280px]"
>
<Sidebar
floating
channels={channels}
activeChannel={channelId}
onSelect={(id) => { setChannelId(id); setSidebarPeek(false); }}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }}
/>
</div>
)}
{!sidebarHidden && (
<Sidebar
channels={channels}
activeChannel={channelId}
onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => setSidebarHidden(true)}
/>
)}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar
@@ -205,6 +259,8 @@ export default function App() {
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length}
view={view} onView={setView}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
/>
{!online && (
@@ -276,6 +332,17 @@ export default function App() {
? () => setPlayingIndex(stepFrom(playingIndex, 1))
: undefined
}
onDownload={
playing.path === null
? () => {
downloadVideo(playing.item.id, quality).catch((e) => setFailure(String(e)));
say("Download started");
}
: undefined
}
downloading={["queued", "running"].includes(
live[playing.item.id]?.state ?? playing.item.state ?? "",
)}
onClose={() => { setPlayingIndex(null); reload(); }}
onDelete={async () => {
await deleteDownload(playing.item.id);