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:
+3
-12
@@ -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
@@ -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}
|
||||
|
||||
+194
-42
@@ -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,12 +550,19 @@ 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. */}
|
||||
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}
|
||||
@@ -471,6 +581,8 @@ export default function Player({
|
||||
>
|
||||
›
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user