diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css index cff901a..425564e 100644 --- a/web/src/dinners/DinnerDetailPage.module.css +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -312,6 +312,13 @@ 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 A drawn at the size it stands for. */ .readingHeader .textSize { diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx index ab21eb0..79b1928 100644 --- a/web/src/dinners/DinnerDetailPage.tsx +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -326,7 +326,8 @@ const textSizes: SegmentedOption[] = [ // is planned for, its title, Keep screen on and the text size. function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) { const keepOnId = useId() - const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn() + const keepOnHintId = useId() + const keepScreenOn = useKeepScreenOn() const { failure } = textSize return ( <> @@ -338,7 +339,13 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
Keep screen on - +
+ {!keepScreenOn.supported && ( +

+ This browser can't keep the screen on, so it may dim while you cook. +

+ )} + {keepScreenOn.failure !== null && ( + keepScreenOn.setOn(true)}> + Couldn't keep the screen on: {keepScreenOn.failure} + + )} {failure !== null && ( {textSizeFailureText[failure.action]}: {failure.message} diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx index 4d53796..314034e 100644 --- a/web/src/dinners/KitchenReading.test.tsx +++ b/web/src/dinners/KitchenReading.test.tsx @@ -116,6 +116,38 @@ function currentStep() { return current[0]!.textContent } +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +type FakeSentinel = { released: boolean; release: Mock<() => Promise> } + +// 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 = { + released: false, + release: vi.fn(() => { + sentinel.released = true + return Promise.resolve() + }), + } + sentinels.push(sentinel) + if (!hold) return Promise.resolve(sentinel) + return new Promise((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(() => { mockViewport(1180) // Wednesday Oct 7, noon in New York. @@ -126,11 +158,14 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers() Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(navigator, 'wakeLock') + Reflect.deleteProperty(document, 'visibilityState') vi.unstubAllGlobals() }) describe('Kitchen reading mode', () => { it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => { + stubWakeLock() serve() await renderPage() @@ -379,4 +414,111 @@ describe('Kitchen reading mode', () => { expect(screen.queryByRole('button', { name: 'Next step' })).not.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. + wakeLock.sentinels[0]!.released = true + act(() => setVisibility('hidden')) + 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('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') + }) + }) }) diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts index a221399..a6907fe 100644 --- a/web/src/dinners/keepScreenOn.ts +++ b/web/src/dinners/keepScreenOn.ts @@ -1,10 +1,69 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' -// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it -// is on, and a setter. It starts off on each visit. +// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is +// 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 -// the switch's state; nothing keeps the screen awake yet. -export function useKeepScreenOn(): [boolean, (on: boolean) => void] { - return useState(false) +// A browser without the Screen Wake Lock API (or a page not served over +// HTTPS) cannot keep the screen on: supported is false and the switch must +// not be offered as working. A request the browser refuses turns the switch +// 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(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 + + 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() + else sentinel = granted + }, + (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 } }