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:
+17
-61
@@ -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"
|
||||
>
|
||||
|
||||
+18
-14
@@ -3,7 +3,7 @@ import {
|
||||
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
||||
} from "../api";
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import type { ImportPreview, Prereqs, Quality } from "../types";
|
||||
import { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
import {
|
||||
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||
@@ -126,22 +126,26 @@ export default function Settings({
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Download quality</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
<b>Best</b> takes the highest resolution available, up to 4K — above 1080p
|
||||
that means VP9 or AV1, which your Mac decodes but older ones may not, and
|
||||
the files are several times larger. <b>Compatible</b> caps at 1080p H.264,
|
||||
which plays anywhere. Audio is AAC either way.
|
||||
Above 1080p YouTube only serves VP9 and AV1. Those play here, but the
|
||||
files are several times larger and older Macs may struggle. Pick 1080p
|
||||
for H.264, which plays anywhere. Audio is AAC at every setting.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||
<span className={LABEL}>Quality</span>
|
||||
<Segmented
|
||||
<select
|
||||
value={quality}
|
||||
onChange={onQuality}
|
||||
options={[
|
||||
{ value: "best", label: "Best (4K)" },
|
||||
{ value: "compatible", label: "Compatible" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
onChange={(e) => onQuality(e.target.value as Quality)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5
|
||||
text-[13px] outline-none cursor-pointer
|
||||
dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
{QUALITIES.map((q) => (
|
||||
<option key={q.value} value={q.value}>
|
||||
{q.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function VideoTile({
|
||||
<div className="mt-2 flex min-w-0 flex-col">
|
||||
<button onClick={onOpen} className="cursor-pointer text-left">
|
||||
<h3
|
||||
className="line-clamp-2 h-[2.25rem] text-[13px] font-medium leading-snug"
|
||||
className="h-[1.25rem] truncate text-[13px] font-medium leading-5"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
|
||||
Reference in New Issue
Block a user