From 7332618e851f03fc4ea1776846a2e9763cfce643 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Sun, 1 Nov 2026 13:00:58 +0100 Subject: [PATCH] fix(theme): stream video conversion progress and auto-play v1 theme media --- src-tauri/src/commands.rs | 131 ++++++++++++++++++++++++++++++-------- src-tauri/src/macos.rs | 62 +++++++++++++++++- src/app.css | 13 ++++ src/lib/themeMedia.ts | 91 ++++++++++++++++++++++++-- src/lib/themes.ts | 7 +- src/routes/+layout.svelte | 5 +- 6 files changed, 274 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e49512d..9f8c076 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1788,17 +1788,20 @@ pub async fn optimize_theme_media(app: AppHandle, source_url: String) -> Result< } }; - let _ = app.emit( - "theme-media-progress", - ThemeMediaProgress { - stage: "optimize".to_string(), - progress: 0.0, - }, - ); - let original = source_url.clone(); + let app_handle = app.clone(); + let on_progress = move |progress: f64| { + let _ = app_handle.emit( + "theme-media-progress", + ThemeMediaProgress { + stage: "optimize".to_string(), + progress, + }, + ); + }; + tokio::task::spawn_blocking(move || { - optimize_theme_media_blocking(&ffmpeg, &source, &original, &cache_name, &destination) + optimize_theme_media_blocking(&ffmpeg, &source, &original, &cache_name, &destination, on_progress) }) .await .map_err(|error| format!("Theme media optimizer thread failed: {error}"))? @@ -2059,13 +2062,82 @@ fn find_media_binary(name: &str) -> Option { } #[cfg(target_os = "macos")] -fn optimize_theme_media_blocking( +fn run_ffmpeg_with_progress( + mut command: std::process::Command, + duration_us: Option, + on_progress: &F, +) -> Result<(bool, String), String> +where + F: Fn(f64) + Send + Sync, +{ + use std::io::{BufRead, BufReader, Read}; + + command.args(["-progress", "pipe:1"]); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|error| format!("Could not spawn ffmpeg: {error}"))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let stderr_thread = std::thread::spawn(move || { + let mut err_str = String::new(); + if let Some(mut err) = stderr { + let _ = err.read_to_string(&mut err_str); + } + err_str + }); + + if let Some(out) = stdout { + let reader = BufReader::new(out); + let mut last_progress = 0.0; + let mut last_emit = std::time::Instant::now(); + + for line in reader.lines() { + let Ok(line) = line else { break }; + if let Some(val_str) = line.strip_prefix("out_time_us=") { + if let Ok(us) = val_str.trim().parse::() + && let Some(total_us) = duration_us + && total_us > 0 + { + let progress = (us as f64 / total_us as f64).clamp(0.0, 0.99); + if (progress - last_progress) >= 0.01 + || last_emit.elapsed() >= std::time::Duration::from_millis(250) + { + last_progress = progress; + last_emit = std::time::Instant::now(); + on_progress(progress); + } + } + } else if line.starts_with("progress=end") { + on_progress(1.0); + } + } + } + + let status = child + .wait() + .map_err(|error| format!("Failed waiting for ffmpeg: {error}"))?; + let stderr_str = stderr_thread.join().unwrap_or_default(); + + Ok((status.success(), stderr_str)) +} + +#[cfg(target_os = "macos")] +fn optimize_theme_media_blocking( ffmpeg: &Path, source: &Path, source_url: &str, cache_name: &str, destination: &Path, -) -> Result { + on_progress: F, +) -> Result +where + F: Fn(f64) + Send + Sync, +{ use std::process::Command; let probe = Command::new(ffmpeg) @@ -2089,6 +2161,17 @@ fn optimize_theme_media_blocking( return Ok(source_url.to_string()); } + let duration_us = probe_text.lines().find_map(|line| { + let index = line.find("Duration: ")?; + let time_str = line[index + 10..].split(',').next()?.trim(); + let mut parts = time_str.split(':'); + let hours: f64 = parts.next()?.parse().ok()?; + let minutes: f64 = parts.next()?.parse().ok()?; + let seconds: f64 = parts.next()?.parse().ok()?; + let total_secs = hours * 3600.0 + minutes * 60.0 + seconds; + Some((total_secs * 1_000_000.0) as u64) + }); + let temporary = destination.with_extension("mp4.tmp"); let _ = std::fs::remove_file(&temporary); @@ -2098,6 +2181,7 @@ fn optimize_theme_media_blocking( source.display() ); let started = std::time::Instant::now(); + on_progress(0.0); let mut command = Command::new(ffmpeg); command.args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"]); @@ -2131,18 +2215,14 @@ fn optimize_theme_media_blocking( "mp4", ]); command.arg(&temporary); - let first = command.output(); - let success = match first { - Ok(output) if output.status.success() => true, - Ok(output) => { + let success = match run_ffmpeg_with_progress(command, duration_us, &on_progress) { + Ok((true, _)) => true, + Ok((false, stderr)) => { log::warn!( target: "launcher", "Hardware theme video encode failed: {}", - String::from_utf8_lossy(&output.stderr) - .chars() - .take(500) - .collect::() + stderr.chars().take(500).collect::() ); false } @@ -2186,16 +2266,14 @@ fn optimize_theme_media_blocking( "mp4", ]); fallback.arg(&temporary); - match fallback.output() { - Ok(output) if output.status.success() => true, - Ok(output) => { + + match run_ffmpeg_with_progress(fallback, duration_us, &on_progress) { + Ok((true, _)) => true, + Ok((false, stderr)) => { log::warn!( target: "launcher", "Software theme video encode failed: {}", - String::from_utf8_lossy(&output.stderr) - .chars() - .take(500) - .collect::() + stderr.chars().take(500).collect::() ); false } @@ -2213,6 +2291,7 @@ fn optimize_theme_media_blocking( std::fs::rename(&temporary, destination) .map_err(|error| format!("Could not store the optimized theme video: {error}"))?; + on_progress(1.0); log::info!( target: "launcher", "Optimized theme video in {:.1}s: {}", diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 3a36d21..a6d5294 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -106,7 +106,9 @@ pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> let configuration: *mut AnyObject = msg_send![webview, configuration]; if configuration.is_null() { return; - } // WKAudiovisualMediaTypesNone. This is a public WKWebView setting. + } + + // WKAudiovisualMediaTypesNone. This is a public WKWebView setting. let _: () = msg_send![configuration, setMediaTypesRequiringUserActionForPlayback: 0usize]; let current: usize = msg_send![configuration, mediaTypesRequiringUserActionForPlayback]; @@ -114,6 +116,64 @@ pub fn allow_media_autoplay(window: &tauri::WebviewWindow) -> Result<(), String> target: "launcher", "Theme media autoplay policy: {current}" ); + + // Configure webpage preferences for unconditional media autoplay + let default_preferences: *mut AnyObject = msg_send![configuration, defaultWebpagePreferences]; + if !default_preferences.is_null() { + let sel = objc2::sel!(_setAutoplayPolicy:); + let responds: bool = msg_send![default_preferences, respondsToSelector: sel]; + if responds { + // 1 = _WKWebsiteAutoplayPolicyAllow (allow autoplay unconditionally) + let _: () = msg_send![default_preferences, _setAutoplayPolicy: 1isize]; + log::debug!(target: "launcher", "Set WKWebpagePreferences._setAutoplayPolicy to Allow (1)"); + } + } + + // Configure WKPreferences + let preferences: *mut AnyObject = msg_send![configuration, preferences]; + if !preferences.is_null() { + let sel_audio = objc2::sel!(_setRequiresUserGestureForAudioPlayback:); + let responds: bool = msg_send![preferences, respondsToSelector: sel_audio]; + if responds { + let _: () = msg_send![preferences, _setRequiresUserGestureForAudioPlayback: false]; + } + + let sel_video = objc2::sel!(_setRequiresUserGestureForVideoPlayback:); + let responds: bool = msg_send![preferences, respondsToSelector: sel_video]; + if responds { + let _: () = msg_send![preferences, _setRequiresUserGestureForVideoPlayback: false]; + } + + let sel_inline = objc2::sel!(_setAllowsInlineMediaPlayback:); + let responds: bool = msg_send![preferences, respondsToSelector: sel_inline]; + if responds { + let _: () = msg_send![preferences, _setAllowsInlineMediaPlayback: true]; + } + + let sel_inline_req = objc2::sel!(_setInlineMediaPlaybackRequiresPlaysInlineAttribute:); + let responds: bool = msg_send![preferences, respondsToSelector: sel_inline_req]; + if responds { + let _: () = msg_send![preferences, _setInlineMediaPlaybackRequiresPlaysInlineAttribute: false]; + } + + let sel_invis = objc2::sel!(_setInvisibleMediaAutoplayNotPermitted:); + let responds: bool = msg_send![preferences, respondsToSelector: sel_invis]; + if responds { + let _: () = msg_send![preferences, _setInvisibleMediaAutoplayNotPermitted: false]; + } + } + + let sel_media_load = objc2::sel!(_setMediaDataLoadsAutomatically:); + let responds: bool = msg_send![configuration, respondsToSelector: sel_media_load]; + if responds { + let _: () = msg_send![configuration, _setMediaDataLoadsAutomatically: true]; + } + + let sel_audio_action = objc2::sel!(_setRequiresUserActionForAudioPlayback:); + let responds: bool = msg_send![configuration, respondsToSelector: sel_audio_action]; + if responds { + let _: () = msg_send![configuration, _setRequiresUserActionForAudioPlayback: false]; + } }) .map_err(|error| format!("Could not enable theme media autoplay: {error}")) } diff --git a/src/app.css b/src/app.css index 08ad2cb..951bf04 100644 --- a/src/app.css +++ b/src/app.css @@ -172,4 +172,17 @@ [role="button"]:not([disabled]) { cursor: pointer; } + + /* Suppress browser and WebKit native media controls and overlay play buttons on theme videos */ + video::-webkit-media-controls, + video::-webkit-media-controls-enclosure, + video::-webkit-media-controls-panel, + video::-webkit-media-controls-play-button, + video::-webkit-media-controls-start-playback-button, + video::-webkit-media-controls-overlay-play-button { + display: none !important; + -webkit-appearance: none !important; + opacity: 0 !important; + pointer-events: none !important; + } } diff --git a/src/lib/themeMedia.ts b/src/lib/themeMedia.ts index 270d133..de6cd5c 100644 --- a/src/lib/themeMedia.ts +++ b/src/lib/themeMedia.ts @@ -66,7 +66,11 @@ const ensureStatusListener = async () => { } else if (stage === 'extract') { themeMediaStatus.set('Installing video engine…'); } else if (stage === 'optimize') { - themeMediaStatus.set('Converting theme video for hardware playback…'); + if (progress > 0) { + themeMediaStatus.set(`Converting theme video… ${Math.round(progress * 100)}%`); + } else { + themeMediaStatus.set('Converting theme video…'); + } } }); } catch (error) { @@ -74,6 +78,14 @@ const ensureStatusListener = async () => { } }; +export const initThemeMediaStatusListener = () => { + void ensureStatusListener(); +}; + +if (typeof window !== 'undefined') { + void ensureStatusListener(); +} + const blobUrlPromises = new Map>(); const optimizedUrlPromises = new Map>(); const objectUrls = new Set(); @@ -108,7 +120,7 @@ const optimizedThemeMediaUrl = async (source: string, video: boolean): Promise('optimize_theme_media', { @@ -181,6 +193,32 @@ export const clearThemeMediaCache = () => { objectUrls.clear(); }; +const unmutingArmed = new WeakSet(); + +/** Restore full sound as soon as the user interacts with the launcher. */ +const armUnmuteOnInteraction = (node: HTMLMediaElement, targetVolume: number) => { + if (unmutingArmed.has(node)) return; + unmutingArmed.add(node); + + const unmute = () => { + window.removeEventListener('pointerdown', unmute, true); + window.removeEventListener('keydown', unmute, true); + window.removeEventListener('click', unmute, true); + unmutingArmed.delete(node); + if (node.isConnected) { + node.muted = false; + node.volume = targetVolume; + if (node.paused) { + void node.play().catch(() => {}); + } + } + }; + + window.addEventListener('pointerdown', unmute, true); + window.addEventListener('keydown', unmute, true); + window.addEventListener('click', unmute, true); +}; + /** Retry playback on the first click or key press after an autoplay rejection. */ const armAutoplayRetry = (node: HTMLMediaElement) => { if (autoplayArmed.has(node)) return; @@ -189,15 +227,25 @@ const armAutoplayRetry = (node: HTMLMediaElement) => { const retry = () => { document.removeEventListener('pointerdown', retry, true); document.removeEventListener('keydown', retry, true); + document.removeEventListener('click', retry, true); autoplayArmed.delete(node); if (node.isConnected) void node.play().catch(() => {}); }; document.addEventListener('pointerdown', retry, true); document.addEventListener('keydown', retry, true); + document.addEventListener('click', retry, true); }; export const playThemeMedia = (node: HTMLMediaElement) => { + if (node instanceof HTMLVideoElement) { + node.playsInline = true; + node.setAttribute('playsinline', ''); + node.setAttribute('webkit-playsinline', ''); + node.controls = false; + node.removeAttribute('controls'); + } + void node .play() .then(() => { @@ -210,7 +258,25 @@ export const playThemeMedia = (node: HTMLMediaElement) => { }) .catch((error) => { console.warn('Theme media autoplay was blocked', error); - armAutoplayRetry(node); + if (node instanceof HTMLVideoElement && !node.muted) { + // Fallback: Start muted immediately so the background video plays + // automatically without freezing or showing native play buttons, + // and unmute on the first user interaction. + const targetVolume = node.volume > 0 ? node.volume : 0.15; + node.muted = true; + void node + .play() + .then(() => { + console.debug('Theme video started playing muted after autoplay block'); + armUnmuteOnInteraction(node, targetVolume); + }) + .catch((mutedError) => { + console.warn('Theme video muted autoplay was also blocked', mutedError); + armAutoplayRetry(node); + }); + } else { + armAutoplayRetry(node); + } }); }; @@ -570,11 +636,24 @@ export function smoothThemeMedia(node: HTMLElement) { const videoLifecycles = new WeakMap void>(); const prepare = (media: HTMLMediaElement) => { - if (media instanceof HTMLVideoElement && !videoLifecycles.has(media)) { - const lifecycle = themeVideoLifecycle(media); - videoLifecycles.set(media, () => lifecycle.destroy()); + if (media instanceof HTMLVideoElement) { + media.playsInline = true; + media.setAttribute('playsinline', ''); + media.setAttribute('webkit-playsinline', ''); + media.controls = false; + media.removeAttribute('controls'); + + if (!videoLifecycles.has(media)) { + const lifecycle = themeVideoLifecycle(media); + videoLifecycles.set(media, () => lifecycle.destroy()); + } + } else { + media.controls = false; + media.removeAttribute('controls'); } + playThemeMedia(media); + const source = media.getAttribute('src') ?? media.querySelector('source[src]')?.getAttribute('src') ?? diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 70634fb..aa213b5 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -1,5 +1,5 @@ import { active_custom_theme, custom_theme_audio_playing, custom_themes } from './global'; -import { clearThemeMediaCache } from './themeMedia'; +import { clearThemeMediaCache, playThemeMedia } from './themeMedia'; import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2'; import { disableMediaControls, setGlobalVolume } from './utils'; import { betterFetch } from '@better-fetch/fetch'; @@ -442,6 +442,11 @@ export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volum } setGlobalVolume(volume); disableMediaControls(); + + // Ensure all media elements mounted by v1 themes start playback automatically + themeContainer.querySelectorAll('video, audio').forEach((media) => { + playThemeMedia(media); + }); }; export const deleteTheme = async (themeToUninstall: Theme) => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 79bbe62..e38a91c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -38,6 +38,7 @@ import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil'; import * as presence from '$lib/presence'; import { + initThemeMediaStatusListener, smoothThemeMedia, themeMediaPreparing, themeMediaStatus, @@ -148,6 +149,8 @@ } onMount(async () => { + initThemeMediaStatusListener(); + try { const ver = await getLauncherVersion(); if (ver) launcherVersion.set(ver); @@ -528,7 +531,7 @@
- {$themeMediaStatus || 'Preparing theme video for smooth playback…'} + {$themeMediaStatus || 'Converting theme video…'}
{/if} {/if}