fuzzyworld

north-star / commit

added wake lock, added toast logic

f495a654df95cc7e4f9cd3077c1a1f96f0800ac8 · Pavel Safronov · 2026-03-16 11:21:29 -0700 · parents b4e7f682f461

added wake lock, added toast logic
 src/app.ts                  |  17 +++++---
 src/compass.ts              |   6 +++
 src/index.html              |   2 +
 src/qr.ts                   |   2 +-
 src/style.css               |  59 +++++++++++++++++++++++++
 src/toast.ts                |  48 +++++++++++++++++++++
 src/wake-lock-controller.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++
 src/wakelock.ts             |  54 +++++++++++++++++++++--
 8 files changed, 280 insertions(+), 11 deletions(-)

diff --git a/src/app.ts b/src/app.ts
index c06d70b..d802cc3 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -2,7 +2,7 @@ import { get, ref, remove, set, serverTimestamp } from 'firebase/database';
 import { getReadableTextColor, getArrowDisplayColor } from './utils';
 import { encryptJson } from './crypto';
 import { initDarkMode } from './darkmode';
-import { createWakeLockManager } from './wakelock';
+import { initWakeLockController } from './wake-lock-controller';
 import { initFirebaseSession } from './firebase';
 import { initSession } from './session';
 import { parseRoomFromHash } from './room';
@@ -22,6 +22,7 @@ const roomCurrent = document.getElementById('room-current');
 const inviteRoomQrList = document.getElementById('invite-room-qr-list');
 const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
 const darkModeToggle = document.getElementById('dark-mode-toggle');
+const wakeLockToggle = document.getElementById('wake-lock-toggle');
 const cleanModeToggle = document.getElementById('clean-mode-toggle');
 const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null;
 const roomLinks = document.getElementById('room-links');
@@ -60,7 +61,11 @@ let startInProgress = false;
 let previousRemoteFriendCount = 0;
 
 initDarkMode(darkModeToggle);
-const { requestWakeLock } = createWakeLockManager();
+const wakeLockController = initWakeLockController({
+    wakeLockToggle,
+    isSessionStarted: () => sessionStarted,
+    isFullscreen: () => document.body.classList.contains('clean-mode')
+});
 
 const setCleanMode = (enabled: boolean) => {
     document.body.classList.toggle('clean-mode', enabled);
@@ -69,6 +74,8 @@ const setCleanMode = (enabled: boolean) => {
         cleanModeToggle.setAttribute('aria-pressed', String(enabled));
         cleanModeToggle.setAttribute('aria-label', enabled ? 'Back to full mode' : 'Enter fullscreen mode');
     }
+
+    wakeLockController.syncWakeLockPreference({ announce: true, source: 'fullscreen' });
 };
 
 if (cleanModeToggle instanceof HTMLButtonElement) {
@@ -317,7 +324,7 @@ const startSession = async () => {
         localStorage.setItem(LAST_ROOM_HASH_STORAGE_KEY, location.hash);
         if (startRoomButton) startRoomButton.classList.add('is-hidden');
         if (resumeRoomButton) resumeRoomButton.classList.add('is-hidden');
-        void requestWakeLock();
+        wakeLockController.syncWakeLockPreference();
         requestRender();
     } catch (err) {
         console.error('Failed to start session', err);
@@ -370,9 +377,7 @@ window.addEventListener('hashchange', () => {
 document.addEventListener('visibilitychange', () => {
     if (document.visibilityState !== 'visible') return;
     compassController.scheduleCompassProbe();
-    if (sessionStarted) {
-        void requestWakeLock();
-    }
+    wakeLockController.syncWakeLockPreference();
 });
 
 const render = () => {
diff --git a/src/compass.ts b/src/compass.ts
index 7dcfd4b..db2fe32 100644
--- a/src/compass.ts
+++ b/src/compass.ts
@@ -1,5 +1,6 @@
 import { normalizeAngle, normalizeSignedAngle } from './utils';
 import { PUBLIC_SETTINGS } from './public-settings';
+import { createToast } from './toast';
 
 type CompassControllerOptions = {
     calibrateHeadingLink: HTMLElement | null;
@@ -17,6 +18,7 @@ export const initCompassController = ({
     requestRender
 }: CompassControllerOptions) => {
     const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
+    const compassToast = createToast({ className: 'wake-lock-toast' });
     const deviceOrientationCtor =
         typeof DeviceOrientationEvent !== 'undefined'
             ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<CompassPermissionState> })
@@ -239,6 +241,7 @@ export const initCompassController = ({
                         title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.',
                         isError: true
                     });
+                    compassToast.show('Compass permission denied. Enable Motion & Orientation Access in browser settings.');
                     return;
                 }
 
@@ -251,6 +254,7 @@ export const initCompassController = ({
                     title: 'Compass access is active. You can now calibrate north.',
                     durationMs: 1500
                 });
+                compassToast.show('Compass enabled. You can calibrate north now.');
                 return;
             }
 
@@ -262,6 +266,7 @@ export const initCompassController = ({
                     isError: status.isError,
                     durationMs: status.isError ? 0 : 1500
                 });
+                compassToast.show(status.label);
                 return;
             }
 
@@ -275,6 +280,7 @@ export const initCompassController = ({
                 title: 'Calibration applied.',
                 durationMs: 1500
             });
+            compassToast.show('North calibrated.');
             requestRender();
         });
     }
diff --git a/src/index.html b/src/index.html
index cbd1e99..b0ae869 100644
--- a/src/index.html
+++ b/src/index.html
@@ -39,6 +39,8 @@
             <input id="self-color-input" name="selfColor" type="color" />
             <button id="dark-mode-toggle" class="dark-mode-toggle" type="button"
                 aria-label="Toggle dark mode">🌙</button>
+            <button id="wake-lock-toggle" class="wake-lock-toggle" type="button" aria-pressed="false"
+                aria-label="Keep screen awake">💤</button>
             <button id="clean-mode-toggle" class="clean-mode-toggle" type="button"
                 aria-pressed="false">Fullscreen</button>
         </div>
diff --git a/src/qr.ts b/src/qr.ts
index 31fdbfc..9db8edf 100644
--- a/src/qr.ts
+++ b/src/qr.ts
@@ -105,7 +105,7 @@ export const renderInviteQrCard = async ({
 
     const collapsedLabel = document.createElement('p');
     collapsedLabel.className = 'room-qr-collapsed-label';
-    collapsedLabel.textContent = 'QR Code';
+    collapsedLabel.textContent = 'Invite Code';
     card.appendChild(collapsedLabel);
 
     const toggleCollapsed = () => {
diff --git a/src/style.css b/src/style.css
index 20aa40c..4fbc8be 100644
--- a/src/style.css
+++ b/src/style.css
@@ -280,6 +280,24 @@ body.dark .app-icon {
     cursor: pointer;
 }
 
+.wake-lock-toggle {
+    width: 44px;
+    flex: 0 0 auto;
+    min-width: 44px;
+    height: 44px;
+    padding: 2px;
+    border: 1px solid #64748b;
+    border-radius: 10px;
+    background: rgba(248, 250, 252, 0.96);
+    font-size: 22px;
+    line-height: 1;
+    cursor: pointer;
+}
+
+.wake-lock-toggle.is-on {
+    box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.3);
+}
+
 .clean-mode-toggle {
     flex: 0 0 auto;
     min-width: 72px;
@@ -299,6 +317,15 @@ body.dark .dark-mode-toggle {
     border-color: #475569;
 }
 
+body.dark .wake-lock-toggle {
+    background: #0f172a;
+    border-color: #475569;
+}
+
+body.dark .wake-lock-toggle.is-on {
+    box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.4);
+}
+
 body.dark .clean-mode-toggle {
     background: #0f172a;
     border-color: #475569;
@@ -367,6 +394,32 @@ body.clean-mode .arrow {
     display: none !important;
 }
 
+.wake-lock-toast {
+    position: fixed;
+    left: 50%;
+    bottom: max(14px, env(safe-area-inset-bottom));
+    transform: translate(-50%, 18px);
+    z-index: 40;
+    max-width: min(92vw, 420px);
+    padding: 10px 14px;
+    border: 1px solid #cbd5e1;
+    border-radius: 12px;
+    background: rgba(255, 255, 255, 0.96);
+    color: #0f172a;
+    font-size: 13px;
+    font-weight: 700;
+    text-align: center;
+    box-shadow: 0 12px 28px rgba(15, 23, 42, 0.22);
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 180ms ease, transform 180ms ease;
+}
+
+.wake-lock-toast.is-visible {
+    opacity: 1;
+    transform: translate(-50%, 0);
+}
+
 #lobby-buttons {
     position: absolute;
     top: 50%;
@@ -710,6 +763,12 @@ body.dark .room-current {
     color: #94a3b8;
 }
 
+body.dark .wake-lock-toast {
+    background: rgba(30, 41, 59, 0.97);
+    border-color: #475569;
+    color: #f1f5f9;
+}
+
 body.dark .room-qr-card {
     background: #1e293b;
     border-color: #334155;
diff --git a/src/toast.ts b/src/toast.ts
new file mode 100644
index 0000000..d2935ae
--- /dev/null
+++ b/src/toast.ts
@@ -0,0 +1,48 @@
+type ToastOptions = {
+    className?: string;
+    durationMs?: number;
+    role?: 'status' | 'alert';
+    ariaLive?: 'polite' | 'assertive';
+};
+
+export const createToast = ({
+    className = 'toast',
+    durationMs = 2200,
+    role = 'status',
+    ariaLive = 'polite'
+}: ToastOptions = {}) => {
+    let hideTimer: number | null = null;
+
+    const toast = document.createElement('div');
+    toast.className = className;
+    toast.setAttribute('role', role);
+    toast.setAttribute('aria-live', ariaLive);
+    document.body.appendChild(toast);
+
+    const show = (message: string) => {
+        toast.textContent = message;
+        toast.classList.add('is-visible');
+
+        if (hideTimer !== null) {
+            window.clearTimeout(hideTimer);
+        }
+
+        hideTimer = window.setTimeout(() => {
+            toast.classList.remove('is-visible');
+            hideTimer = null;
+        }, durationMs);
+    };
+
+    const destroy = () => {
+        if (hideTimer !== null) {
+            window.clearTimeout(hideTimer);
+            hideTimer = null;
+        }
+        toast.remove();
+    };
+
+    return {
+        show,
+        destroy
+    };
+};
diff --git a/src/wake-lock-controller.ts b/src/wake-lock-controller.ts
new file mode 100644
index 0000000..0ba00bb
--- /dev/null
+++ b/src/wake-lock-controller.ts
@@ -0,0 +1,103 @@
+import { createWakeLockManager } from './wakelock';
+import { createToast } from './toast';
+
+type WakeLockControllerOptions = {
+    wakeLockToggle: HTMLElement | null;
+    isSessionStarted: () => boolean;
+    isFullscreen: () => boolean;
+};
+
+const WAKE_LOCK_ENABLED_STORAGE_KEY = 'friend-compass-wake-lock-enabled';
+
+export const initWakeLockController = ({
+    wakeLockToggle,
+    isSessionStarted,
+    isFullscreen
+}: WakeLockControllerOptions) => {
+    const {
+        setWakeLockEnabled,
+        hasWakeLockSupport
+    } = createWakeLockManager();
+
+    let wakeLockEnabledByToggle = localStorage.getItem(WAKE_LOCK_ENABLED_STORAGE_KEY) === '1';
+    const wakeLockToast = createToast({ className: 'wake-lock-toast' });
+
+    const getEffectiveWakeLockEnabled = () => isSessionStarted() && (isFullscreen() || wakeLockEnabledByToggle);
+
+    const updateWakeLockToggleUi = () => {
+        if (!(wakeLockToggle instanceof HTMLButtonElement)) return;
+
+        const supported = hasWakeLockSupport();
+        const forcedByFullscreen = isFullscreen();
+        const enabled = forcedByFullscreen || wakeLockEnabledByToggle;
+
+        wakeLockToggle.disabled = !supported;
+        wakeLockToggle.textContent = enabled ? '🔆' : '💤';
+        wakeLockToggle.classList.toggle('is-on', enabled);
+        wakeLockToggle.setAttribute('aria-pressed', String(enabled));
+
+        if (!supported) {
+            wakeLockToggle.title = 'Wake lock is not supported on this browser/device.';
+            wakeLockToggle.setAttribute('aria-label', 'Wake lock unsupported');
+            return;
+        }
+
+        if (forcedByFullscreen) {
+            wakeLockToggle.title = 'Stay awake is enabled automatically in fullscreen mode.';
+            wakeLockToggle.setAttribute('aria-label', 'Stay awake enabled in fullscreen');
+            return;
+        }
+
+        wakeLockToggle.title = enabled ? 'Stay awake is on. Tap to allow sleeping.' : 'Stay awake is off. Tap to keep screen awake.';
+        wakeLockToggle.setAttribute('aria-label', enabled ? 'Turn stay awake off' : 'Turn stay awake on');
+    };
+
+    const syncWakeLockPreference = ({
+        announce = false,
+        source = 'toggle'
+    }: {
+        announce?: boolean;
+        source?: 'toggle' | 'fullscreen';
+    } = {}) => {
+        const shouldStayAwake = getEffectiveWakeLockEnabled();
+        void setWakeLockEnabled(shouldStayAwake);
+        updateWakeLockToggleUi();
+
+        if (!announce) return;
+        if (!hasWakeLockSupport()) {
+            wakeLockToast.show('Stay awake is not supported on this browser.');
+            return;
+        }
+
+        if (source === 'toggle') {
+            if (shouldStayAwake) {
+                wakeLockToast.show(isSessionStarted() ? 'Stay awake is on.' : 'Stay awake enabled for when you start the session.');
+                return;
+            }
+            wakeLockToast.show('Stay awake is off.');
+            return;
+        }
+
+        if (isFullscreen()) {
+            wakeLockToast.show(isSessionStarted() ? 'Fullscreen keeps your screen awake.' : 'Fullscreen will keep your screen awake once the session starts.');
+            return;
+        }
+
+        wakeLockToast.show(shouldStayAwake ? 'Exited fullscreen. Stay awake is on.' : 'Exited fullscreen. Stay awake is off.');
+    };
+
+    if (wakeLockToggle instanceof HTMLButtonElement) {
+        wakeLockToggle.addEventListener('click', () => {
+            wakeLockEnabledByToggle = !wakeLockEnabledByToggle;
+            localStorage.setItem(WAKE_LOCK_ENABLED_STORAGE_KEY, wakeLockEnabledByToggle ? '1' : '0');
+            syncWakeLockPreference({ announce: true, source: 'toggle' });
+        });
+    }
+
+    updateWakeLockToggleUi();
+
+    return {
+        syncWakeLockPreference,
+        updateWakeLockToggleUi
+    };
+};
diff --git a/src/wakelock.ts b/src/wakelock.ts
index a0c606f..5901839 100644
--- a/src/wakelock.ts
+++ b/src/wakelock.ts
@@ -1,17 +1,48 @@
 export const createWakeLockManager = () => {
-    let wakeLockSentinel: any = null;
+    type WakeLockSentinelLike = {
+        release: () => Promise<void>;
+        addEventListener: (type: 'release', listener: () => void) => void;
+    };
+
+    let wakeLockSentinel: WakeLockSentinelLike | null = null;
     const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
+    let wakeLockRequested = false;
+
+    const getWakeLockApi = () => (navigator as Navigator & {
+        wakeLock?: { request: (type: 'screen') => Promise<WakeLockSentinelLike> }
+    }).wakeLock;
+
+    const releaseWakeLock = async () => {
+        if (!wakeLockSentinel) return;
+
+        try {
+            await wakeLockSentinel.release();
+        } catch (err) {
+            console.debug('Wake lock release failed', err);
+        } finally {
+            wakeLockSentinel = null;
+        }
+    };
 
     const requestWakeLock = async () => {
         if (!hasWakeLockSupport) return;
+        if (!wakeLockRequested) return;
         if (document.visibilityState !== 'visible') return;
         if (wakeLockSentinel) return;
 
         try {
-            wakeLockSentinel = await (navigator as any).wakeLock.request('screen');
+            const nextSentinel = await getWakeLockApi()?.request('screen');
+            if (!nextSentinel) return;
+
+            if (!wakeLockRequested || document.visibilityState !== 'visible') {
+                await nextSentinel.release();
+                return;
+            }
+
+            wakeLockSentinel = nextSentinel;
             wakeLockSentinel.addEventListener('release', () => {
                 wakeLockSentinel = null;
-                if (document.visibilityState === 'visible') {
+                if (wakeLockRequested && document.visibilityState === 'visible') {
                     void requestWakeLock();
                 }
             });
@@ -20,5 +51,20 @@ export const createWakeLockManager = () => {
         }
     };
 
-    return { requestWakeLock };
+    const setWakeLockEnabled = async (enabled: boolean) => {
+        wakeLockRequested = enabled;
+        if (!enabled) {
+            await releaseWakeLock();
+            return;
+        }
+
+        await requestWakeLock();
+    };
+
+    return {
+        requestWakeLock,
+        setWakeLockEnabled,
+        hasWakeLockSupport: () => hasWakeLockSupport,
+        isWakeLockEnabled: () => wakeLockRequested
+    };
 };