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:
+83
-16
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user