openvisengine/src/lib/ava/wake-lock.ts
2026-11-05 22:25:24 +00:00

128 lines
4.5 KiB
TypeScript
Executable file

// OVE — Screen Wake Lock manager (v7.11).
//
// Mobile browsers dim and eventually turn the screen off after a few seconds
// of inactivity — correct for reading, fatal for a music visualizer propped
// on a table. While audio is actually playing (demo / mic / file / link —
// anything but idle) the app holds a screen wake lock so the visuals stay
// visible; the moment playback stops the lock is released and the phone's
// normal inactivity timeout takes over again.
//
// API notes (developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API):
// • navigator.wakeLock.request('screen') → Promise<WakeLockSentinel>
// • The browser SILENTLY releases the sentinel when the page becomes
// hidden (app switch / screen lock) — so a 'visibilitychange' listener
// re-acquires it when the visitor comes back and music is still going.
// • request() rejects when unsupported (older iOS Safari, Firefox Android
// pre-126), when the document is hidden, or when a platform setting
// refuses — every rejection is swallowed: the lock is a nice-to-have,
// never a blocker.
// • Wake locks are free — no battery drain beyond the screen itself, and
// the page already renders at full tilt while playing.
/** Minimal structural type — lib.dom versions vary in Wake Lock coverage. */
interface WakeLockSentinelLike {
readonly released: boolean
release: () => Promise<void>
addEventListener: (type: 'release', listener: () => void) => void
removeEventListener: (type: 'release', listener: () => void) => void
}
interface WakeLockLike {
request: (type: 'screen') => Promise<WakeLockSentinelLike>
}
/** The held sentinel (null = not held / browser released it). */
let sentinel: WakeLockSentinelLike | null = null
/** Whether a lock is currently WANTED (music playing). */
let wantActive = false
/** Guards against overlapping request() calls. */
let requesting = false
/** The sentinel's own release listener (kept so it can be detached). */
let releaseListener: (() => void) | null = null
function navigatorWakeLock(): WakeLockLike | null {
if (typeof navigator === 'undefined') return null
const nav = navigator as Navigator & { wakeLock?: unknown }
const wl = nav.wakeLock
if (!wl || typeof wl !== 'object') return null
const typed = wl as Partial<WakeLockLike>
return typeof typed.request === 'function' ? (typed as WakeLockLike) : null
}
/** Drop our reference when the browser auto-releases (page hidden). */
function onSentinelRelease(): void {
sentinel = null
releaseListener = null
}
async function acquire(): Promise<void> {
if (sentinel !== null || requesting) return
const wl = navigatorWakeLock()
if (!wl) return
requesting = true
try {
const s = await wl.request('screen')
// state may have flipped to "stopped" while the promise was in flight
if (!wantActive) {
try {
await s.release()
} catch {
/* already gone */
}
return
}
sentinel = s
releaseListener = onSentinelRelease
s.addEventListener('release', releaseListener)
} catch {
/* unsupported / hidden / refused — degrade silently */
} finally {
requesting = false
}
}
function dropSentinel(): void {
const s = sentinel
sentinel = null
if (s) {
if (releaseListener) s.removeEventListener('release', releaseListener)
releaseListener = null
s.release().catch(() => {
/* already released */
})
}
}
/**
* Hold or release the screen wake lock according to playback. Called with
* `true` whenever a source starts, `false` whenever the engine goes idle.
*/
export function syncWakeLock(active: boolean): void {
wantActive = active
if (!active) {
dropSentinel()
return
}
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void acquire()
}
// hidden right now (rare — playback needs a gesture, but a queued playlist
// could land here): the visibilitychange listener below acquires on return.
}
/** Explicit teardown (hook unmount / pagehide). */
export function releaseWakeLock(): void {
syncWakeLock(false)
}
/**
* Re-acquire the lock when the page becomes visible again while music is
* still playing (the browser always drops it on hide). Returns a cleanup.
*/
export function watchWakeLockVisibility(): () => void {
const onVisibility = () => {
if (document.visibilityState === 'visible' && wantActive) void acquire()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}