diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index d0dd6797c0..89d23ac952 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -43,6 +43,14 @@ export async function highlightInFolder(path) { return await invoke('plugin:utils|highlight_in_folder', { path }) } +export async function openVideoOverlay(videoId) { + return await invoke('plugin:utils|open_video_overlay', { videoId }) +} + +export async function closeVideoOverlay() { + return await invoke('plugin:utils|close_video_overlay') +} + export async function showLauncherLogsFolder() { return await invoke('plugin:utils|show_launcher_logs_folder', {}) } diff --git a/apps/app-frontend/src/pages/project/Description.vue b/apps/app-frontend/src/pages/project/Description.vue index 825b35d70a..c25c669b00 100644 --- a/apps/app-frontend/src/pages/project/Description.vue +++ b/apps/app-frontend/src/pages/project/Description.vue @@ -1,18 +1,126 @@ + + diff --git a/apps/app/build.rs b/apps/app/build.rs index f4d8c14a7b..e18170a3eb 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -243,6 +243,8 @@ fn main() { "should_disable_mouseover", "highlight_in_folder", "open_path", + "open_video_overlay", + "close_video_overlay", "show_launcher_logs_folder", "progress_bars_list", "get_opening_command", diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index a2b013ae3c..fdac1da02f 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; -use tauri::Runtime; +use tauri::webview::{NewWindowResponse, WebviewBuilder}; +use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime, WebviewUrl}; use tauri_plugin_opener::OpenerExt; use theseus::{ handler, @@ -20,6 +21,8 @@ pub fn init() -> tauri::plugin::TauriPlugin { should_disable_mouseover, highlight_in_folder, open_path, + open_video_overlay, + close_video_overlay, show_launcher_logs_folder, progress_bars_list, get_opening_command @@ -109,6 +112,117 @@ pub async fn open_path(app: tauri::AppHandle, path: PathBuf) { .ok(); } +const VIDEO_WEBVIEW_LABEL: &str = "video-overlay"; + +fn is_valid_video_id(video_id: &str) -> bool { + !video_id.is_empty() + && video_id.len() <= 16 + && video_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Computes the centered video rect (16:9) within the main window, leaving a +/// margin so the surrounding backdrop and the close button stay visible. +fn video_overlay_rect( + window_size: PhysicalSize, +) -> (PhysicalPosition, PhysicalSize) { + let win_w = window_size.width as f32; + let win_h = window_size.height as f32; + + // Use at most 80% of each dimension, keeping a 16:9 aspect ratio. + let max_w = win_w * 0.8; + let max_h = win_h * 0.8; + let mut width = max_w; + let mut height = width * 9.0 / 16.0; + if height > max_h { + height = max_h; + width = height * 16.0 / 9.0; + } + + let x = ((win_w - width) / 2.0).max(0.0); + let y = ((win_h - height) / 2.0).max(0.0); + + ( + PhysicalPosition::new(x as i32, y as i32), + PhysicalSize::new(width as u32, height as u32), + ) +} + +/// Opens a YouTube video as an in-app overlay webview centered over the main +/// window. +/// +/// Inline YouTube `/embed` iframes fail with "Video player configuration error" +/// (Error 153) inside the app, because the main webview loads from a custom +/// `tauri://localhost` scheme and macOS WKWebView refuses to attach a `Referer` +/// to the cross-origin subframe. Loading the standard `watch?v=` page in a +/// separate child webview gives YouTube a real origin, so playback works. The +/// frontend draws a dimmed backdrop and close button behind/around it. +#[tauri::command] +pub async fn open_video_overlay( + app: tauri::AppHandle, + video_id: String, +) -> Result<()> { + if !is_valid_video_id(&video_id) { + tracing::error!( + "Refusing to open invalid YouTube video id: {video_id}" + ); + return Ok(()); + } + + let Some(window) = app.get_window("main") else { + return Ok(()); + }; + + let url: Url = format!("https://www.youtube.com/watch?v={video_id}") + .parse() + .map_err(|_| { + TheseusSerializableError::Theseus( + theseus::ErrorKind::OtherError( + "Failed to parse video URL".to_string(), + ) + .as_error(), + ) + })?; + + let (position, size) = video_overlay_rect(window.inner_size()?); + + if let Some(webview) = app.webviews().get(VIDEO_WEBVIEW_LABEL) { + webview.navigate(url)?; + webview.set_size(size)?; + webview.set_position(position)?; + webview.show().ok(); + } else { + window.add_child( + WebviewBuilder::new(VIDEO_WEBVIEW_LABEL, WebviewUrl::External(url)) + .initialization_script_for_all_frames(include_str!( + "youtube-theater.js" + )) + .zoom_hotkeys_enabled(false) + .on_new_window(|_, _| NewWindowResponse::Deny), + position, + size, + )?; + } + + Ok(()) +} + +/// Hides the in-app video overlay webview (moved offscreen and hidden). +#[tauri::command] +pub async fn close_video_overlay(app: tauri::AppHandle) { + if let Some(webview) = app.webviews().get(VIDEO_WEBVIEW_LABEL) { + // Navigate away first so audio stops, then hide offscreen. + if let Ok(url) = "about:blank".parse() { + let _ = webview.navigate(url); + } + webview + .set_position(PhysicalPosition::new(-10000, -10000)) + .ok(); + webview.hide().ok(); + } +} + #[tauri::command] pub async fn show_launcher_logs_folder(app: tauri::AppHandle) { if let Some(d) = DirectoryInfo::global_handle_if_ready() { diff --git a/apps/app/src/api/youtube-theater.js b/apps/app/src/api/youtube-theater.js new file mode 100644 index 0000000000..74b87a33c4 --- /dev/null +++ b/apps/app/src/api/youtube-theater.js @@ -0,0 +1,85 @@ +// Injected into the in-app YouTube watch-page webview so it shows only the +// video player filling the popup, with no YouTube chrome (masthead, sidebar, +// comments). Stylesheet rules use `!important` so they override the inline +// sizing YouTube's player JS continuously re-applies to the