From 99b73ca5a789fff3960c9487e00cba086f74c1ba Mon Sep 17 00:00:00 2001 From: rayha Date: Tue, 17 Mar 2026 16:58:53 +0700 Subject: [PATCH 1/3] feat: add wake lock to keep screen on during file transfer - Add useWakeLock composable for Screen Wake Lock API - Integrate wake lock in SessionDialog for active transfers - Wrap SessionDialog in ClientOnly for SSR compatibility --- app/components/dialog/SessionDialog.vue | 23 ++++ app/composables/useWakeLock.ts | 166 ++++++++++++++++++++++++ app/pages/index.vue | 4 +- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 app/composables/useWakeLock.ts diff --git a/app/components/dialog/SessionDialog.vue b/app/components/dialog/SessionDialog.vue index c0d9e92..6fefa42 100644 --- a/app/components/dialog/SessionDialog.vue +++ b/app/components/dialog/SessionDialog.vue @@ -33,6 +33,29 @@ 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..05f4431 --- /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, + } +} diff --git a/app/pages/index.vue b/app/pages/index.vue index 42a04c6..28d9492 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -72,7 +72,9 @@ - + + + From 173abe6cb5e9a43ca39b4d653e4ef07e2fd77d19 Mon Sep 17 00:00:00 2001 From: rayha Date: Tue, 17 Mar 2026 17:14:23 +0700 Subject: [PATCH 2/3] refactor: remove ClientOnly wrapper for SessionDialog SSR safety handled by useWakeLock composable's browser checks --- app/pages/index.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/pages/index.vue b/app/pages/index.vue index 28d9492..42a04c6 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -72,9 +72,7 @@ - - - + From 218d990ae9175025ab1800d61576f53fc7760f13 Mon Sep 17 00:00:00 2001 From: rayha Date: Tue, 17 Mar 2026 17:40:22 +0700 Subject: [PATCH 3/3] style: reformat code using pnpm run format --- app/components/dialog/SessionDialog.vue | 19 ++-- app/composables/useWakeLock.ts | 110 ++++++++++++------------ 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/app/components/dialog/SessionDialog.vue b/app/components/dialog/SessionDialog.vue index 6fefa42..3d523e4 100644 --- a/app/components/dialog/SessionDialog.vue +++ b/app/components/dialog/SessionDialog.vue @@ -35,26 +35,29 @@ 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() +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(); } -}) +}); // 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() + 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 index 05f4431..b7ef507 100644 --- a/app/composables/useWakeLock.ts +++ b/app/composables/useWakeLock.ts @@ -7,31 +7,31 @@ // Declare locally for environments where WakeLockSentinel is absent from DOM lib interface WakeLockSentinelLike extends EventTarget { - readonly released: boolean - readonly type: "screen" - release(): Promise + readonly released: boolean; + readonly type: "screen"; + release(): Promise; } type NavigatorWithWakeLock = Navigator & { - wakeLock: { request(type: "screen"): Promise } -} + wakeLock: { request(type: "screen"): Promise }; +}; export function useWakeLock() { // Internal state — properly scoped within composable - const wakeLock = ref(null) - const error = ref(null) + 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 + 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 - }) + return typeof navigator !== "undefined" && "wakeLock" in navigator; + }); /** * Acquire wake lock to prevent screen from sleeping. @@ -39,128 +39,128 @@ export function useWakeLock() { */ const acquire = async (): Promise => { if (!isSupported.value) { - console.warn("[WakeLock] Not supported in this browser") - return false + console.warn("[WakeLock] Not supported in this browser"); + return false; } // Already holding a wake lock if (wakeLock.value) { - return true + return true; } // Prevent concurrent acquire calls — a previous request is still in-flight if (acquiring) { - return false + return false; } - acquiring = true - releaseWhileAcquiring = false + acquiring = true; + releaseWhileAcquiring = false; try { const newLock = await ( navigator as NavigatorWithWakeLock - ).wakeLock.request("screen") + ).wakeLock.request("screen"); // release() was called while we were awaiting the OS grant — honour it if (releaseWhileAcquiring) { - releaseWhileAcquiring = false - await newLock.release() - return false + releaseWhileAcquiring = false; + await newLock.release(); + return false; } - wakeLock.value = newLock + wakeLock.value = newLock; wakeLock.value.addEventListener( "release", () => { // Null the ref first so acquire() guard works correctly on retry - wakeLock.value = null + 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 + wasActive = true; // If the page is already visible, retry immediately if ( typeof document !== "undefined" && document.visibilityState === "visible" ) { acquire().then((success) => { - if (success) wasActive = false - }) + if (success) wasActive = false; + }); } } }, { once: true }, - ) + ); - error.value = null - return 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 + error.value = err instanceof Error ? err.message : "Unknown error"; + console.warn("[WakeLock] Failed to acquire:", err); + return false; } finally { - acquiring = false + 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 + wasActive = false; if (acquiring) { // An acquire() call is in-flight; signal it to release once granted - releaseWhileAcquiring = true - return + releaseWhileAcquiring = true; + return; } if (!wakeLock.value) { - return + return; } - intentionalRelease = true + intentionalRelease = true; try { - await wakeLock.value.release() + await wakeLock.value.release(); // wakeLock.value is nullified by the 'release' event listener above } catch (err) { - console.warn("[WakeLock] Failed to release:", err) + console.warn("[WakeLock] Failed to release:", err); } finally { - intentionalRelease = false + 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() + const success = await acquire(); if (success) { - wasActive = false + 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) - }) + if (!isSupported.value) return; + document.addEventListener("visibilitychange", handleVisibilityChange); + }); onUnmounted(async () => { // onUnmounted only fires on the client. - document.removeEventListener("visibilitychange", handleVisibilityChange) - await release() - }) + document.removeEventListener("visibilitychange", handleVisibilityChange); + await release(); + }); return { isSupported, error: readonly(error), acquire, release, - } + }; }