north-star / commit
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 + }; };