diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 76dc851..0a6b13b 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -499,6 +499,85 @@ fn get_livekit_url(app: tauri::AppHandle) -> String { data.livekit_server_url.clone() } +#[tauri::command] +async fn create_screenshare_window( + app: tauri::AppHandle, + video_token: String, +) -> Result<(), String> { + if let Some(window) = app.get_webview_window("screenshare") { + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + + let url = format!("screenshare.html?videoToken={}", video_token); + + #[allow(unused_mut)] + let mut window_builder = + WebviewWindowBuilder::new(&app, "screenshare", WebviewUrl::App(url.into())) + .title("Screen sharing") + .inner_size(800.0, 450.0) + .resizable(true) + .visible(false) + .transparent(true) + .decorations(false) + .shadow(true) + .always_on_top(false) + .maximizable(false); + + #[cfg(target_os = "macos")] + { + window_builder = window_builder.hidden_title(true) + } + + let screenshare_window = window_builder + .build() + .map_err(|e| format!("Failed to create screenshare window: {}", e))?; + + let window_clone = screenshare_window.clone(); + + screenshare_window + .run_on_main_thread(move || { + #[cfg(target_os = "macos")] + { + use window_vibrancy::{ + apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState, + }; + + if let Err(e) = apply_vibrancy( + &window_clone, + NSVisualEffectMaterial::HudWindow, + Some(NSVisualEffectState::Active), + Some(16.0), + ) { + log::warn!("Failed to apply vibrancy to screenshare window: {}", e); + } + + set_window_corner_radius(&window_clone, 16.0); + } + + #[cfg(target_os = "windows")] + { + use window_vibrancy::apply_blur; + + if let Err(e) = apply_blur(&window_clone, Some((18, 18, 18, 125))) { + log::warn!("Failed to apply blur to screenshare window: {}", e); + } + } + + if let Err(e) = window_clone.show() { + log::error!("Failed to show screenshare window: {}", e); + } + + if let Err(e) = window_clone.set_focus() { + log::error!("Failed to focus screenshare window: {}", e); + } + }) + .map_err(|e| format!("Failed to run on main thread: {}", e))?; + + Ok(()) +} + #[tauri::command] async fn create_camera_window(app: tauri::AppHandle, camera_token: String) -> Result<(), String> { log::info!("create_camera_window with token: {}", camera_token); @@ -519,9 +598,7 @@ async fn create_camera_window(app: tauri::AppHandle, camera_token: String) -> Re #[cfg(target_os = "macos")] { - window_builder = window_builder - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Overlay); + window_builder = window_builder.hidden_title(true) } let camera_window = window_builder @@ -562,6 +639,10 @@ async fn create_camera_window(app: tauri::AppHandle, camera_token: String) -> Re if let Err(e) = window_clone.show() { log::error!("Failed to show camera window: {}", e); } + + if let Err(e) = window_clone.set_focus() { + log::error!("Failed to focus camera window: {}", e); + } }) .map_err(|e| format!("Failed to run on main thread: {}", e))?; @@ -934,6 +1015,7 @@ fn main() { get_camera_permission, open_camera_settings, create_camera_window, + create_screenshare_window, set_sentry_metadata, call_started, ]) diff --git a/tauri/src/App.css b/tauri/src/App.css index 0ab5321..942821a 100644 --- a/tauri/src/App.css +++ b/tauri/src/App.css @@ -407,13 +407,21 @@ body { } .screenshare-body { + margin: 0; + height: 100vh; + width: 100vw; overflow: hidden; - background-color: var(--color-slate-900); + background-color: transparent; +} + +.screenshare-body #root { + height: 100%; + width: 100%; } .screenshare-video-focus, .screenshare-video:focus { - outline: 3px solid var(--color-yellow-300); + outline: 2px solid var(--color-yellow-300); outline-offset: -2px; } diff --git a/tauri/src/components/SharingScreen/Controls.tsx b/tauri/src/components/SharingScreen/Controls.tsx index 42417f1..0981940 100644 --- a/tauri/src/components/SharingScreen/Controls.tsx +++ b/tauri/src/components/SharingScreen/Controls.tsx @@ -1,5 +1,4 @@ import { HiOutlineCursorClick } from "react-icons/hi"; -import { LiaHandPointerSolid } from "react-icons/lia"; import { useSharingContext } from "@/windows/screensharing/context"; import { TooltipContent, TooltipTrigger, Tooltip, TooltipProvider } from "../ui/tooltip"; import { BiSolidJoystick } from "react-icons/bi"; @@ -7,8 +6,13 @@ import useStore from "@/store/store"; import { SegmentedControl } from "../ui/segmented-control"; import { useState } from "react"; import { CustomIcons } from "../ui/icons"; +import { cn } from "@/lib/utils"; -export function ScreenSharingControls() { +type ScreenSharingControlsProps = { + className?: string; +}; + +export function ScreenSharingControls({ className }: ScreenSharingControlsProps = {}) { const { setIsSharingKeyEvents, setIsSharingMouse } = useSharingContext(); const isRemoteControlEnabled = useStore((state) => state.callTokens?.isRemoteControlEnabled); const [remoteControlStatus, setRemoteControlStatus] = useState("controlling"); @@ -26,7 +30,7 @@ export function ScreenSharingControls() { return ( -
+
{ onlySubscribed: true, }); const localParticipant = useLocalParticipant(); - let { isSharingMouse, isSharingKeyEvents, parentKeyTrap } = useSharingContext(); + const { isSharingMouse, isSharingKeyEvents, parentKeyTrap, setStreamDimensions } = useSharingContext(); const [wrapperRef, isMouseInside] = useHover(); const { updateCallTokens } = useStore(); const [mouse, mouseRef] = useMouse(); @@ -263,6 +263,14 @@ const ConsumerComponent = React.memo(() => { } }, [track, streamWidth, streamHeight]); + useEffect(() => { + if (track) { + setStreamDimensions({ width: streamWidth, height: streamHeight }); + } else { + setStreamDimensions(null); + } + }, [track, streamWidth, streamHeight, setStreamDimensions]); + /* * We do this because we need a way to retrigger the useEffect below, * adding the videoRef.current to the dependency array doesn't work because @@ -525,7 +533,7 @@ const ConsumerComponent = React.memo(() => { }, [isMouseInside, isSharingKeyEvents, parentKeyTrap]); const clearClipboard = useCallback(async () => { - await writeText(""); + await writeText(""); }, []); useEffect(() => { @@ -628,10 +636,13 @@ const ConsumerComponent = React.memo(() => { return (
{DEBUGGING_VIDEO_TRACK && ( diff --git a/tauri/src/components/SharingScreen/utils.ts b/tauri/src/components/SharingScreen/utils.ts index 5f6dbaf..c0fef62 100644 --- a/tauri/src/components/SharingScreen/utils.ts +++ b/tauri/src/components/SharingScreen/utils.ts @@ -1,34 +1,19 @@ import { OS } from "@/constants"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { getCurrentWindow, PhysicalSize, LogicalSize } from "@tauri-apps/api/window"; -import { currentMonitor } from "@tauri-apps/api/window"; +import { PhysicalSize, LogicalSize, currentMonitor } from "@tauri-apps/api/window"; const appWindow = getCurrentWebviewWindow(); -/* - * This function resizes the window to fit the stream's aspect ratio. - * - * It assumes that the stream's aspect ratio is greater than 1 (width > height). - * There are two possible scenarios that need to be handled in order to avoid - * the window to overflowing the screen (we could only overflow the heigth because - * we this is calculated from the width using the aspect ratio): - * - The monitor's width is greater than its height, and then - * we need to make sure the stream doesn't overflow the height. - * - * In this case we calculate the max width from the monitor's height - * and don't allow the window to have a width greater than the - * calculated one. - * - * - The monitor's height is greater than its width, and then - * we need to make sure the stream doesn't overflow the width. - * - * In this case we calculate the max height from the monitor's width - * and don't allow the window to have a height greater than the - * calculated one. - */ -export async function resizeWindow(streamWidth: number, streamHeight: number, ref: React.RefObject) { +type WindowSizingResult = { + maxContentWidth: number; + maxContentHeight: number; + aspectRatio: number; + streamExtraOffset: number; +}; + +const calculateWindowSizing = async (streamWidth: number, streamHeight: number): Promise => { if (streamWidth === 16 && streamHeight === 9) { - return; + return null; } const monitor = await currentMonitor(); const monitorWidth = monitor?.size.width || 0; @@ -63,15 +48,41 @@ export async function resizeWindow(streamWidth: number, streamHeight: number, re let maxHeight = Math.floor(monitorHeight - taskbarHeight - streamExtraOffset); let maxWidth = Math.floor(monitorWidth); - if (maxWidth > 0 && maxHeight > 0) { - if (monitorWidth >= monitorHeight) { - maxWidth = Math.floor(maxHeight * aspectRatio); - } else { - maxHeight = Math.floor(maxWidth / aspectRatio); - } - appWindow.setMaxSize(new LogicalSize(maxWidth, maxHeight + streamExtraOffset)); + if (maxWidth <= 0 || maxHeight <= 0) { + return null; } + if (monitorWidth >= monitorHeight) { + maxWidth = Math.floor(maxHeight * aspectRatio); + } else { + maxHeight = Math.floor(maxWidth / aspectRatio); + } + + return { + maxContentWidth: maxWidth, + maxContentHeight: maxHeight, + aspectRatio, + streamExtraOffset, + }; +}; + +/* + * This function resizes the window to fit the stream's aspect ratio. + * + * It assumes that the stream's aspect ratio is greater than 1 (width > height). + * There are two possible scenarios that need to be handled in order to avoid + * the window to overflowing the screen (we could only overflow the height because + * this is calculated from the width using the aspect ratio). + */ +export async function resizeWindow(streamWidth: number, streamHeight: number, ref: React.RefObject) { + const sizing = await calculateWindowSizing(streamWidth, streamHeight); + if (!sizing) { + return; + } + + const { maxContentWidth, maxContentHeight, aspectRatio, streamExtraOffset } = sizing; + appWindow.setMaxSize(new LogicalSize(maxContentWidth, maxContentHeight + streamExtraOffset)); + let size = await appWindow.innerSize(); if (ref.current) { @@ -79,8 +90,8 @@ export async function resizeWindow(streamWidth: number, streamHeight: number, re return; } - const newWidth = Math.min(size.width, maxWidth); - const newHeight = Math.min(Math.floor(size.width / aspectRatio), maxHeight) + streamExtraOffset; + const newWidth = Math.min(size.width, maxContentWidth); + const newHeight = Math.min(Math.floor(size.width / aspectRatio), maxContentHeight) + streamExtraOffset; console.log(`Current size is ${size.width}x${size.height}; New size will be ${newWidth}x${newHeight}`); appWindow.setSize( // As the video will be always full window width minus some padding, @@ -89,3 +100,17 @@ export async function resizeWindow(streamWidth: number, streamHeight: number, re ); } } + +export async function setWindowToMaxStreamSize(streamWidth: number, streamHeight: number) { + const sizing = await calculateWindowSizing(streamWidth, streamHeight); + if (!sizing) { + return; + } + + const { maxContentWidth, maxContentHeight, streamExtraOffset } = sizing; + + await appWindow.setMaxSize(new LogicalSize(maxContentWidth, maxContentHeight + streamExtraOffset)); + await appWindow.setSize( + new PhysicalSize(Math.floor(maxContentWidth), Math.floor(maxContentHeight + streamExtraOffset)), + ); +} diff --git a/tauri/src/windows/screensharing/context.tsx b/tauri/src/windows/screensharing/context.tsx index 4ba68cd..8a9772e 100644 --- a/tauri/src/windows/screensharing/context.tsx +++ b/tauri/src/windows/screensharing/context.tsx @@ -9,6 +9,8 @@ type SharingContextType = { setVideoToken: (value: string) => void; parentKeyTrap?: HTMLDivElement; setParentKeyTrap: (value: HTMLDivElement) => void; + streamDimensions: { width: number; height: number } | null; + setStreamDimensions: (value: { width: number; height: number } | null) => void; }; const SharingContext = createContext(undefined); @@ -30,6 +32,7 @@ export const SharingProvider: React.FC = ({ children }) => const [isSharingKeyEvents, setIsSharingKeyEvents] = useState(true); const [parentKeyTrap, setParentKeyTrap] = useState(undefined); const [videoToken, setVideoToken] = useState(null); + const [streamDimensions, setStreamDimensions] = useState<{ width: number; height: number } | null>(null); return ( = ({ children }) => setParentKeyTrap, videoToken, setVideoToken, + streamDimensions, + setStreamDimensions, }} > {children} diff --git a/tauri/src/windows/screensharing/main.tsx b/tauri/src/windows/screensharing/main.tsx index b079f61..77263f0 100644 --- a/tauri/src/windows/screensharing/main.tsx +++ b/tauri/src/windows/screensharing/main.tsx @@ -1,14 +1,18 @@ import "@/services/sentry"; import "../../App.css"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom/client"; import { SharingScreen } from "@/components/SharingScreen/SharingScreen"; import { SharingProvider, useSharingContext } from "./context"; import { ScreenSharingControls } from "@/components/SharingScreen/Controls"; import { Toaster } from "react-hot-toast"; -import { useDisableNativeContextMenu } from "@/lib/hooks"; +import { useDisableNativeContextMenu, useResizeListener } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; import { tauriUtils } from "../window-utils"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { PhysicalSize, PhysicalPosition, currentMonitor } from "@tauri-apps/api/window"; +import { setWindowToMaxStreamSize } from "@/components/SharingScreen/utils"; +import { LuMaximize2, LuMinimize2, LuMinus, LuX } from "react-icons/lu"; const appWindow = getCurrentWebviewWindow(); @@ -20,10 +24,44 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( , ); +const TitlebarButton = ({ + onClick, + disabled, + children, + label, + className, +}: { + onClick?: () => void; + disabled?: boolean; + children: React.ReactNode; + label: string; + className?: string; +}) => { + return ( + + ); +}; + function Window() { useDisableNativeContextMenu(); - const { setParentKeyTrap, setVideoToken, videoToken } = useSharingContext(); + const { setParentKeyTrap, setVideoToken, videoToken, streamDimensions } = useSharingContext(); const [livekitUrl, setLivekitUrl] = useState(""); + const previousSizeRef = useRef<{ width: number; height: number; x: number; y: number } | null>(null); + const [isMaximized, setIsMaximized] = useState(false); + const isProgrammaticResizeRef = useRef(false); useEffect(() => { const videoTokenFromUrl = tauriUtils.getTokenParam("videoToken"); @@ -45,15 +83,146 @@ function Window() { enableDock(); }, []); + // Detect manual window resizing and reset isMaximized state + const handleWindowResize = useCallback(() => { + // Ignore resize events during programmatic resizing + if (isProgrammaticResizeRef.current) { + return; + } + + // If window is marked as maximized but user manually resized, reset the state + if (isMaximized) { + setIsMaximized(false); + previousSizeRef.current = null; + } + }, [isMaximized]); + + useResizeListener(handleWindowResize); + + const handleClose = useCallback(() => { + appWindow.close(); + }, []); + + const handleMinimize = useCallback(async () => { + const minimized = await appWindow.isMinimized(); + if (minimized) { + await appWindow.show(); + await appWindow.unminimize(); + await appWindow.setFocus(); + } else { + await appWindow.minimize(); + } + }, []); + + const handleFullscreen = useCallback(async () => { + if (!streamDimensions) { + return; + } + + if (!isMaximized) { + isProgrammaticResizeRef.current = true; + const size = await appWindow.innerSize(); + const position = await appWindow.innerPosition(); + previousSizeRef.current = { + width: size.width, + height: size.height, + x: position.x, + y: position.y, + }; + + // Get monitor info for centering + const monitor = await currentMonitor(); + if (!monitor) { + await setWindowToMaxStreamSize(streamDimensions.width, streamDimensions.height); + setIsMaximized(true); + isProgrammaticResizeRef.current = false; + return; + } + + // Calculate window size using 92% of screen height + const factor = await appWindow.scaleFactor(); + const streamExtraOffset = 50 * factor; + const aspectRatio = streamDimensions.width / streamDimensions.height; + + // Use 92% of monitor height + const maxHeight = Math.floor(monitor.size.height * 0.87); + const maxWidth = Math.floor(monitor.size.width); + + // Calculate width based on aspect ratio, ensuring it fits within screen bounds + let finalWidth: number; + let finalHeight: number; + + if (maxHeight * aspectRatio <= maxWidth) { + // Height is the limiting factor + finalHeight = Math.floor(maxHeight + streamExtraOffset); + finalWidth = Math.floor(maxHeight * aspectRatio); + } else { + // Width is the limiting factor + finalWidth = maxWidth; + finalHeight = Math.floor(maxWidth / aspectRatio + streamExtraOffset); + } + + // Set window size + await appWindow.setSize(new PhysicalSize(finalWidth, finalHeight)); + + // Center the window on the monitor + const centerX = Math.floor((monitor.size.width - finalWidth) / 2) + monitor.position.x; + const centerY = Math.floor((monitor.size.height - finalHeight) / 2) + monitor.position.y; + await appWindow.setPosition(new PhysicalPosition(centerX, centerY)); + + setIsMaximized(true); + // Reset flag after a short delay to allow resize event to fire + setTimeout(() => { + isProgrammaticResizeRef.current = false; + }, 100); + } else if (previousSizeRef.current) { + isProgrammaticResizeRef.current = true; + await appWindow.setSize(new PhysicalSize(previousSizeRef.current.width, previousSizeRef.current.height)); + await appWindow.setPosition(new PhysicalPosition(previousSizeRef.current.x, previousSizeRef.current.y)); + previousSizeRef.current = null; + setIsMaximized(false); + // Reset flag after a short delay to allow resize event to fire + setTimeout(() => { + isProgrammaticResizeRef.current = false; + }, 100); + } + }, [streamDimensions, isMaximized]); + + const fullscreenDisabled = !streamDimensions; + return (
ref && setParentKeyTrap(ref)} > -
- +
+
+ + + + + + + + {isMaximized ? + + : } + +
+
+ +
+
{videoToken && } diff --git a/tauri/src/windows/window-utils.ts b/tauri/src/windows/window-utils.ts index faf6968..a812162 100644 --- a/tauri/src/windows/window-utils.ts +++ b/tauri/src/windows/window-utils.ts @@ -10,8 +10,6 @@ getVersion().then((version) => { }); const createScreenShareWindow = async (videoToken: string, bringToFront: boolean = true) => { - const URL = `screenshare.html?videoToken=${videoToken}`; - // Check if there is already a window open, // then focus on it and bring it to the front const isWindowOpen = await WebviewWindow.getByLabel("screenshare"); @@ -21,23 +19,17 @@ const createScreenShareWindow = async (videoToken: string, bringToFront: boolean } if (isTauri) { - const newWindow = new WebviewWindow("screenshare", { - width: 800, - height: 450, - url: URL, - hiddenTitle: true, - titleBarStyle: "overlay", - resizable: true, - // alwaysOnTop: true, - maximizable: false, - alwaysOnTop: false, - visible: true, - title: "Screen sharing", - }); - newWindow.once("tauri://window-created", () => { - newWindow.setFocus(); - }); + try { + await invoke("create_screenshare_window", { videoToken }); + const windowHandle = await WebviewWindow.getByLabel("screenshare"); + if (windowHandle) { + await windowHandle.setFocus(); + } + } catch (error) { + console.error("Failed to create screenshare window:", error); + } } else { + const URL = `screenshare.html?videoToken=${videoToken}`; window.open(URL); } };