ReactとWake Lock APIで画面を暗くさせない方法

​​React​Playwright,Screen Wake Lock API,TypeScript,useEffect

navigator.wakeLock.request('screen') を呼ぶと、ページを表示しているあいだ、画面が暗くなったりロックされたりしないようにブラウザに頼めます。レシピを見ながら料理をするときや、QRコードを読み取ってもらうときのように、画面に触らずに見続ける場面で使います。端末がしばらく操作しないと画面をスリープさせる設定になっていても、そのページを表示しているあいだはスリープさせずにおけます。

React では、オン・オフを useEffect に任せ、後片付けで手放すように書きます。ただし、この Wake Lock はタブを切り替えると自動で解除され、戻ってきても元には戻りません。この記事では、タブに戻ったら取り直すフックを作り、許可がないときや、取得を待っているあいだにオフにしたときの動きも Chromium で確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153(headless)です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode を付けた開発用ビルドで動かしました。確かめたのは API の動き(取得・解除・例外)で、実際の画面が暗くならないかどうかは、画面のない検証環境では確かめられません。Firefox と Safari、スマートフォンの実機、本番用ビルド、サーバーでの描画(SSR)、取得の結果が届く順番が入れ替わる場合は試していません。

request() で取り、release() で手放す

navigator.wakeLock.request('screen') は Promise を返し、取得できると WakeLockSentinel が返ります(MDN の WakeLock.request() の説明)。手放すときは、この WakeLockSentinel の release() を呼びます。解除されると released が true になり、release イベントが届きます(MDN の WakeLockSentinel の説明)。

ts
const sentinel = await navigator.wakeLock.request('screen');
// ……画面を暗くしたくない作業……
await sentinel.release();

一度解除された WakeLockSentinel は、もう使えません。もう一度オンにするときは、request() を呼び直して新しく取ります(MDN の Screen Wake Lock API の概要)。

型は TypeScript の lib の dom に入っているので、型定義のパッケージを足さなくても、tsc の型チェックが通りました。

タブに戻ったら取り直すフック

作ったフックは次のとおりです。enabled が true のあいだだけ Wake Lock を持ち、いま持っているか(active)と、取れなかったときの理由(error)を返します。

ts
import { useEffect, useState } from 'react';

// サーバーで描くときに navigator がなくても落ちないようにする確認
const supported = typeof navigator !== 'undefined' && 'wakeLock' in navigator;

export function useWakeLock(enabled: boolean) {
  const [active, setActive] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!enabled || !supported) return;
    let sentinel: WakeLockSentinel | null = null;
    let cancelled = false;

    const acquire = async () => {
      // 隠れているページからは取れないので、見えているときだけ頼む
      if (document.visibilityState !== 'visible') return;
      if (sentinel && !sentinel.released) return;
      try {
        const s = await navigator.wakeLock.request('screen');
        // 待っているあいだにオフにされた、隠れて解除された、
        // 別の acquire() が先に取った、のどれかなら、これは使わずに手放す
        if (cancelled || s.released || (sentinel && !sentinel.released)) {
          await s.release();
          return;
        }
        sentinel = s;
        setActive(true);
        setError(null);
        // 自分で手放したときも、ブラウザに解除されたときも呼ばれる
        s.addEventListener('release', () => setActive(false));
      } catch (e) {
        setError(e instanceof Error ? `${e.name}: ${e.message}` : String(e));
      }
    };

    // タブに戻ってきたら取り直す
    const onVisibilityChange = () => {
      if (document.visibilityState === 'visible') void acquire();
    };

    void acquire();
    document.addEventListener('visibilitychange', onVisibilityChange);
    return () => {
      cancelled = true;
      document.removeEventListener('visibilitychange', onVisibilityChange);
      void sentinel?.release();
    };
  }, [enabled]);

  return { supported, active, error };
}

使う側は、チェックボックスの状態を enabled として渡し、返ってきた値で説明の文を変えます。

tsx
import { useState } from 'react';
import { useWakeLock } from './useWakeLock';

type Props = { defaultEnabled?: boolean };

export function KeepAwakeToggle({ defaultEnabled = false }: Props) {
  const [enabled, setEnabled] = useState(defaultEnabled);
  const { supported, active, error } = useWakeLock(enabled);

  let message = '画面は通常どおり暗くなります';
  if (!supported) message = 'このブラウザでは使えません';
  else if (enabled && error) message = `オンにできませんでした(${error})`;
  else if (enabled && active) message = '画面を暗くしないようにしています';
  else if (enabled) message = '画面を暗くしない設定は、いまは止まっています';

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={enabled}
          disabled={!supported}
          onChange={(e) => setEnabled(e.target.checked)}
        />
        調理中は画面を暗くしない
      </label>
      <p role="status">{message}</p>
    </div>
  );
}
チェックボックス「調理中は画面を暗くしない」にチェックが入り、その下に「画面を暗くしないようにしています」と表示されている
幅375pxの画面で、Chromiumでオンにしたときの表示

検証では、request() を包んで、取った数と解除された数を数えました。チェックを入れると取った数が1になり、外すと解除された数も1になりました。オン・オフを3回くり返しても、同時に持っていた数はいつも1つまでで、最後は0に戻りました。

タブを切り替えると解除され、戻っても元には戻らない

別のタブを開くと、ページの document.visibilityState が hidden になり、Wake Lock は自動で解除されました。release イベントは、visibilitychange イベントより先に届きました。画面の表示は「画面を暗くしない設定は、いまは止まっています」に変わりました。

隠れているあいだに request() を呼ぶと、次の例外になりました。

text
NotAllowedError: Failed to execute 'request' on 'WakeLock': The requesting page is not visible

MDN にも、見えているページだけが Wake Lock を取れること、ページが隠れると解除されることが書かれています。そのため、フックでは visibilitychange を受け取り、visible に戻ったときに acquire() を呼び直しています(MDN の Screen Wake Lock API の概要にある、取り直しの例)。元のタブに戻すと、新しい Wake Lock が取れて、表示も「画面を暗くしないようにしています」に戻りました。切り替えと戻りをもう1回くり返しても、同じように解除と取り直しが起きました。

なお、Playwright で別のタブを前に出しても、元のページの visibilityState は visible のままで、Wake Lock も解除されませんでした。タブの切り替えを試すときは、Chromium を直接起動し、Chrome DevTools Protocol でタブを切り替えます。

StrictMode では、取得を待つあいだのオフに備える

開発用ビルドの StrictMode では、React がエフェクトの準備と後片付けを1回余分に実行します(公式リファレンスの StrictMode の説明)。最初からオンで表示すると、1回目の request() の結果が届く前に後片付けが走り、2回目の request() が呼ばれます。

request() は Promise なので、後片付けの時点では、まだ sentinel に何も入っていません。そこで、後片付けで cancelled を true にしておき、あとから届いた Wake Lock はすぐに手放します。同じ if では、待っているあいだにタブが隠れて解除されていた場合(s.released)と、別の acquire() が先に取っていた場合にも備えて、届いた Wake Lock を使わずに手放すようにしています。最初からオンで表示したときの数は、次のとおりでした。

取った数 解除された数 同時に持っていた最大の数
表示した直後 2 1 1
オフにしたあと 2 2 1

比べるため、この if とコメントの6行を消して同じことをすると、表示した直後に2つの Wake Lock を同時に持っていました。オフにしても1つしか解除されず、画面には「画面は通常どおり暗くなります」と出ているのに、1つは持ったままでした。

取った数 解除された数 同時に持っていた最大の数
表示した直後 2 0 2
オフにしたあと 2 1 2

チェックボックスを続けて2回クリックし、取得を待っているあいだにオフにした場合も、取った分はすべて解除されました。

取れないときは NotAllowedError になり、メッセージで理由が分かる

Wake Lock が取れないときは、request() が NotAllowedError で失敗します。MDN には、Permissions Policy で禁止されているとき、ページが表示されていないとき、電池の残りが少ないなどで端末が取れないときが挙がっています(MDN の WakeLock.request() の例外の説明)。検証環境では、次の2つの場合を試しました。

条件 返ってきたメッセージ
headless の Chromium で、許可を与えずに開く Wake Lock permission request denied
ページに Permissions-Policy: screen-wake-lock=() を付けて配信する Failed to execute 'request' on 'WakeLock': Access to Screen Wake Lock features is disallowed by permissions policy

許可を与えずに開いたときは、navigator.permissions.query({ name: 'screen-wake-lock' }) の結果が prompt でした。Playwright では、context.grantPermissions(['screen-wake-lock']) で許可を与えると取れるようになりました。

Permissions Policy の既定では、ほかのオリジンの iframe からは使えません。使わせるには、Permissions-Policy ヘッダーでそのオリジンを許可し、iframe に allow="screen-wake-lock" を付けます(MDN の Screen Wake Lock API の概要にあるセキュリティの説明)。

安全なコンテキストでないページには navigator.wakeLock がない

Wake Lock API は、安全なコンテキストでしか使えません。HTTPS のページのほか、http://localhost や http://127.0.0.1 のページも安全なコンテキストになります。http://example.com のような、それ以外のホストを http:// で開いたページはなりません(MDN の安全なコンテキストの説明)。検証環境では、Chromium の起動オプション(--host-resolver-rules)で example.com を 127.0.0.1 に向け、http://example.com:8080/ を開くと、isSecureContext が false になり、'wakeLock' in navigator も false でした。フックの supported が false になるので、チェックボックスは押せなくなり、「このブラウザでは使えません」と表示されました。

開発中の PC の http://127.0.0.1 で動いていても、スマートフォンから PC の IP アドレスを http:// で開くと、そのページは安全なコンテキストになりません。スマートフォンで試すときは、HTTPS で配信してから開きます。

MDN の互換性の表では、Chrome 84、Firefox 126、Safari 16.4 から使えるとされています。iOS の Safari は 18.4 からで、16.4〜18.3 は、ホーム画面に追加した Web アプリでは動かないとされています(MDN の Screen Wake Lock API の互換性の表)。

MDN の Screen Wake Lock API の概要では、Wake Lock が効いているかどうかを画面に表示し、利用者がオフにできるようにすることが勧められています。この記事のチェックボックスと説明の文は、その2つを1か所で済ませる形にしました。

​Posted by 86