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.
181 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|