fix: real video fullscreen, and edge navigation arrows
The full-screen button did nothing: WKWebView inside a Tauri window has element fullscreen disabled outright, so both requestFullscreen() and WebKit's webkitEnterFullscreen() are inert, and Tauri exposes no switch for it. Fullscreening the window alone is not the same thing either — the player's header and footer stay on screen around the picture. The button now fullscreens the window and hides every piece of chrome, so the video alone fills the display, with Esc and a hover control to exit. Prev/next also get large arrows on the left and right edges of the picture, fading in on hover, alongside the header buttons.
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"]}}
|
||||
+115
-15
@@ -1,3 +1,4 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
@@ -29,17 +30,19 @@ function teardown(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 {
|
||||
const webkit = v as HTMLVideoElement & {
|
||||
webkitPresentationMode?: string;
|
||||
webkitSetPresentationMode?: (mode: string) => void;
|
||||
};
|
||||
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
|
||||
webkit.webkitSetPresentationMode?.("inline");
|
||||
}
|
||||
} catch {
|
||||
/* not supported here */
|
||||
}
|
||||
try {
|
||||
if (webkit.webkitDisplayingFullscreen) webkit.webkitExitFullscreen?.();
|
||||
} catch {
|
||||
/* not supported here */
|
||||
}
|
||||
try {
|
||||
if (document.pictureInPictureElement) void document.exitPictureInPicture();
|
||||
} catch {
|
||||
@@ -60,6 +63,15 @@ function teardown(v: HTMLVideoElement | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The WebKit-only members we rely on for PiP and native video fullscreen. */
|
||||
type WebkitVideo = HTMLVideoElement & {
|
||||
webkitPresentationMode?: string;
|
||||
webkitSetPresentationMode?: (mode: string) => void;
|
||||
webkitEnterFullscreen?: () => void;
|
||||
webkitExitFullscreen?: () => void;
|
||||
webkitDisplayingFullscreen?: boolean;
|
||||
};
|
||||
|
||||
/** Save at most this often while playing; also saved on close. */
|
||||
const SAVE_EVERY_MS = 5000;
|
||||
/** Ignore a saved position this close to either end — nothing useful to resume. */
|
||||
@@ -135,12 +147,58 @@ export default function Player({
|
||||
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
|
||||
};
|
||||
|
||||
const goFullscreen = () => {
|
||||
// The stage rather than the <video>, so our letterboxing travels with it.
|
||||
stageRef.current?.requestFullscreen?.().catch(() => {
|
||||
videoRef.current?.requestFullscreen?.().catch(() => {});
|
||||
});
|
||||
};
|
||||
// Real video fullscreen, the only way available here.
|
||||
//
|
||||
// WKWebView inside a Tauri window has element fullscreen disabled outright,
|
||||
// so `video.requestFullscreen()` and WebKit's `webkitEnterFullscreen()` are
|
||||
// both inert, and Tauri exposes no switch to turn it on. Fullscreening the
|
||||
// window alone is not the same thing — the player's own header and footer
|
||||
// stay on screen around the video. So: fullscreen the window AND hide every
|
||||
// piece of chrome, leaving the picture alone on the display. Same result,
|
||||
// and it cannot silently fail.
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const setFullscreen = useCallback(async (on: boolean) => {
|
||||
try {
|
||||
await getCurrentWindow().setFullscreen(on);
|
||||
} catch {
|
||||
/* still worth hiding the chrome */
|
||||
}
|
||||
setIsFullscreen(on);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(
|
||||
() => void setFullscreen(!isFullscreen),
|
||||
[isFullscreen, setFullscreen],
|
||||
);
|
||||
|
||||
// Leaving the player must not strand the window in fullscreen.
|
||||
const leave = useCallback(async () => {
|
||||
if (isFullscreen) await setFullscreen(false);
|
||||
onClose();
|
||||
}, [isFullscreen, onClose, setFullscreen]);
|
||||
|
||||
// Escape backs out, as it does everywhere else in the app.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (isFullscreen) void setFullscreen(false);
|
||||
else void leave();
|
||||
}
|
||||
if (e.key === "f" && !e.metaKey && !e.ctrlKey) void toggleFullscreen();
|
||||
// Arrow keys only when the video does not own them for seeking.
|
||||
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
|
||||
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [leave, toggleFullscreen, onPrev, onNext, isFullscreen, setFullscreen]);
|
||||
|
||||
const edgeBtn =
|
||||
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
||||
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
||||
"opacity-0 transition-opacity group-hover/stage:opacity-100 focus-visible:opacity-100 " +
|
||||
"hover:bg-slate-950/80 disabled:hidden";
|
||||
|
||||
const navBtn =
|
||||
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
||||
@@ -150,12 +208,13 @@ export default function Player({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
{!isFullscreen && <div data-tauri-drag-region className="h-9 shrink-0" />}
|
||||
<header
|
||||
hidden={isFullscreen}
|
||||
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={onClose} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
<button onClick={leave} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
@@ -173,8 +232,13 @@ export default function Player({
|
||||
{item.channel_title}
|
||||
</span>
|
||||
|
||||
<button onClick={goFullscreen} disabled={!src} title="Full screen" className={navBtn}>
|
||||
⤢ Full screen
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
disabled={!src}
|
||||
title={isFullscreen ? "Leave full screen (f)" : "Full screen (f)"}
|
||||
className={navBtn}
|
||||
>
|
||||
{isFullscreen ? "⤡ Exit full screen" : "⤢ Full screen"}
|
||||
</button>
|
||||
|
||||
{streaming ? (
|
||||
@@ -193,7 +257,42 @@ export default function Player({
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||
<div ref={stageRef} className="relative min-h-0 flex-1 bg-slate-950">
|
||||
<div ref={stageRef} className="group/stage relative min-h-0 flex-1 bg-slate-950">
|
||||
{/* 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>
|
||||
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => void setFullscreen(false)}
|
||||
title="Leave full screen (Esc)"
|
||||
className="absolute right-3 top-3 z-10 rounded-full bg-slate-950/55 px-3 py-1.5
|
||||
text-[11px] font-medium text-white opacity-0 backdrop-blur
|
||||
transition-opacity group-hover/stage:opacity-100 hover:bg-slate-950/80
|
||||
cursor-pointer"
|
||||
>
|
||||
⤡ Exit full screen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{src ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -229,6 +328,7 @@ export default function Player({
|
||||
</div>
|
||||
|
||||
<footer
|
||||
hidden={isFullscreen}
|
||||
className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user