diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b49c05..278d019 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod combat_log; mod hotkey; +mod playback_window; mod recording; mod settings; mod wcl_upload; @@ -36,6 +37,7 @@ pub fn run() { .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(recording_state) + .manage(playback_window::PlaybackWindowState::default()) .manage(wcl_upload::WclAuthService::new()) .setup(|app| { let main_window = app @@ -89,6 +91,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ is_debug_build, + playback_window::enter_playback_fullscreen, + playback_window::exit_playback_fullscreen, recording::start_recording, recording::stop_recording, recording::list_capture_windows, diff --git a/src-tauri/src/playback_window.rs b/src-tauri/src/playback_window.rs new file mode 100644 index 0000000..48d39de --- /dev/null +++ b/src-tauri/src/playback_window.rs @@ -0,0 +1,218 @@ +//! Preserves native Windows placement while playback uses the full monitor. + +use tauri::{State, WebviewWindow}; + +#[cfg(target_os = "windows")] +use std::mem::size_of; +#[cfg(target_os = "windows")] +use std::sync::Mutex; +#[cfg(target_os = "windows")] +use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, +}; +#[cfg(target_os = "windows")] +use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowPlacement, SetWindowPlacement, SetWindowPos, ShowWindow, HWND_TOP, SWP_FRAMECHANGED, + SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, SW_MAXIMIZE, + SW_RESTORE, SW_SHOWMAXIMIZED, SW_SHOWNORMAL, WINDOWPLACEMENT, +}; + +#[derive(Default)] +pub(crate) struct PlaybackWindowState { + #[cfg(target_os = "windows")] + placement: Mutex>, +} + +#[cfg(target_os = "windows")] +fn windows_error(action: &str) -> String { + format!("{action}: {}", std::io::Error::last_os_error()) +} + +#[cfg(target_os = "windows")] +fn monitor_geometry(info: &MONITORINFO) -> Result<(i32, i32, i32, i32), String> { + let width = info.rcMonitor.right - info.rcMonitor.left; + let height = info.rcMonitor.bottom - info.rcMonitor.top; + + if width <= 0 || height <= 0 { + return Err("Active monitor reported invalid dimensions".to_string()); + } + + Ok((info.rcMonitor.left, info.rcMonitor.top, width, height)) +} + +#[cfg(target_os = "windows")] +unsafe fn restore_placement( + hwnd: windows_sys::Win32::Foundation::HWND, + placement: &WINDOWPLACEMENT, +) -> Result<(), String> { + if placement.showCmd == SW_SHOWMAXIMIZED as u32 { + let mut normal_placement = *placement; + normal_placement.showCmd = SW_SHOWNORMAL as u32; + if unsafe { SetWindowPlacement(hwnd, &normal_placement) } == 0 { + return Err(windows_error( + "Failed to restore the normal window placement", + )); + } + unsafe { ShowWindow(hwnd, SW_MAXIMIZE) }; + } else if unsafe { SetWindowPlacement(hwnd, placement) } == 0 { + return Err(windows_error("Failed to restore the window placement")); + } + + if unsafe { + SetWindowPos( + hwnd, + std::ptr::null_mut(), + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER, + ) + } == 0 + { + return Err(windows_error("Failed to refresh the restored window frame")); + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +#[tauri::command] +pub(crate) fn enter_playback_fullscreen( + window: WebviewWindow, + state: State<'_, PlaybackWindowState>, +) -> Result<(), String> { + let mut saved_placement = state + .placement + .lock() + .map_err(|_| "Playback window state lock is poisoned".to_string())?; + + if saved_placement.is_some() { + return Ok(()); + } + + let hwnd = window + .hwnd() + .map_err(|error| format!("Failed to access the application window: {error}"))? + .0; + let mut placement = WINDOWPLACEMENT { + length: size_of::() as u32, + ..Default::default() + }; + + unsafe { + if GetWindowPlacement(hwnd, &mut placement) == 0 { + return Err(windows_error("Failed to read the current window placement")); + } + + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if monitor.is_null() { + return Err(windows_error("Failed to find the active monitor")); + } + + let mut monitor_info = MONITORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut monitor_info) == 0 { + return Err(windows_error("Failed to read the active monitor geometry")); + } + + let (x, y, width, height) = monitor_geometry(&monitor_info)?; + *saved_placement = Some(placement); + ShowWindow(hwnd, SW_RESTORE); + + if SetWindowPos( + hwnd, + HWND_TOP, + x, + y, + width, + height, + SWP_FRAMECHANGED | SWP_NOOWNERZORDER | SWP_SHOWWINDOW, + ) == 0 + { + let entry_error = windows_error("Failed to enter playback fullscreen"); + match restore_placement(hwnd, &placement) { + Ok(()) => *saved_placement = None, + Err(rollback_error) => { + return Err(format!( + "{entry_error}; rollback also failed: {rollback_error}" + )); + } + } + return Err(entry_error); + } + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +#[tauri::command] +pub(crate) fn enter_playback_fullscreen( + _window: WebviewWindow, + _state: State<'_, PlaybackWindowState>, +) -> Result<(), String> { + Err("Playback fullscreen is only supported on Windows".to_string()) +} + +#[cfg(target_os = "windows")] +#[tauri::command] +pub(crate) fn exit_playback_fullscreen( + window: WebviewWindow, + state: State<'_, PlaybackWindowState>, +) -> Result<(), String> { + let mut saved_placement = state + .placement + .lock() + .map_err(|_| "Playback window state lock is poisoned".to_string())?; + let Some(placement) = saved_placement.as_ref() else { + return Ok(()); + }; + let hwnd = window + .hwnd() + .map_err(|error| format!("Failed to access the application window: {error}"))? + .0; + + unsafe { restore_placement(hwnd, placement)? }; + *saved_placement = None; + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +#[tauri::command] +pub(crate) fn exit_playback_fullscreen( + _window: WebviewWindow, + _state: State<'_, PlaybackWindowState>, +) -> Result<(), String> { + Err("Playback fullscreen is only supported on Windows".to_string()) +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use windows_sys::Win32::Foundation::RECT; + + #[test] + fn monitor_geometry_supports_offset_monitors() { + let info = MONITORINFO { + rcMonitor: RECT { + left: -1920, + top: 0, + right: 0, + bottom: 1080, + }, + ..Default::default() + }; + + assert_eq!(monitor_geometry(&info), Ok((-1920, 0, 1920, 1080))); + } + + #[test] + fn monitor_geometry_rejects_empty_bounds() { + let info = MONITORINFO::default(); + + assert!(monitor_geometry(&info).is_err()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e40d0ec..52e1372 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,6 +26,7 @@ "minWidth": 800, "minHeight": 500, "decorations": false, + "shadow": false, "transparent": false, "resizable": true, "maximizable": true, diff --git a/src/components/app/Layout.tsx b/src/components/app/Layout.tsx index 72abc9a..a47d7fa 100644 --- a/src/components/app/Layout.tsx +++ b/src/components/app/Layout.tsx @@ -11,7 +11,7 @@ import { RecordingsList } from "../playback/RecordingsList"; import { Settings } from "../settings/Settings"; import { CombatLogDebug } from "../debug/CombatLogDebug"; import { WarcraftLogsUploadPage } from "../warcraftlogs/WarcraftLogsUploadPage"; -import { VideoProvider } from "../../contexts/VideoContext"; +import { useVideo, VideoProvider } from "../../contexts/VideoContext"; import { RecordingProvider } from "../../contexts/RecordingContext"; import { SettingsProvider, useSettings } from "../../contexts/SettingsContext"; import { MarkerProvider } from "../../contexts/MarkerContext"; @@ -26,6 +26,7 @@ const AUTO_UPDATE_SESSION_FLAG = "floorpov:auto-update-check-ran"; function LayoutContent() { const { settings, isLoading: isSettingsLoading } = useSettings(); + const { isFullscreen } = useVideo(); const hasAttemptedAutoUpdateRef = useRef(false); const autoUpdateDownloadedBytesRef = useRef(0); const autoUpdateContentLengthRef = useRef(null); @@ -237,13 +238,21 @@ function LayoutContent() { {autoUpdateBannerText} )} - -
- + {!isFullscreen && } +
+ {!isFullscreen && ( + + )} {currentView === "main" ? ( -
{ - if (event.key === "ArrowUp") { - event.preventDefault(); - adjustMediaSectionHeight(-MEDIA_SECTION_RESIZE_DELTA); - return; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - adjustMediaSectionHeight(MEDIA_SECTION_RESIZE_DELTA); - } - }} - role="separator" - aria-orientation="horizontal" - aria-label="Resize media section" - aria-valuemin={320} - aria-valuenow={mediaSectionHeight} - aria-valuemax={mediaSectionMaxHeight} - aria-valuetext={`${mediaSectionHeight}px`} - tabIndex={0} - > -
-
- + {!isFullscreen && ( + <> +
{ + if (event.key === "ArrowUp") { + event.preventDefault(); + adjustMediaSectionHeight(-MEDIA_SECTION_RESIZE_DELTA); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + adjustMediaSectionHeight(MEDIA_SECTION_RESIZE_DELTA); + } + }} + role="separator" + aria-orientation="horizontal" + aria-label="Resize media section" + aria-valuemin={320} + aria-valuenow={mediaSectionHeight} + aria-valuemax={mediaSectionMaxHeight} + aria-valuetext={`${mediaSectionHeight}px`} + tabIndex={0} + > +
+
+ + + )} ) : currentView === "settings" ? ( { try { - const isMaximized = await appWindow.isMaximized(); - if (isMaximized) { - await appWindow.unmaximize(); - } else { - await appWindow.maximize(); - } - } catch (e) { - console.error('Maximize error:', e); + await appWindow.toggleMaximize(); + } catch (error) { + console.error("Maximize error:", error); } }; diff --git a/src/components/playback/VideoPlayer.tsx b/src/components/playback/VideoPlayer.tsx index 214d6af..9476d6a 100644 --- a/src/components/playback/VideoPlayer.tsx +++ b/src/components/playback/VideoPlayer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { createPortal } from "react-dom"; import { AlertTriangle, @@ -35,22 +35,25 @@ export function VideoPlayer() { updateDuration, syncIsPlaying, setVideoLoading, + isFullscreen, + fullscreenPhase, + toggleFullscreen, + exitFullscreen, } = useVideo(); const { isRecording, recordingWarning } = useRecording(); const inlineSurfaceHostRef = useRef(null); + const controlsRef = useRef(null); const speedMenuRef = useRef(null); - const immersiveSurfaceRef = useRef(null); const [showSpeedMenu, setShowSpeedMenu] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const [seekValue, setSeekValue] = useState(0); const [volumeBeforeMute, setVolumeBeforeMute] = useState(1); - const [isImmersiveMode, setIsImmersiveMode] = useState(false); + const [showControls, setShowControls] = useState(true); const [inlineSurfaceRect, setInlineSurfaceRect] = useState({ left: 0, top: 0, width: 0, height: 0 }); - const [videoNativeSize, setVideoNativeSize] = useState({ width: 0, height: 0 }); - const [devicePixelRatio, setDevicePixelRatio] = useState(() => window.devicePixelRatio || 1); - const [immersiveViewportSize, setImmersiveViewportSize] = useState({ width: 0, height: 0 }); + const autoExitAttemptedRef = useRef(false); + const controlsHideTimeoutRef = useRef(null); const showVideo = Boolean(videoSrc) && !isRecording; const displayedSeekValue = Math.min(currentTime, Math.max(duration, 0)); @@ -59,11 +62,25 @@ export function VideoPlayer() { setSeekValue(displayedSeekValue); }; - const toggleImmersiveMode = () => { - setIsImmersiveMode((currentValue) => !currentValue); - }; + const resetControlsHideTimer = useCallback(() => { + setShowControls(true); + + if (controlsHideTimeoutRef.current !== null) { + window.clearTimeout(controlsHideTimeoutRef.current); + controlsHideTimeoutRef.current = null; + } - const inlineSurfaceStyle: CSSProperties | undefined = isImmersiveMode + if (isFullscreen) { + controlsHideTimeoutRef.current = window.setTimeout(() => { + if (!controlsRef.current?.contains(document.activeElement)) { + setShowControls(false); + } + controlsHideTimeoutRef.current = null; + }, 3000); + } + }, [isFullscreen]); + + const inlineSurfaceStyle: CSSProperties | undefined = isFullscreen ? undefined : inlineSurfaceRect.width > 0 && inlineSurfaceRect.height > 0 ? { @@ -83,31 +100,7 @@ export function VideoPlayer() { } }; - const immersiveVideoStyle = - isImmersiveMode && - videoNativeSize.width > 0 && - videoNativeSize.height > 0 && - immersiveViewportSize.width > 0 && - immersiveViewportSize.height > 0 - ? (() => { - const safeDevicePixelRatio = Math.max(1, devicePixelRatio); - const nativeCssWidth = Math.max(1, Math.floor(videoNativeSize.width / safeDevicePixelRatio)); - const nativeCssHeight = Math.max(1, Math.floor(videoNativeSize.height / safeDevicePixelRatio)); - const widthScale = immersiveViewportSize.width / nativeCssWidth; - const heightScale = immersiveViewportSize.height / nativeCssHeight; - const scale = Math.min(widthScale, heightScale, 1); - - return { - width: `${Math.max(1, Math.floor(nativeCssWidth * scale))}px`, - height: `${Math.max(1, Math.floor(nativeCssHeight * scale))}px`, - }; - })() - : undefined; - const immersiveControlsStyle = - isImmersiveMode && immersiveVideoStyle?.width - ? { width: immersiveVideoStyle.width } - : undefined; - const playerSurfaceClassName = isImmersiveMode + const playerSurfaceClassName = isFullscreen ? "fixed inset-0 z-[200] flex items-center justify-center overflow-hidden bg-neutral-950" : "fixed z-40 overflow-hidden bg-neutral-950/90"; @@ -136,23 +129,6 @@ export function VideoPlayer() { }; }, [showSpeedMenu]); - useEffect(() => { - if (!isImmersiveMode) { - return; - } - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsImmersiveMode(false); - } - }; - - window.addEventListener("keydown", handleEscape); - return () => { - window.removeEventListener("keydown", handleEscape); - }; - }, [isImmersiveMode]); - useEffect(() => { if (!showVideo) { syncIsPlaying(false); @@ -176,7 +152,7 @@ export function VideoPlayer() { window.clearTimeout(syncTimeout); window.cancelAnimationFrame(syncFrame); }; - }, [isImmersiveMode, showVideo, syncIsPlaying, videoRef]); + }, [showVideo, syncIsPlaying, videoRef]); useEffect(() => { if (!isSeeking) { @@ -184,12 +160,6 @@ export function VideoPlayer() { } }, [displayedSeekValue, isSeeking]); - useEffect(() => { - if (!videoSrc) { - setVideoNativeSize({ width: 0, height: 0 }); - } - }, [videoSrc]); - useEffect(() => { const updateInlineSurfaceRect = () => { const hostRect = inlineSurfaceHostRef.current?.getBoundingClientRect(); @@ -248,86 +218,134 @@ export function VideoPlayer() { }, []); useEffect(() => { - const handleResize = () => { - setDevicePixelRatio(window.devicePixelRatio || 1); - }; + if (showVideo || fullscreenPhase === "windowed") { + autoExitAttemptedRef.current = false; + return; + } - window.addEventListener("resize", handleResize); + if (fullscreenPhase === "fullscreen" && !autoExitAttemptedRef.current) { + autoExitAttemptedRef.current = true; + void exitFullscreen(); + } + }, [exitFullscreen, fullscreenPhase, showVideo]); + + useEffect(() => { + if (!isFullscreen || !showVideo) { + setShowControls(true); + if (controlsHideTimeoutRef.current !== null) { + window.clearTimeout(controlsHideTimeoutRef.current); + controlsHideTimeoutRef.current = null; + } + return; + } + + resetControlsHideTimer(); return () => { - window.removeEventListener("resize", handleResize); + if (controlsHideTimeoutRef.current !== null) { + window.clearTimeout(controlsHideTimeoutRef.current); + controlsHideTimeoutRef.current = null; + } }; - }, []); + }, [isFullscreen, resetControlsHideTimer, showVideo]); useEffect(() => { - if (!isImmersiveMode || !showVideo) { - setImmersiveViewportSize({ width: 0, height: 0 }); + if (!showVideo && !isFullscreen) { return; } - const updateViewportSize = () => { - const surfaceRect = immersiveSurfaceRef.current?.getBoundingClientRect(); - if (!surfaceRect) { + const handleKeyboard = (event: KeyboardEvent) => { + resetControlsHideTimer(); + + if (event.key === "Escape" && isFullscreen) { + event.preventDefault(); + setShowSpeedMenu(false); + void exitFullscreen(); return; } - const nextWidth = Math.max(0, Math.floor(surfaceRect.width)); - const nextHeight = Math.max(0, Math.floor(surfaceRect.height)); - - setImmersiveViewportSize((currentSize) => - currentSize.width === nextWidth && currentSize.height === nextHeight - ? currentSize - : { width: nextWidth, height: nextHeight } - ); - }; + if (!showVideo) { + return; + } - updateViewportSize(); + const target = event.target; + const isTextEntry = + target instanceof HTMLElement && + (target.isContentEditable || + ["SELECT", "TEXTAREA"].includes(target.tagName) || + (target instanceof HTMLInputElement && target.type !== "range")); - if (typeof ResizeObserver === "undefined") { - window.addEventListener("resize", updateViewportSize); - return () => { - window.removeEventListener("resize", updateViewportSize); - }; - } + if ((event.key === "f" || event.key === "F") && !isTextEntry) { + event.preventDefault(); + void toggleFullscreen(); + return; + } - const resizeObserver = new ResizeObserver(() => { - updateViewportSize(); - }); + if ( + isTextEntry || + (target instanceof HTMLElement && ["BUTTON", "INPUT"].includes(target.tagName)) + ) { + return; + } - if (immersiveSurfaceRef.current) { - resizeObserver.observe(immersiveSurfaceRef.current); - } + switch (event.key) { + case " ": + event.preventDefault(); + togglePlay(); + break; + case "ArrowLeft": + event.preventDefault(); + seek(Math.max(0, currentTime - 5)); + break; + case "ArrowRight": + event.preventDefault(); + seek(Math.min(duration, currentTime + 5)); + break; + case "m": + case "M": + event.preventDefault(); + if (volume === 0) { + setVolume(volumeBeforeMute > 0 ? volumeBeforeMute : 1); + } else { + setVolumeBeforeMute(volume); + setVolume(0); + } + break; + } + }; - window.addEventListener("resize", updateViewportSize); + window.addEventListener("keydown", handleKeyboard); return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", updateViewportSize); + window.removeEventListener("keydown", handleKeyboard); }; - }, [isImmersiveMode, showVideo]); + }, [ + currentTime, + duration, + exitFullscreen, + isFullscreen, + resetControlsHideTimer, + seek, + setVolume, + showVideo, + toggleFullscreen, + togglePlay, + volume, + volumeBeforeMute, + ]); const playerSurface = (
{showVideo && ( -
+