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.
This commit is contained in:
Vincent Rozenberg
2026-09-04 01:56:08 +02:00
parent 41d638b232
commit 35f14ab044
4 changed files with 251 additions and 73 deletions
+3 -12
View File
@@ -129,15 +129,6 @@ async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
/// Arc allows this out of the box. Chrome and its relatives ship with it off, /// Arc allows this out of the box. Chrome and its relatives ship with it off,
/// and say so in the error, which is passed straight back rather than being /// and say so in the error, which is passed straight back rather than being
/// flattened into "something went wrong". /// flattened into "something went wrong".
pub async fn run_js_logged(app: &AppHandle, browser: &str, js: &str) -> Result<String, String> {
let r = run_js(browser, js).await;
match &r {
Ok(out) => log(app, &format!("js ok, {} chars back", out.len())),
Err(e) => log(app, &format!("js failed: {e}")),
}
r
}
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> { pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
let safari = browser == "Safari"; let safari = browser == "Safari";
let script = if safari { let script = if safari {
@@ -211,7 +202,7 @@ pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scra
let mut last = 0usize; let mut last = 0usize;
let mut settled = 0; let mut settled = 0;
for round in 0..40 { for round in 0..40 {
let n = run_js_logged(app, &browser, subscriptions::SCROLL_JS) let n = run_js(&browser, subscriptions::SCROLL_JS)
.await? .await?
.parse::<usize>() .parse::<usize>()
.unwrap_or(0); .unwrap_or(0);
@@ -232,7 +223,7 @@ pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scra
tokio::time::sleep(std::time::Duration::from_millis(900)).await; tokio::time::sleep(std::time::Duration::from_millis(900)).await;
} }
let raw = run_js_logged(app, &browser, subscriptions::EXTRACT_JS).await?; let raw = run_js(&browser, subscriptions::EXTRACT_JS).await?;
let rows = subscriptions::parse_rows(&raw); let rows = subscriptions::parse_rows(&raw);
log(app, &format!("scraped {} channels from {browser}", rows.len())); log(app, &format!("scraped {} channels from {browser}", rows.len()));
if rows.is_empty() { if rows.is_empty() {
@@ -366,7 +357,7 @@ pub fn quit_app(app: AppHandle) {
/// The panel's own size. Fixed, because it is a menu: it does not resize. /// The panel's own size. Fixed, because it is a menu: it does not resize.
const PANEL_W: f64 = 304.0; const PANEL_W: f64 = 304.0;
const PANEL_H: f64 = 252.0; const PANEL_H: f64 = 296.0;
/// Opens the panel under the menu bar icon. /// Opens the panel under the menu bar icon.
/// ///
+13 -1
View File
@@ -359,6 +359,15 @@ export default function App() {
}); });
}, [quality, embedLang]); }, [quality, embedLang]);
// The menu bar can ask to clear the downloads; the answering is done here,
// by the same confirmation the toolbar uses.
useEffect(() => {
const un = listen("downloads:wipe-request", () => setConfirmWipe(true));
return () => {
void un.then((f) => f());
};
}, []);
// The menu bar reads the subscription list, but replacing what is here is // The menu bar reads the subscription list, but replacing what is here is
// not a thing to agree to in a panel that closes when you look away. // not a thing to agree to in a panel that closes when you look away.
useEffect(() => { useEffect(() => {
@@ -742,9 +751,12 @@ export default function App() {
</main> </main>
</div> </div>
{/* The player is deliberately not keyed on the video. Remounting would
build a new stage element, and the stage is what is fullscreen — so
every Next dropped out of fullscreen. It resets its own per-video
state instead. */}
{playing && playingIndex != null && ( {playing && playingIndex != null && (
<Player <Player
key={playing.item.id}
item={playing.item} item={playing.item}
path={playing.path} path={playing.path}
index={playingIndex} index={playingIndex}
+212 -60
View File
@@ -1,9 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, embeddedSubtitles,
fetchSubtitles,
fileUrl,
listSubtitles,
openExternal,
resolveStream,
savePlayback, savePlayback,
} from "../api"; } from "../api";
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types"; import {
DEFAULT_SUB_LANG,
SUB_FONTS,
SUB_PLACES,
type FeedItem,
type SubStyle,
} from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format"; import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls"; import PlayerControls from "./PlayerControls";
import { Badge, BTN, Dialog, Spinner } from "./ui"; import { Badge, BTN, Dialog, Spinner } from "./ui";
@@ -39,20 +50,22 @@ interface Props {
} }
/** /**
* Releases a <video> completely. * Ends any Picture-in-Picture session on a <video>.
* *
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture * Detaching the element is not enough: WebKit keeps the session (and its
* session (and its audio) running after the element leaves the DOM, so closing * audio) running after the element leaves the DOM, so stepping to the next
* the player or stepping to the next video would leave the previous one playing * video would leave the previous one playing with no way to stop it.
* with no way to stop it. Every exit path goes through here.
*/ */
function teardown(v: HTMLVideoElement | null) { function releasePiP(v: HTMLVideoElement | null) {
if (!v) return; if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is // Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation. // tried too, since either may be the live implementation.
const webkit = v as WebkitVideo; const webkit = v as WebkitVideo;
try { try {
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") { if (
webkit.webkitPresentationMode &&
webkit.webkitPresentationMode !== "inline"
) {
webkit.webkitSetPresentationMode?.("inline"); webkit.webkitSetPresentationMode?.("inline");
} }
} catch { } catch {
@@ -68,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
} catch { } catch {
/* not supported here */ /* not supported here */
} }
}
/**
* Releases the element for good, on the way out of the player.
*
* Separate from the above because it also leaves fullscreen, and this used to
* run on every change of video: stepping to the next one dropped you out of
* fullscreen every time, which is the opposite of watching one after another.
*/
function teardown(v: HTMLVideoElement | null) {
if (!v) return;
releasePiP(v);
try { try {
if (document.fullscreenElement) void document.exitFullscreen(); if (document.fullscreenElement) void document.exitFullscreen();
} catch { } catch {
@@ -157,9 +182,24 @@ function placeCues(vtt: string, line: number | null): string {
} }
export default function Player({ export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, item,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext, path,
index, total, titleBarInset, onClose,
onDelete,
onPrev,
onNext,
onDownload,
downloading,
maxHeight,
subLang,
onSubLang,
subStyle,
onSubStyle,
onOpenChannel,
autoplayNext,
index,
total,
titleBarInset,
}: Props) { }: Props) {
const streaming = path === null; const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null); const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
@@ -243,7 +283,8 @@ export default function Player({
// Placement is a WebVTT cue setting, not something CSS can reach, so it is // Placement is a WebVTT cue setting, not something CSS can reach, so it is
// written into the cues themselves. Re-cut whenever the choice changes. // written into the cues themselves. Re-cut whenever the choice changes.
useEffect(() => { useEffect(() => {
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null; const line =
SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
const urls: string[] = []; const urls: string[] = [];
setTracks( setTracks(
rawTracks.map(([lang, text]) => { rawTracks.map(([lang, text]) => {
@@ -259,6 +300,24 @@ export default function Player({
}; };
}, [rawTracks, subStyle.place]); }, [rawTracks, subStyle.place]);
// What a remount used to clear. Kept in one place so a new video starts as
// clean as it would have, without throwing the stage away to get there.
useEffect(() => {
setError(null);
setBuffering(true);
setShowDescription(false);
}, [item.id]);
// Fullscreen belongs to the stage, which now outlives the video. Whether it
// is on decides whether anything to click is shown at all.
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const sync = () => setFullscreen(!!document.fullscreenElement);
sync();
document.addEventListener("fullscreenchange", sync);
return () => document.removeEventListener("fullscreenchange", sync);
}, []);
// Controls and edge arrows fade away while you are just watching. // Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true); const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined); const hideTimer = useRef<number | undefined>(undefined);
@@ -279,8 +338,11 @@ export default function Player({
return; return;
} }
let cancelled = false; let cancelled = false;
setSrc(null);
setError(null); setError(null);
// Quiet the outgoing video, but leave it in place: clearing the source
// would unmount the element mid-fullscreen.
videoRef.current?.pause();
setBuffering(true);
resolveStream(item.id, maxHeight) resolveStream(item.id, maxHeight)
.then((u) => !cancelled && setSrc(u)) .then((u) => !cancelled && setSrc(u))
.catch((e) => !cancelled && setError(String(e))); .catch((e) => !cancelled && setError(String(e)));
@@ -289,7 +351,6 @@ export default function Player({
}; };
}, [item.id, path, maxHeight]); }, [item.id, path, maxHeight]);
const persist = useCallback(() => { const persist = useCallback(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
@@ -305,9 +366,15 @@ export default function Player({
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
return () => teardown(v); return () => releasePiP(v);
}, [src]); }, [src]);
// Leaving the player is the only place the element is released outright.
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, []);
const onTimeUpdate = () => { const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not // Frames are flowing, so whatever the media events claimed, we are not
// buffering. A resume-seek can fire `waiting` after `playing` and leave the // buffering. A resume-seek can fire `waiting` after `playing` and leave the
@@ -326,7 +393,8 @@ export default function Player({
const v = videoRef.current; const v = videoRef.current;
const at = item.position ?? 0; const at = item.position ?? 0;
if (!v || !Number.isFinite(v.duration)) return; if (!v || !Number.isFinite(v.duration)) return;
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at; if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S)
v.currentTime = at;
}; };
const leave = useCallback(() => onClose(), [onClose]); const leave = useCallback(() => onClose(), [onClose]);
@@ -340,8 +408,16 @@ export default function Player({
if (e.key === "Escape" && !document.fullscreenElement) leave(); if (e.key === "Escape" && !document.fullscreenElement) leave();
// Moving between videos. Shift with the arrows because the bare ones // Moving between videos. Shift with the arrows because the bare ones
// seek, and shift with N and P because that is what YouTube uses. // seek, and shift with N and P because that is what YouTube uses.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.(); if (
if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.(); e.shiftKey &&
(e.key === "ArrowLeft" || e.key === "P" || e.key === "p")
)
onPrev?.();
if (
e.shiftKey &&
(e.key === "ArrowRight" || e.key === "N" || e.key === "n")
)
onNext?.();
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -386,17 +462,41 @@ export default function Player({
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back" <button
className={navIcon}> onClick={leave}
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> title="Back to the feed (Esc)"
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" /> aria-label="Back"
className={navIcon}
>
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 6l-6 6 6 6"
/>
</svg> </svg>
</button> </button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}> <button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
className={navBtn}
>
Prev Prev
</button> </button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}> <button
onClick={onNext}
disabled={!onNext}
title="Next video"
className={navBtn}
>
Next Next
</button> </button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500"> <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
@@ -421,7 +521,10 @@ export default function Player({
error ? ( error ? (
<Badge tone="danger">Unavailable</Badge> <Badge tone="danger">Unavailable</Badge>
) : src && !buffering ? ( ) : src && !buffering ? (
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}> <Badge
tone="accent"
title={`Streaming at ${height || "an unknown"}p`}
>
Streaming{height ? ` · ${height}p` : ""} Streaming{height ? ` · ${height}p` : ""}
</Badge> </Badge>
) : ( ) : (
@@ -447,30 +550,39 @@ export default function Player({
ref={stageRef} ref={stageRef}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onMouseMove={showChrome} onMouseMove={showChrome}
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)} onMouseLeave={() =>
!videoRef.current?.paused && setChromeVisible(false)
}
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`} className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
> >
{/* Edge arrows, the way a player wants them: big targets on the left and {/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top right of the picture. They fade in on hover so they never sit on top
of the video while you are watching it. */} of the video while you are watching it.
<button
onClick={onPrev} Gone entirely in fullscreen. Nothing to navigate with there: it
disabled={!onPrev} plays one video after another and shows only the picture. */}
title="Previous video" {!fullscreen && (
aria-label="Previous video" <>
className={`${edgeBtn} left-3`} <button
> onClick={onPrev}
disabled={!onPrev}
</button> title="Previous video"
<button aria-label="Previous video"
onClick={onNext} className={`${edgeBtn} left-3`}
disabled={!onNext} >
title="Next video"
aria-label="Next video" </button>
className={`${edgeBtn} right-3`} <button
> onClick={onNext}
disabled={!onNext}
</button> title="Next video"
aria-label="Next video"
className={`${edgeBtn} right-3`}
>
</button>
</>
)}
{/* Resolving the stream and buffering it are the same wait as far as {/* Resolving the stream and buffering it are the same wait as far as
you are concerned, so they get the same spinner in the same place. */} you are concerned, so they get the same spinner in the same place. */}
@@ -483,7 +595,6 @@ export default function Player({
{src ? ( {src ? (
<video <video
ref={videoRef} ref={videoRef}
key={src}
src={src} src={src}
autoPlay autoPlay
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -513,7 +624,13 @@ export default function Player({
className="absolute inset-0 size-full object-contain" className="absolute inset-0 size-full object-contain"
> >
{tracks.map(([lang, url]) => ( {tracks.map(([lang, url]) => (
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} /> <track
key={url}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={url}
/>
))} ))}
</video> </video>
) : ( ) : (
@@ -522,7 +639,9 @@ export default function Player({
<div className="max-w-sm"> <div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p> <p className="text-[13px] text-red-400">{error}</p>
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
className={`${BTN} mt-3 cursor-pointer py-1.5`} className={`${BTN} mt-3 cursor-pointer py-1.5`}
> >
Open on YouTube instead Open on YouTube instead
@@ -592,8 +711,18 @@ export default function Player({
{downloading ? ( {downloading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" /> viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
/>
</svg> </svg>
)} )}
</button> </button>
@@ -606,30 +735,53 @@ export default function Player({
className={`${navIcon} hover:border-red-500! hover:text-red-600! className={`${navIcon} hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`} dark:hover:border-red-500! dark:hover:text-red-400!`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
/>
</svg> </svg>
</button> </button>
)} )}
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
title="Open on YouTube" title="Open on YouTube"
aria-label="Open on YouTube" aria-label="Open on YouTube"
className={navIcon} className={navIcon}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"
/>
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
</footer> </footer>
{showDescription && ( {showDescription && (
<Dialog title={item.title} onCancel={() => setShowDescription(false)} wide> <Dialog
title={item.title}
onCancel={() => setShowDescription(false)}
wide
>
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed"> <p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
<Linked text={item.description} /> <Linked text={item.description} />
</p> </p>
+23
View File
@@ -122,6 +122,29 @@ export default function TrayPanel() {
} }
/> />
<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" /> <div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row <Row