diff --git a/app/components/dialog/SessionDialog.vue b/app/components/dialog/SessionDialog.vue index c0d9e92..3d523e4 100644 --- a/app/components/dialog/SessionDialog.vue +++ b/app/components/dialog/SessionDialog.vue @@ -33,6 +33,32 @@ import { formatBytes } from "~/utils/fileSize"; const { t } = useI18n(); +// Wake lock: keep screen on during file transfer +// useWakeLock registers its own onUnmounted cleanup, so we don't need one here. +const { acquire, release } = useWakeLock(); + +onMounted(() => { + // Check if session is already active (e.g., page refresh during transfer) + if (store.session.state !== SessionState.idle) { + acquire(); + } +}); + +// Acquire/release wake lock when transfer starts or ends +watch( + () => store.session.state, + (newState, oldState) => { + if (newState !== SessionState.idle && oldState === SessionState.idle) { + acquire(); + } else if ( + newState === SessionState.idle && + oldState !== SessionState.idle + ) { + release(); + } + }, +); + const totalCurr = computed(() => { return formatBytes(store.session.curr); }); diff --git a/app/composables/useWakeLock.ts b/app/composables/useWakeLock.ts new file mode 100644 index 0000000..b7ef507 --- /dev/null +++ b/app/composables/useWakeLock.ts @@ -0,0 +1,166 @@ +/** + * Composable for managing screen wake lock during file transfer + * Keeps the screen on while user is actively transferring files + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API + */ + +// Declare locally for environments where WakeLockSentinel is absent from DOM lib +interface WakeLockSentinelLike extends EventTarget { + readonly released: boolean; + readonly type: "screen"; + release(): Promise; +} + +type NavigatorWithWakeLock = Navigator & { + wakeLock: { request(type: "screen"): Promise }; +}; + +export function useWakeLock() { + // Internal state — properly scoped within composable + const wakeLock = ref(null); + const error = ref(null); + + // Plain variables: not reactive — only used for internal control flow + let wasActive = false; + let intentionalRelease = false; + let acquiring = false; + let releaseWhileAcquiring = false; + + // computed() is correct here: navigator is undefined on SSR, defined on client. + // The value never changes after hydration, but computed() provides SSR safety. + const isSupported = computed(() => { + return typeof navigator !== "undefined" && "wakeLock" in navigator; + }); + + /** + * Acquire wake lock to prevent screen from sleeping. + * Returns true if the lock was successfully acquired. + */ + const acquire = async (): Promise => { + if (!isSupported.value) { + console.warn("[WakeLock] Not supported in this browser"); + return false; + } + + // Already holding a wake lock + if (wakeLock.value) { + return true; + } + + // Prevent concurrent acquire calls — a previous request is still in-flight + if (acquiring) { + return false; + } + + acquiring = true; + releaseWhileAcquiring = false; + + try { + const newLock = await ( + navigator as NavigatorWithWakeLock + ).wakeLock.request("screen"); + + // release() was called while we were awaiting the OS grant — honour it + if (releaseWhileAcquiring) { + releaseWhileAcquiring = false; + await newLock.release(); + return false; + } + + wakeLock.value = newLock; + wakeLock.value.addEventListener( + "release", + () => { + // Null the ref first so acquire() guard works correctly on retry + wakeLock.value = null; + if (!intentionalRelease) { + // Flag re-acquire intent for next visibility change. + // Covers both page-hidden OS releases and system policy releases + // (e.g. low-battery) while the page is still visible. + wasActive = true; + // If the page is already visible, retry immediately + if ( + typeof document !== "undefined" && + document.visibilityState === "visible" + ) { + acquire().then((success) => { + if (success) wasActive = false; + }); + } + } + }, + { once: true }, + ); + + error.value = null; + return true; + } catch (err) { + error.value = err instanceof Error ? err.message : "Unknown error"; + console.warn("[WakeLock] Failed to acquire:", err); + return false; + } finally { + acquiring = false; + } + }; + + /** + * Release wake lock to allow screen to sleep normally. + */ + const release = async (): Promise => { + // Always clear intent first — even if the lock was already auto-released + wasActive = false; + + if (acquiring) { + // An acquire() call is in-flight; signal it to release once granted + releaseWhileAcquiring = true; + return; + } + + if (!wakeLock.value) { + return; + } + + intentionalRelease = true; + try { + await wakeLock.value.release(); + // wakeLock.value is nullified by the 'release' event listener above + } catch (err) { + console.warn("[WakeLock] Failed to release:", err); + } finally { + intentionalRelease = false; + } + }; + + const handleVisibilityChange = async () => { + if (document.visibilityState === "visible" && wasActive) { + // Re-acquire wake lock when page becomes visible again. + // If acquire fails, wasActive remains true so the next visibility + // change retries — no infinite loop since we wait for an OS event. + const success = await acquire(); + if (success) { + wasActive = false; + } + } + }; + + onMounted(() => { + // onMounted only fires on the client, never on SSR. + // The isSupported guard avoids attaching a dead listener on unsupported browsers. + if (!isSupported.value) return; + document.addEventListener("visibilitychange", handleVisibilityChange); + }); + + onUnmounted(async () => { + // onUnmounted only fires on the client. + document.removeEventListener("visibilitychange", handleVisibilityChange); + await release(); + }); + + return { + isSupported, + error: readonly(error), + acquire, + release, + }; +}