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
+24
View File
@@ -1141,6 +1141,7 @@ dependencies = [
"chrono",
"csv",
"futures",
"objc2-web-kit",
"quick-xml 0.42.0",
"reqwest",
"rusqlite",
@@ -2597,6 +2598,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2609,6 +2620,17 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2652,6 +2674,8 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
+3
View File
@@ -31,3 +31,6 @@ futures = "0.3.34"
tauri-plugin-dialog = "2.7.2"
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences"] }
+1 -2
View File
@@ -9,7 +9,6 @@
"core:default",
"opener:default",
"dialog:default",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
"core:window:allow-start-dragging"
]
}
+1 -1
View File
@@ -1 +1 @@
{"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"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging"]}}
+36 -22
View File
@@ -1,20 +1,23 @@
//! Driving `yt-dlp` and interpreting its progress output.
/// Highest resolution available, which on YouTube means VP9 or AV1 above 1080p.
/// Audio is still pinned to AAC (`m4a`): YouTube pairs those codecs with Opus,
/// which WebKit will not decode inside an MP4 container, so taking Opus would
/// yield a silent file.
/// Audio is pinned to AAC (`m4a`) in every selector: YouTube pairs those codecs
/// with Opus, which WebKit will not decode inside an MP4 container, so taking
/// Opus would yield a silent file.
pub const FORMAT_BEST: &str = "bv*+ba[ext=m4a]/bv*+ba/b";
/// H.264 video plus AAC audio. Caps at 1080p — YouTube serves H.264 no higher —
/// but is guaranteed to decode in WKWebView on any Mac.
pub const FORMAT_COMPATIBLE: &str =
"bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/bv*+ba/b";
pub fn format_selector(quality: &str) -> &'static str {
match quality {
"compatible" => FORMAT_COMPATIBLE,
_ => FORMAT_BEST,
/// Builds a format selector for a chosen quality.
///
/// `quality` is either "best" or a maximum height in pixels ("2160", "1080", …).
/// Anything unrecognised falls back to best, so a stale stored preference can
/// never leave downloads broken.
pub fn format_selector(quality: &str) -> String {
match quality.parse::<u32>() {
Ok(height) if (144..=4320).contains(&height) => format!(
"bv*[height<={h}]+ba[ext=m4a]/bv*[height<={h}]+ba/b[height<={h}]/b",
h = height
),
_ => FORMAT_BEST.to_string(),
}
}
@@ -79,7 +82,7 @@ pub fn parse_progress_line(line: &str) -> Option<Progress> {
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
vec![
"-f".into(),
format_selector(quality).into(),
format_selector(quality),
"--merge-output-format".into(),
"mp4".into(),
"--no-playlist".into(),
@@ -173,26 +176,37 @@ mod tests {
}
#[test]
fn compatible_quality_pins_h264_and_aac() {
let args = build_args("abc123", "/tmp/out.%(ext)s", "compatible");
assert!(args.contains(&FORMAT_COMPATIBLE.to_string()));
fn best_quality_takes_the_highest_available() {
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
assert!(args.contains(&FORMAT_BEST.to_string()));
assert!(args.contains(&"mp4".to_string()));
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
assert!(args.contains(&"--no-playlist".to_string()));
}
#[test]
fn best_quality_still_pins_aac_audio() {
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
assert!(args.contains(&FORMAT_BEST.to_string()));
fn every_selector_pins_aac_audio() {
// Opus in MP4 would be silent in WebKit, so the audio half stays m4a.
assert!(FORMAT_BEST.contains("ba[ext=m4a]"));
for q in ["best", "2160", "1080", "480"] {
assert!(format_selector(q).contains("ba[ext=m4a]"), "quality {q}");
}
}
#[test]
fn unknown_quality_falls_back_to_best() {
fn a_numeric_quality_caps_the_height() {
let sel = format_selector("1080");
assert!(sel.contains("height<=1080"));
assert!(!sel.contains("height<=2160"));
assert!(build_args("x", "o", "1080").contains(&sel));
}
#[test]
fn nonsense_or_out_of_range_quality_falls_back_to_best() {
assert_eq!(format_selector("nonsense"), FORMAT_BEST);
assert_eq!(format_selector("compatible"), FORMAT_COMPATIBLE);
assert_eq!(format_selector(""), FORMAT_BEST);
assert_eq!(format_selector("0"), FORMAT_BEST);
assert_eq!(format_selector("99999"), FORMAT_BEST);
assert_eq!(format_selector("best"), FORMAT_BEST);
}
#[test]
+28
View File
@@ -9,6 +9,28 @@ pub mod thumbs;
use tauri::Manager;
/// Turns on WKWebView's element fullscreen.
///
/// It is off by default in a Tauri window, which is why the native player has
/// no full-screen button and why `requestFullscreen()` — and the right-click
/// "Enter Full Screen" item — silently do nothing. Flipping this one preference
/// puts the button back in the video's own transport bar, where it belongs.
#[cfg(target_os = "macos")]
fn enable_element_fullscreen(window: &tauri::WebviewWindow) {
use objc2_web_kit::WKWebView;
let _ = window.with_webview(|platform| unsafe {
let ptr = platform.inner() as *const WKWebView;
if ptr.is_null() {
return;
}
let webview: &WKWebView = &*ptr;
webview
.configuration()
.preferences()
.setElementFullscreenEnabled(true);
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -17,6 +39,12 @@ pub fn run() {
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
app.manage(state);
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window("main") {
enable_element_fullscreen(&window);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
+3 -4
View File
@@ -13,7 +13,7 @@ import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed";
import type { FeedFilter, FeedItem, Quality, RefreshProgress } from "./types";
import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types";
const TOAST_MS = 2400;
@@ -34,9 +34,8 @@ export default function App() {
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const [quality, setQuality] = useState<Quality>(() => {
try {
return localStorage.getItem("flighttube.quality") === "compatible"
? "compatible"
: "best";
const stored = localStorage.getItem("flighttube.quality");
return QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
} catch {
return "best";
}
+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"
>
+18 -14
View File
@@ -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">
+1 -1
View File
@@ -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}
+11 -1
View File
@@ -86,4 +86,14 @@ export interface ImportPreview {
removed_downloads: number;
}
export type Quality = "best" | "compatible";
/** "best" or a maximum height in pixels. */
export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480";
export const QUALITIES: Array<{ value: Quality; label: string }> = [
{ value: "best", label: "Best available (up to 4K)" },
{ value: "2160", label: "2160p — 4K" },
{ value: "1440", label: "1440p — 2K" },
{ value: "1080", label: "1080p" },
{ value: "720", label: "720p" },
{ value: "480", label: "480p" },
];