fix: native video fullscreen, window dragging, quality picker, spinner

Enable WKWebView's elementFullscreenEnabled at startup. It is off by
default in a Tauri window, which is why the native player had no
full-screen button and why right-click 'Enter Full Screen' did nothing.
With it on, WebKit's own control appears on the video and gives the real
system fullscreen player, so the app-level workaround is gone.

Restore window dragging: data-tauri-drag-region needs
core:window:allow-start-dragging, which was missing, leaving the
title-bar strip inert.

Download quality is now a resolution picker (best/2160/1440/1080/720/
480) instead of a two-way toggle; every selector still pins AAC audio
because Opus in MP4 is silent in WebKit.

Tile titles truncate to a single line so rows stay uniform, and the
player shows a spinner while the video buffers, not just while the
stream URL resolves.
This commit is contained in:
vincent
2026-08-29 10:38:35 +02:00
parent 359873cff1
commit 61db10b2c8
11 changed files with 143 additions and 106 deletions
+17 -61
View File
@@ -1,4 +1,3 @@
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";
@@ -94,6 +93,7 @@ export default function Player({
const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null);
const [buffering, setBuffering] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0);
@@ -147,52 +147,19 @@ export default function Player({
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
};
// 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]);
const leave = useCallback(() => onClose(), [onClose]);
// 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();
if (e.key === "Escape") leave();
// 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]);
}, [leave, onPrev, onNext]);
const edgeBtn =
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
@@ -208,9 +175,8 @@ export default function Player({
return (
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
{!isFullscreen && <div data-tauri-drag-region className="h-9 shrink-0" />}
<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"
>
@@ -232,18 +198,9 @@ export default function Player({
{item.channel_title}
</span>
<button
onClick={toggleFullscreen}
disabled={!src}
title={isFullscreen ? "Leave full screen (f)" : "Full screen (f)"}
className={navBtn}
>
{isFullscreen ? "⤡ Exit full screen" : "⤢ Full screen"}
</button>
{streaming ? (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{src ? "Streaming" : error ? "Unavailable" : "Loading"}
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading"}
</span>
) : (
<button
@@ -280,17 +237,12 @@ export default function Player({
</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>
{/* Two different waits look the same to you: resolving the stream, and
the player buffering it. Both get the spinner. */}
{src && buffering && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Spinner className="size-8 text-white/80" />
</div>
)}
{src ? (
@@ -303,6 +255,11 @@ export default function Player({
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onPause={persist}
onLoadStart={() => setBuffering(true)}
onWaiting={() => setBuffering(true)}
onStalled={() => setBuffering(true)}
onCanPlay={() => setBuffering(false)}
onPlaying={() => setBuffering(false)}
className="absolute inset-0 size-full object-contain"
/>
) : (
@@ -328,7 +285,6 @@ 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"
>