Keep screen on via Wake Lock (Feature 15) #151
@@ -312,6 +312,13 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keepOnHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
||||||
A drawn at the size it stands for. */
|
A drawn at the size it stands for. */
|
||||||
.readingHeader .textSize {
|
.readingHeader .textSize {
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ const textSizes: SegmentedOption<TextSize>[] = [
|
|||||||
// is planned for, its title, Keep screen on and the text size.
|
// is planned for, its title, Keep screen on and the text size.
|
||||||
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
||||||
const keepOnId = useId()
|
const keepOnId = useId()
|
||||||
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
|
const keepOnHintId = useId()
|
||||||
|
const keepScreenOn = useKeepScreenOn()
|
||||||
const { failure } = textSize
|
const { failure } = textSize
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -338,7 +339,13 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.keepOn}>
|
<div className={styles.keepOn}>
|
||||||
<span id={keepOnId}>Keep screen on</span>
|
<span id={keepOnId}>Keep screen on</span>
|
||||||
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
|
<Toggle
|
||||||
|
checked={keepScreenOn.on}
|
||||||
|
onCheckedChange={keepScreenOn.setOn}
|
||||||
|
aria-labelledby={keepOnId}
|
||||||
|
disabled={!keepScreenOn.supported}
|
||||||
|
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Segmented
|
<Segmented
|
||||||
label="Text size"
|
label="Text size"
|
||||||
@@ -348,6 +355,16 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
|
|||||||
className={styles.textSize}
|
className={styles.textSize}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
{!keepScreenOn.supported && (
|
||||||
|
<p id={keepOnHintId} className={styles.keepOnHint}>
|
||||||
|
This browser can't keep the screen on, so it may dim while you cook.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{keepScreenOn.failure !== null && (
|
||||||
|
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
|
||||||
|
Couldn't keep the screen on: {keepScreenOn.failure}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
{failure !== null && (
|
{failure !== null && (
|
||||||
<ErrorBanner onRetry={textSize.retry}>
|
<ErrorBanner onRetry={textSize.retry}>
|
||||||
{textSizeFailureText[failure.action]}: {failure.message}
|
{textSizeFailureText[failure.action]}: {failure.message}
|
||||||
|
|||||||
@@ -116,6 +116,45 @@ function currentStep() {
|
|||||||
return current[0]!.textContent
|
return current[0]!.textContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
|
||||||
|
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
|
||||||
|
|
||||||
|
// letGo releases a lock as the browser does, telling its listeners so.
|
||||||
|
function letGo(sentinel: FakeSentinel) {
|
||||||
|
if (sentinel.released) return
|
||||||
|
sentinel.released = true
|
||||||
|
sentinel.dispatchEvent(new Event('release'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
|
||||||
|
// is granted at once, or, given hold, only when the test calls grant.
|
||||||
|
function stubWakeLock({ hold = false } = {}) {
|
||||||
|
const sentinels: FakeSentinel[] = []
|
||||||
|
const pending: (() => void)[] = []
|
||||||
|
const request = vi.fn((type: string) => {
|
||||||
|
expect(type).toBe('screen')
|
||||||
|
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
|
||||||
|
released: false,
|
||||||
|
release: vi.fn(() => {
|
||||||
|
letGo(sentinel)
|
||||||
|
return Promise.resolve()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
sentinels.push(sentinel)
|
||||||
|
if (!hold) return Promise.resolve(sentinel)
|
||||||
|
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
|
||||||
|
})
|
||||||
|
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
|
||||||
|
return { request, sentinels, grant: () => pending.shift()!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVisibility shows or hides the page as the browser would, telling the
|
||||||
|
// page so.
|
||||||
|
function setVisibility(state: DocumentVisibilityState) {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockViewport(1180)
|
mockViewport(1180)
|
||||||
// Wednesday Oct 7, noon in New York.
|
// Wednesday Oct 7, noon in New York.
|
||||||
@@ -126,11 +165,14 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
|
Reflect.deleteProperty(navigator, 'wakeLock')
|
||||||
|
Reflect.deleteProperty(document, 'visibilityState')
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Kitchen reading mode', () => {
|
describe('Kitchen reading mode', () => {
|
||||||
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
||||||
|
stubWakeLock()
|
||||||
serve()
|
serve()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|
||||||
@@ -379,4 +421,154 @@ describe('Kitchen reading mode', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Keep screen on', () => {
|
||||||
|
it('holds a screen wake lock while on and releases it when turned off', async () => {
|
||||||
|
const wakeLock = stubWakeLock()
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
expect(keepOn).toBeEnabled()
|
||||||
|
expect(wakeLock.request).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||||
|
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// On again: a fresh lock.
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||||
|
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases the lock when the screen is left', async () => {
|
||||||
|
const wakeLock = stubWakeLock()
|
||||||
|
serve()
|
||||||
|
const view = await renderPage()
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases a lock granted only after the screen was left', async () => {
|
||||||
|
const wakeLock = stubWakeLock({ hold: true })
|
||||||
|
serve()
|
||||||
|
const view = await renderPage()
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await act(async () => wakeLock.grant())
|
||||||
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
|
||||||
|
const wakeLock = stubWakeLock()
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
// Hidden, the browser lets the lock go; nothing is asked while hidden.
|
||||||
|
act(() => setVisibility('hidden'))
|
||||||
|
act(() => letGo(wakeLock.sentinels[0]!))
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
act(() => setVisibility('visible'))
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
// Visible again with the lock still held: no second lock.
|
||||||
|
act(() => setVisibility('visible'))
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
act(() => setVisibility('hidden'))
|
||||||
|
act(() => setVisibility('visible'))
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
|
||||||
|
const wakeLock = stubWakeLock()
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
act(() => letGo(wakeLock.sentinels[0]!))
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||||
|
await act(async () => {})
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
// The fresh lock is watched too.
|
||||||
|
act(() => letGo(wakeLock.sentinels[1]!))
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
|
||||||
|
expect(wakeLock.sentinels[2]!.released).toBe(false)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
|
||||||
|
const wakeLock = stubWakeLock({ hold: true })
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Hidden and shown again before the first lock is granted, which the
|
||||||
|
// browser has let go by the time it is.
|
||||||
|
act(() => setVisibility('hidden'))
|
||||||
|
act(() => setVisibility('visible'))
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||||
|
letGo(wakeLock.sentinels[0]!)
|
||||||
|
await act(async () => wakeLock.grant())
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await act(async () => wakeLock.grant())
|
||||||
|
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||||
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a lock the browser refuses and turns the switch back off', async () => {
|
||||||
|
const wakeLock = stubWakeLock()
|
||||||
|
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
|
||||||
|
)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
|
||||||
|
serve()
|
||||||
|
await renderPage()
|
||||||
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||||
|
expect(keepOn).toBeDisabled()
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(keepOn).toHaveAccessibleDescription(
|
||||||
|
"This browser can't keep the screen on, so it may dim while you cook.",
|
||||||
|
)
|
||||||
|
fireEvent.click(keepOn)
|
||||||
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,86 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
|
// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is
|
||||||
// is on, and a setter. It starts off on each visit.
|
// on, setOn, whether this browser can keep the screen on at all, and why the
|
||||||
|
// screen could not be kept on, if it could not.
|
||||||
|
export type KeepScreenOn = {
|
||||||
|
on: boolean
|
||||||
|
setOn: (on: boolean) => void
|
||||||
|
supported: boolean
|
||||||
|
failure: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only
|
||||||
|
// then. It starts off on each visit. The browser lets the lock go whenever
|
||||||
|
// the page is hidden, so it is asked for again each time the page is visible
|
||||||
|
// while the switch is on. Turning the switch off or leaving the screen
|
||||||
|
// releases it, including a lock still being granted as the screen is left.
|
||||||
//
|
//
|
||||||
// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
|
// A browser without the Screen Wake Lock API (or a page not served over
|
||||||
// the switch's state; nothing keeps the screen awake yet.
|
// HTTPS) cannot keep the screen on: supported is false and the switch must
|
||||||
export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
|
// not be offered as working. A request the browser refuses turns the switch
|
||||||
return useState(false)
|
// back off and is reported as failure; turning it on again asks again.
|
||||||
|
export function useKeepScreenOn(): KeepScreenOn {
|
||||||
|
const [on, setOnState] = useState(false)
|
||||||
|
const [failure, setFailure] = useState<string | null>(null)
|
||||||
|
const supported = 'wakeLock' in navigator
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!on) return
|
||||||
|
const wakeLock = navigator.wakeLock
|
||||||
|
let sentinel: WakeLockSentinel | null = null
|
||||||
|
let requesting = false
|
||||||
|
let left = false
|
||||||
|
|
||||||
|
// The browser may let a lock go while the switch is on: always when the
|
||||||
|
// page is hidden, and, the specification allows, while it is still
|
||||||
|
// visible. Either way the lock is asked for again at once if the page is
|
||||||
|
// visible, or when it is visible again if not, so the switch never claims
|
||||||
|
// a lock that is not held.
|
||||||
|
function onRelease() {
|
||||||
|
if (!left) acquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquire() {
|
||||||
|
if (document.visibilityState !== 'visible' || requesting) return
|
||||||
|
if (sentinel !== null && !sentinel.released) return
|
||||||
|
requesting = true
|
||||||
|
wakeLock.request('screen').then(
|
||||||
|
(granted) => {
|
||||||
|
requesting = false
|
||||||
|
if (left) {
|
||||||
|
void granted.release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sentinel = granted
|
||||||
|
granted.addEventListener('release', onRelease)
|
||||||
|
// While the request was pending the page may have been hidden and
|
||||||
|
// shown again (that visibilitychange was ignored) and the lock let
|
||||||
|
// go before it could be listened for, so check again now.
|
||||||
|
acquire()
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
requesting = false
|
||||||
|
if (left) return
|
||||||
|
setFailure(String(err))
|
||||||
|
setOnState(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire()
|
||||||
|
document.addEventListener('visibilitychange', acquire)
|
||||||
|
return () => {
|
||||||
|
left = true
|
||||||
|
document.removeEventListener('visibilitychange', acquire)
|
||||||
|
if (sentinel !== null) void sentinel.release()
|
||||||
|
}
|
||||||
|
}, [on])
|
||||||
|
|
||||||
|
function setOn(next: boolean) {
|
||||||
|
if (next) setFailure(null)
|
||||||
|
setOnState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { on, setOn, supported, failure }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user