Files
FlightTube/src/components/TrayPanel.tsx
T
Vincent Rozenberg 35f14ab044 feat: fullscreen carries across videos, and stays out of the way
In fullscreen, Next now keeps you there and shows nothing but the
picture: one video after another, no arrows, no header, no counter.

Three things had to change for that. The player was keyed on the video,
so every Next rebuilt the stage — and the stage is what is fullscreen.
It now survives, resetting its own per-video state instead of being
thrown away to get it.

The cleanup that ran on every change of source called exitFullscreen
outright. It exists for the Picture-in-Picture leak, which does need
handling per video; leaving fullscreen and dropping the source belong
to leaving the player, and are now only done there.

And resolving a stream set the source to nothing first, which unmounted
the element mid-fullscreen. The outgoing video is paused in place
instead, and the source goes straight from one address to the next.

The edge arrows are gone in fullscreen. The transport bar stays — it is
playback, not navigation — and fades on its own as before.

Also: Remove all downloads in the menu bar panel, which asks in the
window rather than in a panel that closes when you look away; and the
JavaScript round-trip logging is gone now that the scrape is settled.

Verified in the running app: three videos in a row without leaving
fullscreen, chrome faded to nothing, and Remove all reaching the
window's own "Delete every download?" with 50 downloads left untouched.
2026-09-04 01:56:08 +02:00

181 lines
5.5 KiB
TypeScript

import { useEffect, useState } from "react";
import { emitTo } from "@tauri-apps/api/event";
import {
hidePanel,
quitApp,
scrapeSubscriptions,
showMainWindow,
traySaveVideo,
} from "../api";
import { Spinner } from "./ui";
/**
* The menu bar panel.
*
* A window rather than a native menu, so it is the app's own type, spacing and
* colours instead of the system's. It hangs from the icon and closes when it
* loses focus, which is what a menu does; everything else about it is ours.
*/
interface RowProps {
label: string;
hint?: string;
onClick: () => void;
busy?: boolean;
icon: React.ReactNode;
}
function Row({ label, hint, onClick, busy, icon }: RowProps) {
return (
<button
onClick={onClick}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left
transition-colors hover:bg-slate-100 disabled:cursor-wait
dark:hover:bg-slate-800"
>
<span className="grid size-6 shrink-0 place-items-center text-slate-400 dark:text-slate-500">
{busy ? <Spinner className="size-3.5" /> : icon}
</span>
<span className="min-w-0">
<span className="block truncate text-[12.5px] font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{hint && (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{hint}
</span>
)}
</span>
</button>
);
}
const ICON = "size-4";
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8 } as const;
export default function TrayPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
// The panel is its own window, so it carries no page background of the app's.
useEffect(() => {
document.documentElement.style.background = "transparent";
document.body.style.background = "transparent";
}, []);
const run = (id: string, fn: () => Promise<unknown>, closeAfter = true) => {
setBusy(id);
setNote(null);
fn()
.then(() => {
if (closeAfter) void hidePanel();
})
.catch((e) => setNote(String(e)))
.finally(() => setBusy(null));
};
const importSubscriptions = () =>
run(
"subs",
async () => {
const result = await scrapeSubscriptions();
// The window owns the confirmation: replacing the list is not a thing
// to agree to in a panel that closes when you look away.
await emitTo("main", "subs:scraped", result);
await showMainWindow();
},
false,
);
return (
<div
className="flex h-screen w-screen flex-col rounded-xl border border-slate-300 bg-white/95
p-1.5 shadow-2xl backdrop-blur-xl dark:border-slate-700 dark:bg-slate-900/95"
>
<div className="px-2 pb-1 pt-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
FlightTube
</div>
<Row
label="Download this video"
hint="From the browser it is playing in"
busy={busy === "video"}
onClick={() => run("video", traySaveVideo)}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v10.5M7.5 10l4.5 4.5 4.5-4.5M4 18.5v1A2.5 2.5 0 006.5 22h11a2.5 2.5 0 002.5-2.5v-1" />
</svg>
}
/>
<Row
label="Import subscriptions"
hint="Read them off your YouTube page"
busy={busy === "subs"}
onClick={importSubscriptions}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h9M4 12h9M4 17h5" />
<path d="M17 9v8m0 0l-2.5-2.5M17 17l2.5-2.5" />
</svg>
}
/>
<Row
label="Remove all downloads"
hint="Asks in the window first"
busy={busy === "wipe"}
onClick={() =>
run(
"wipe",
async () => {
await emitTo("main", "downloads:wipe-request", null);
await showMainWindow();
},
false,
)
}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
}
/>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row
label="Open FlightTube"
onClick={() => run("open", async () => showMainWindow())}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M9 5v14" />
</svg>
}
/>
<Row
label="Quit"
onClick={() => void quitApp()}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 5h3a2 2 0 012 2v10a2 2 0 01-2 2h-3M10 12H3m0 0l3.5-3.5M3 12l3.5 3.5" />
</svg>
}
/>
{note && (
<p
className="mt-1 max-h-16 overflow-y-auto rounded-lg bg-red-500/10 px-2 py-1.5 text-[11px]
leading-snug text-red-700 dark:text-red-300"
>
{note}
</p>
)}
</div>
);
}