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![