From 61db10b2c87b6da78ea992e76fb58d627764c4a9 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 10:38:35 +0200 Subject: [PATCH] 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. --- src-tauri/Cargo.lock | 24 ++++++++ src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 3 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/downloader.rs | 58 +++++++++++------- src-tauri/src/lib.rs | 28 +++++++++ src/App.tsx | 7 +-- src/components/Player.tsx | 78 ++++++------------------- src/components/Settings.tsx | 32 +++++----- src/components/VideoTile.tsx | 2 +- src/types.ts | 12 +++- 11 files changed, 143 insertions(+), 106 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03a0cc1..075c710 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7c3b61d..2e34398 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 711b73f..6f15304 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 58fc9c9..b07666d 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index c21125b..b54e6e8 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -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::() { + 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 { pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec { 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] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 675e6ee..1cdc1af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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![ diff --git a/src/App.tsx b/src/App.tsx index bf78c28..ae9f762 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [quality, setQuality] = useState(() => { 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"; } diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 2940cfd..818494a 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -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(path ? fileUrl(path) : null); const [error, setError] = useState(null); + const [buffering, setBuffering] = useState(false); const videoRef = useRef(null); const stageRef = useRef(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 (
- {!isFullscreen &&
} +