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 };
+}