From 3b6d3a46310a73e5ebe2e0ae90dd97214898271d Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Fri, 26 Sep 2025 21:04:24 +0200 Subject: [PATCH] feat: Keep screen awake (#1788) * feat keepAwake hook * reaqure lock after view change * refactor: move it to a navigation view setting * save to search params --- apps/client/src/App.tsx | 2 + .../navigation-menu/NavigationMenu.tsx | 11 ++- .../src/features/keep-awake/KeepAwake.tsx | 84 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/features/keep-awake/KeepAwake.tsx diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c8a27fb8c..369e9a857 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla import { AppContextProvider } from './common/context/AppContext'; import { ontimeQueryClient } from './common/queryClient'; import { connectSocket } from './common/utils/socket'; +import KeepAwake from './features/keep-awake/KeepAwake'; import { TranslationProvider } from './translation/TranslationProvider'; import AppRouter from './AppRouter'; import { baseURI } from './externals'; @@ -24,6 +25,7 @@ function App() { + diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 1d7bbd553..6b3390dd5 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,10 +1,11 @@ import { memo } from 'react'; -import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; +import { IoClose, IoContract, IoExpand, IoEye, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { useLocation } from 'react-router'; import { Dialog } from '@base-ui-components/react/dialog'; import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { isLocalhost } from '../../../externals'; +import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake'; import { navigatorConstants } from '../../../viewerConfig'; import { useClientStore } from '../../stores/clientStore'; import { useViewOptionsStore } from '../../stores/viewOptions'; @@ -31,6 +32,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { const [isRenameOpen, handlers] = useDisclosure(false); const { fullscreen, toggle } = useFullscreen(); const { mirror, toggleMirror } = useViewOptionsStore(); + const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const location = useLocation(); return ( @@ -62,6 +64,13 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { {mirror && Active} + {window.isSecureContext && ( + + Keep Awake + + {keepAwake && Active} + + )} Rename Client
diff --git a/apps/client/src/features/keep-awake/KeepAwake.tsx b/apps/client/src/features/keep-awake/KeepAwake.tsx new file mode 100644 index 000000000..c8bb78da0 --- /dev/null +++ b/apps/client/src/features/keep-awake/KeepAwake.tsx @@ -0,0 +1,84 @@ +import { use, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; + +import { PresetContext } from '../../common/context/PresetContext'; + +/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */ +export default function KeepAwake() { + const { keepAwake } = useKeepAwakeOptions(); + const [wakeLockSentinel, setWakeLockSentinel] = useState(null); + + const removeLock = () => { + if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null)); + }; + + const acquireLock = () => { + if (!wakeLockSentinel || wakeLockSentinel.released) { + setWakeLockSentinel(null); + navigator.wakeLock + .request('screen') + .then((sentinel) => { + setWakeLockSentinel(sentinel); + }) + .catch(console.error); + } + }; + + useEffect(() => { + const controller = new AbortController(); + if (keepAwake) { + acquireLock(); + document.addEventListener( + 'visibilitychange', + () => { + if (wakeLockSentinel !== null && document.visibilityState === 'visible') { + acquireLock(); + } + }, + { signal: controller.signal }, + ); + } else { + removeLock(); + } + + return () => { + controller.abort(); + removeLock(); + }; + }, [keepAwake]); + + return <>; +} + +const keepAwakeKey = 'keep-awake'; + +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) { + // Helper to get value from either source, prioritizing defaultValues + return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey); +} + +/** + * Hook exposes the keep awake options + */ +export function useKeepAwakeOptions() { + const [searchParams, setSearchParams] = useSearchParams(); + const maybePreset = use(PresetContext); + + const keepAwake = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + + const toggleKeepAwake = useCallback(() => { + setSearchParams((searchParams) => { + if (keepAwake) { + searchParams.delete(keepAwakeKey); + } else { + searchParams.set(keepAwakeKey, '1'); + } + return searchParams; + }); + }, [keepAwake]); + + return { keepAwake, toggleKeepAwake }; +}