Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod combat_log;
mod hotkey;
mod playback_window;
mod recording;
mod settings;
mod wcl_upload;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
218 changes: 218 additions & 0 deletions src-tauri/src/playback_window.rs
Original file line number Diff line number Diff line change
@@ -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<Option<WINDOWPLACEMENT>>,
}

#[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::<WINDOWPLACEMENT>() 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::<MONITORINFO>() 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());
}
}
1 change: 1 addition & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"minWidth": 800,
"minHeight": 500,
"decorations": false,
"shadow": false,
"transparent": false,
"resizable": true,
"maximizable": true,
Expand Down
87 changes: 50 additions & 37 deletions src/components/app/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<number | null>(null);
Expand Down Expand Up @@ -237,13 +238,21 @@ function LayoutContent() {
{autoUpdateBannerText}
</div>
)}
<TitleBar />
<div className="flex flex-1 min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex-row md:gap-3 md:p-3">
<Sidebar
onNavigate={handleNavigate}
currentView={currentView}
isDebugMode={isDebugBuild}
/>
{!isFullscreen && <TitleBar />}
<div
className={
isFullscreen
? "flex min-h-0 flex-1 overflow-hidden"
: "flex flex-1 min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex-row md:gap-3 md:p-3"
}
>
{!isFullscreen && (
<Sidebar
onNavigate={handleNavigate}
currentView={currentView}
isDebugMode={isDebugBuild}
/>
)}
<AnimatePresence mode="wait" initial={false}>
{currentView === "main" ? (
<motion.div
Expand All @@ -263,35 +272,39 @@ function LayoutContent() {
<VideoPlayer />
</main>
</section>
<div
className={`flex h-3 w-full cursor-row-resize items-center justify-center border-y border-white/10 bg-(--surface-2) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45 ${
isResizingMedia ? "bg-white/10" : "hover:bg-white/5"
}`}
onPointerDown={handleMediaResizeStart}
onKeyDown={(event) => {
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}
>
<div className="h-0.5 w-24 rounded-full bg-white/35" />
</div>
<RecordingsList />
{!isFullscreen && (
<>
<div
className={`flex h-3 w-full cursor-row-resize items-center justify-center border-y border-white/10 bg-(--surface-2) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45 ${
isResizingMedia ? "bg-white/10" : "hover:bg-white/5"
}`}
onPointerDown={handleMediaResizeStart}
onKeyDown={(event) => {
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}
>
<div className="h-0.5 w-24 rounded-full bg-white/35" />
</div>
<RecordingsList />
</>
)}
</motion.div>
) : currentView === "settings" ? (
<motion.div
Expand Down
11 changes: 3 additions & 8 deletions src/components/app/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ export function TitleBar() {

const handleMaximize = async () => {
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);
}
};

Expand Down
Loading