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,
/// and say so in the error, which is passed straight back rather than being
/// 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> {
let safari = browser == "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 settled = 0;
for round in 0..40 {
let n = run_js_logged(app, &browser, subscriptions::SCROLL_JS)
let n = run_js(&browser, subscriptions::SCROLL_JS)
.await?
.parse::<usize>()
.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;
}
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);
log(app, &format!("scraped {} channels from {browser}", rows.len()));
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.
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.
///
+13 -1
View File
@@ -359,6 +359,15 @@ export default function App() {
});
}, [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
// not a thing to agree to in a panel that closes when you look away.
useEffect(() => {
@@ -742,9 +751,12 @@ export default function App() {
</main>
</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 && (
<Player
key={playing.item.id}
item={playing.item}
path={playing.path}
index={playingIndex}
+212 -60
View File
@@ -1,9 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
embeddedSubtitles,
fetchSubtitles,
fileUrl,
listSubtitles,
openExternal,
resolveStream,
savePlayback,
} 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 PlayerControls from "./PlayerControls";
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
* session (and its audio) running after the element leaves the DOM, so closing
* the player or stepping to the next video would leave the previous one playing
* with no way to stop it. Every exit path goes through here.
* Detaching the element is not enough: WebKit keeps the session (and its
* audio) running after the element leaves the DOM, so stepping to the next
* video would leave the previous one playing with no way to stop it.
*/
function teardown(v: HTMLVideoElement | null) {
function releasePiP(v: HTMLVideoElement | null) {
if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation.
const webkit = v as WebkitVideo;
try {
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
if (
webkit.webkitPresentationMode &&
webkit.webkitPresentationMode !== "inline"
) {
webkit.webkitSetPresentationMode?.("inline");
}
} catch {
@@ -68,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
} catch {
/* 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 {
if (document.fullscreenElement) void document.exitFullscreen();
} catch {
@@ -157,9 +182,24 @@ function placeCues(vtt: string, line: number | null): string {
}
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
index, total, titleBarInset,
item,
path,
onClose,
onDelete,
onPrev,
onNext,
onDownload,
downloading,
maxHeight,
subLang,
onSubLang,
subStyle,
onSubStyle,
onOpenChannel,
autoplayNext,
index,
total,
titleBarInset,
}: Props) {
const streaming = 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
// written into the cues themselves. Re-cut whenever the choice changes.
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[] = [];
setTracks(
rawTracks.map(([lang, text]) => {
@@ -259,6 +300,24 @@ export default function Player({
};
}, [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.
const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined);
@@ -279,8 +338,11 @@ export default function Player({
return;
}
let cancelled = false;
setSrc(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)
.then((u) => !cancelled && setSrc(u))
.catch((e) => !cancelled && setError(String(e)));
@@ -289,7 +351,6 @@ export default function Player({
};
}, [item.id, path, maxHeight]);
const persist = useCallback(() => {
const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
@@ -305,9 +366,15 @@ export default function Player({
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
return () => releasePiP(v);
}, [src]);
// Leaving the player is the only place the element is released outright.
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, []);
const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not
// 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 at = item.position ?? 0;
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]);
@@ -340,8 +408,16 @@ export default function Player({
if (e.key === "Escape" && !document.fullscreenElement) leave();
// Moving between videos. Shift with the arrows because the bare ones
// 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 (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.();
if (
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);
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
dark:border-slate-800 dark:bg-slate-900"
>
<button onClick={leave} title="Back to the feed (Esc)" 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" />
<button
onClick={leave}
title="Back to the feed (Esc)"
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>
</button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
<button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
className={navBtn}
>
Prev
</button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
<button
onClick={onNext}
disabled={!onNext}
title="Next video"
className={navBtn}
>
Next
</button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
@@ -421,7 +521,10 @@ export default function Player({
error ? (
<Badge tone="danger">Unavailable</Badge>
) : 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` : ""}
</Badge>
) : (
@@ -447,30 +550,39 @@ export default function Player({
ref={stageRef}
onContextMenu={(e) => e.preventDefault()}
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"}`}
>
{/* 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
of the video while you are watching it. */}
<button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
aria-label="Previous video"
className={`${edgeBtn} left-3`}
>
</button>
<button
onClick={onNext}
disabled={!onNext}
title="Next video"
aria-label="Next video"
className={`${edgeBtn} right-3`}
>
</button>
of the video while you are watching it.
Gone entirely in fullscreen. Nothing to navigate with there: it
plays one video after another and shows only the picture. */}
{!fullscreen && (
<>
<button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
aria-label="Previous video"
className={`${edgeBtn} left-3`}
>
</button>
<button
onClick={onNext}
disabled={!onNext}
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
you are concerned, so they get the same spinner in the same place. */}
@@ -483,7 +595,6 @@ export default function Player({
{src ? (
<video
ref={videoRef}
key={src}
src={src}
autoPlay
onContextMenu={(e) => e.preventDefault()}
@@ -513,7 +624,13 @@ export default function Player({
className="absolute inset-0 size-full object-contain"
>
{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>
) : (
@@ -522,7 +639,9 @@ export default function Player({
<div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p>
<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`}
>
Open on YouTube instead
@@ -592,8 +711,18 @@ export default function Player({
{downloading ? (
<Spinner className="size-4" />
) : (
<svg 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
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>
)}
</button>
@@ -606,30 +735,53 @@ export default function Player({
className={`${navIcon} hover:border-red-500! hover:text-red-600!
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">
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
/>
</svg>
</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"
aria-label="Open on YouTube"
className={navIcon}
>
<svg viewBox="0 0 24 24" 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
viewBox="0 0 24 24"
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>
</button>
</div>
</div>
</footer>
{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">
<Linked text={item.description} />
</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" />
<Row