mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
feat(ui): replace detach mode with embed mode (#1378)
Remove the detach window feature and replace it with a simpler `?embed` query parameter that hides the header bar and status bar using the same settings levers. Embed mode latches into session state so it persists across in-app navigation. - Delete useDetachedWindow hook and window tracking logic - Add `isEmbedMode` to UI store, latched from `?embed` query param - Embed mode forces hideHeaderBar and hideStatusBar via same code path as the existing appearance settings - Replace detach/close buttons with fullscreen split button containing "Compact Window" option that opens embed view in new window - Hide settings button and show close button in embed mode - Simplify useAppNavigation by removing query param threading - Add action_bar_compact_window i18n key to all 14 locales
This commit is contained in:
+1082
-1081
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
+1095
-1094
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useRef } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import {
|
||||
LuCable,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
useVideoStore,
|
||||
} from "@hooks/stores";
|
||||
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
|
||||
import { useDetachedWindow } from "@hooks/useDetachedWindow";
|
||||
import { Button } from "@components/Button";
|
||||
import Container from "@components/Container";
|
||||
import PasteModal from "@components/popovers/PasteModal";
|
||||
@@ -39,13 +37,9 @@ import { m } from "@localizations/messages.js";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
isDetachedWindow,
|
||||
}: {
|
||||
requestFullscreen: () => Promise<void>;
|
||||
isDetachedWindow?: boolean;
|
||||
}) {
|
||||
const params = useParams() as { id?: string };
|
||||
const deviceId = params.id || "local";
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
||||
const {
|
||||
@@ -57,11 +51,11 @@ export default function Actionbar({
|
||||
setOcrMode,
|
||||
usbSerialConsoleEnabled,
|
||||
setUsbSerialConsoleEnabled,
|
||||
isEmbedMode,
|
||||
} = useUiStore();
|
||||
const { remoteVirtualMediaState } = useMountMediaStore();
|
||||
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||
const { developerMode } = useSettingsStore();
|
||||
const { openDetachedWindow } = useDetachedWindow();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -327,7 +321,7 @@ export default function Actionbar({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isDetachedWindow && (
|
||||
{!isEmbedMode && (
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
@@ -344,7 +338,7 @@ export default function Actionbar({
|
||||
|
||||
<div className="hidden items-center gap-x-2 lg:flex">
|
||||
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
||||
{isDetachedWindow ? (
|
||||
{isEmbedMode ? (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
@@ -353,22 +347,26 @@ export default function Actionbar({
|
||||
onClick={() => window.close()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={m.detach()}
|
||||
LeadingIcon={LuExternalLink}
|
||||
onClick={() => openDetachedWindow(deviceId)}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={m.action_bar_fullscreen()}
|
||||
LeadingIcon={LuMaximize}
|
||||
<SplitButtonGroup>
|
||||
<SplitButtonPrimary
|
||||
icon={LuMaximize}
|
||||
label={m.action_bar_fullscreen()}
|
||||
onClick={() => requestFullscreen()}
|
||||
/>
|
||||
</>
|
||||
<SplitButtonCaret
|
||||
menuItems={[
|
||||
{
|
||||
label: m.action_bar_compact_window(),
|
||||
icon: LuExternalLink,
|
||||
onClick: () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("embed", "");
|
||||
window.open(url.toString(), "_blank", "noopener");
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SplitButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,12 +23,10 @@ import { m } from "@localizations/messages.js";
|
||||
|
||||
export default function WebRTCVideo({
|
||||
hasConnectionIssues,
|
||||
hideActionBar,
|
||||
isDetachedWindow,
|
||||
hideStatusBar,
|
||||
}: {
|
||||
hasConnectionIssues: boolean;
|
||||
hideActionBar?: boolean;
|
||||
isDetachedWindow?: boolean;
|
||||
hideStatusBar?: boolean;
|
||||
}) {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
@@ -606,35 +604,30 @@ export default function WebRTCVideo({
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-(--grid-layout)">
|
||||
{!hideActionBar && (
|
||||
<div className="flex min-h-[39.5px] flex-col">
|
||||
<div className="flex flex-col">
|
||||
<fieldset
|
||||
disabled={peerConnection?.connectionState !== "connected"}
|
||||
className="contents"
|
||||
>
|
||||
<Actionbar
|
||||
requestFullscreen={requestFullscreen}
|
||||
isDetachedWindow={isDetachedWindow}
|
||||
/>
|
||||
<MacroBar />
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="flex min-h-[39.5px] flex-col">
|
||||
<div className="flex flex-col">
|
||||
<fieldset
|
||||
disabled={peerConnection?.connectionState !== "connected"}
|
||||
className="contents"
|
||||
>
|
||||
<Actionbar
|
||||
requestFullscreen={requestFullscreen}
|
||||
/>
|
||||
<MacroBar />
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="h-full overflow-hidden">
|
||||
<div className="relative h-full">
|
||||
{!isDetachedWindow && (
|
||||
<div
|
||||
className={cx(
|
||||
"absolute inset-0 z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
|
||||
"bg-[radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px),radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px)] dark:bg-[radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px),radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px)]",
|
||||
"bg-position-[0_0,10px_10px]",
|
||||
"bg-size-[20px_20px]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cx(
|
||||
"absolute inset-0 z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
|
||||
"bg-[radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px),radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px)] dark:bg-[radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px),radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px)]",
|
||||
"bg-position-[0_0,10px_10px]",
|
||||
"bg-size-[20px_20px]",
|
||||
)}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative grow overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -642,9 +635,7 @@ export default function WebRTCVideo({
|
||||
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
|
||||
<PointerLockBar show={showPointerLockBar} />
|
||||
<div
|
||||
className={cx("relative flex items-center justify-center overflow-hidden", {
|
||||
"mx-4 my-2": !isDetachedWindow,
|
||||
})}
|
||||
className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={fullscreenContainerRef}
|
||||
@@ -672,8 +663,7 @@ export default function WebRTCVideo({
|
||||
hasConnectionIssues ||
|
||||
peerConnectionState !== "connected",
|
||||
"opacity-60!": showPointerLockBar,
|
||||
"animate-slideUpFade":
|
||||
isPlaying && !isDetachedWindow,
|
||||
"animate-slideUpFade": isPlaying,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
@@ -704,7 +694,7 @@ export default function WebRTCVideo({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hideActionBar && !settings.hideStatusBar && (
|
||||
{!hideStatusBar && (
|
||||
<div>
|
||||
<InfoBar />
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,9 @@ export interface UIState {
|
||||
|
||||
usbSerialConsoleEnabled: boolean;
|
||||
setUsbSerialConsoleEnabled: (enabled: boolean) => void;
|
||||
|
||||
isEmbedMode: boolean;
|
||||
setEmbedMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
@@ -117,6 +120,9 @@ export const useUiStore = create<UIState>(set => ({
|
||||
|
||||
usbSerialConsoleEnabled: false,
|
||||
setUsbSerialConsoleEnabled: (enabled: boolean) => set({ usbSerialConsoleEnabled: enabled }),
|
||||
|
||||
isEmbedMode: false,
|
||||
setEmbedMode: (enabled: boolean) => set({ isEmbedMode: enabled }),
|
||||
}));
|
||||
|
||||
export interface RTCState {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type { NavigateOptions } from "react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
@@ -41,7 +41,6 @@ export function getDeviceUiPath(path: string, deviceId?: string): string {
|
||||
export function useDeviceUiNavigation() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get the device ID from params
|
||||
const deviceId = useMemo(() => params.id, [params.id]);
|
||||
@@ -57,13 +56,9 @@ export function useDeviceUiNavigation() {
|
||||
// Function to navigate to the correct path with all options
|
||||
const navigateTo = useCallback(
|
||||
(path: string, options?: NavigateOptions) => {
|
||||
let computedPath = getPath(path);
|
||||
if (searchParams.get("detached") === "true") {
|
||||
computedPath += computedPath.includes("?") ? "&detached=true" : "?detached=true";
|
||||
}
|
||||
navigate(computedPath, options);
|
||||
void navigate(getPath(path), options);
|
||||
},
|
||||
[getPath, navigate, searchParams],
|
||||
[getPath, navigate],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { isOnDevice } from "@/main";
|
||||
|
||||
// Module-level Map to track windows (avoids serialization issues)
|
||||
const windowMap = new Map<string, Window>();
|
||||
|
||||
export function useDetachedWindow() {
|
||||
const openDetachedWindow = (deviceId: string) => {
|
||||
// Check existing window
|
||||
const existing = windowMap.get(deviceId);
|
||||
if (existing && !existing.closed) {
|
||||
existing.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 1280;
|
||||
const height = 720;
|
||||
const left = Math.max(0, (window.screen.width - width) / 2);
|
||||
const top = Math.max(0, (window.screen.height - height) / 2);
|
||||
const features = `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`;
|
||||
const url = isOnDevice ? "/?detached=true" : `/devices/${deviceId}?detached=true`;
|
||||
|
||||
const win = window.open(url, `jetkvm-${deviceId}`, features);
|
||||
if (win) {
|
||||
win.document.title = "JetKVM";
|
||||
windowMap.set(deviceId, win);
|
||||
// Cleanup on close
|
||||
const interval = setInterval(() => {
|
||||
if (win.closed) {
|
||||
windowMap.delete(deviceId);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return { openDetachedWindow };
|
||||
}
|
||||
@@ -124,10 +124,24 @@ export default function KvmIdRoute() {
|
||||
setDisableVideoFocusTrap,
|
||||
rebootState,
|
||||
setRebootState,
|
||||
isEmbedMode,
|
||||
setEmbedMode,
|
||||
} = useUiStore();
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
const isDetachedWindow = queryParams.get("detached") === "true";
|
||||
const hideHeaderBar = useSettingsStore(state => state.hideHeaderBar);
|
||||
const hasEmbedParam = queryParams.has("embed");
|
||||
|
||||
// Latch embed mode once from ?embed query param — persists across in-app
|
||||
// navigation even if the query param is lost (e.g. opening settings)
|
||||
useEffect(() => {
|
||||
if (hasEmbedParam && !isEmbedMode) {
|
||||
setEmbedMode(true);
|
||||
}
|
||||
}, [hasEmbedParam, isEmbedMode, setEmbedMode]);
|
||||
|
||||
const settingsHideHeaderBar = useSettingsStore(state => state.hideHeaderBar);
|
||||
const settingsHideStatusBar = useSettingsStore(state => state.hideStatusBar);
|
||||
const hideHeaderBar = isEmbedMode || settingsHideHeaderBar;
|
||||
const hideStatusBar = isEmbedMode || settingsHideStatusBar;
|
||||
|
||||
const {
|
||||
peerConnection,
|
||||
@@ -977,7 +991,7 @@ export default function KvmIdRoute() {
|
||||
return (
|
||||
<FeatureFlagProvider appVersion={appVersion}>
|
||||
<title>{displayHostname ? `${displayHostname} - JetKVM` : "JetKVM"}</title>
|
||||
{!isDetachedWindow && !outlet && otaState.updating && (
|
||||
{!isEmbedMode && !outlet && otaState.updating && (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
||||
@@ -991,7 +1005,7 @@ export default function KvmIdRoute() {
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<div className="relative h-full">
|
||||
{!isDetachedWindow && (
|
||||
{!hideHeaderBar && (
|
||||
<FocusTrap
|
||||
paused={disableVideoFocusTrap}
|
||||
focusTrapOptions={{
|
||||
@@ -1008,10 +1022,10 @@ export default function KvmIdRoute() {
|
||||
|
||||
<div
|
||||
className={cx("grid h-full select-none", {
|
||||
"grid-rows-(--grid-headerBody)": !isDetachedWindow && !hideHeaderBar,
|
||||
"grid-rows-(--grid-headerBody)": !hideHeaderBar,
|
||||
})}
|
||||
>
|
||||
{!isDetachedWindow && !hideHeaderBar && (
|
||||
{!hideHeaderBar && (
|
||||
<DashboardNavbar
|
||||
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
|
||||
showConnectionStatus={true}
|
||||
@@ -1027,7 +1041,7 @@ export default function KvmIdRoute() {
|
||||
{isFailsafeMode && failsafeReason === "video" ? null : (
|
||||
<WebRTCVideo
|
||||
hasConnectionIssues={!!ConnectionStatusElement}
|
||||
isDetachedWindow={isDetachedWindow}
|
||||
hideStatusBar={hideStatusBar}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user