SceneExplorer WebUI 開発記 その2 ― シャッフル再生と、「ボタンが出ない」の正体

みなさん、こんにちは。

先日、動画管理アプリ SceneExplorer にWebUIを後付けした話を書きました。

その後、LAN内のWindows PCやスマホ、タブレットから問題なく使えることを確認できていたのですが、しばらく使っているうちに「動画を1本1本選ぶんじゃなくて、シャッフル再生したい」という欲が出てきました。音楽プレイヤーのランダム再生の感覚で、手元の動画をVGM的に流しっぱなしにしたい、というやつです。

今回はこの機能追加の過程で遭遇した、地味だが厄介な2つの問題について書きたいと思います。結論から言うと、どちらも「テストコードだと一見動いているように見えるが、実機で試すと破綻する」パターンで、表面的な動作確認だけでは気づけなかった類のものでした。


環境の補足

前回からの変更点として、今回は自宅のラズベリーパイ(正確にはRaspbian BusterがインストールされたRaspberry Pi 3B+)でもWebUIを動かしたくなり、feature/webui(Node.js 22系)とは別に webui-node12(Node.js 12.17固定)というブランチを用意しています。

参考:
Node22用 ⇒ https://github.com/taoman26/SceneExplorer/tree/feature/webui
Node12用 ⇒ https://github.com/taoman26/SceneExplorer/tree/webui-node12
なお、両ブランチとも本記事の修正内容が既に適用されています。

Node 12は node:sqlite が無いので better-sqlite3 に、Express 5も使えないので Express 4 に、という具合に依存関係を落とした別実装です。この2系統を並行してメンテする前提で、今回の話を進めます。


シャッフル再生を実装する

Claude Codeと相談しながら、機能そのものは素直に形にしました。

  • ヘッダーに「シャッフル再生」ボタンを追加
  • 今表示中の絞り込み結果全体(フォルダ/タグ/検索条件に一致する全件。表示中の1ページだけではない)からランダムな順で再生するキューを作成
  • 動画詳細モーダルを「シャッフル再生中」バッジ付きのモードで開き、自動再生・再生終了で次の動画へ自動的に進行
  • 動画が見つからない・再生できない場合は自動でスキップ。絞り込み範囲が全滅している場合は無限ループせず諦めてメッセージを出力

絞り込み結果全体からシャッフルするには、通常の動画一覧API(並び替え・ページング付きでフルの動画情報を返す、やや重いAPI)とは別に、「条件に一致するIDだけを返す」軽量なAPIを新設しました。

GET /api/videos/ids?dir=&tag=&untagged=1&missing=1&q=
→ { "ids": [12, 88, 3, 45, ...] }   // 並び替え・ページングなし、最大20000件

これをクライアント側でFisher–Yatesシャッフルし、再生が終わるたびに次のIDへ進め、キューを使い切ったら再シャッフルして続けます。自動テスト4件を追加し、ダミーの動画ライブラリ(動画120本)でブラウザ操作も確認し、ここまでは順調に開発が進みました。


つまずいた点1. Node22では出るのにNode12では出ないボタン

両ブランチのシャッフル機能を実機で確認したところ、以下の不具合に遭遇しました。

Node 22の方はシャッフルボタンが表示された。Node 12の方はシャッフルボタンが表示されない

原因はすぐに想像がつきました。webui-node12 はNode 12自身ではViteのビルドができない(Node 20.19以上が必要)ため、別マシンでビルドしたクライアントの dist/ をコピーして使う運用にしていたからです。起動スクリプト(start.sh)は「dist/index.html が無ければビルドする」という条件だったため、過去にビルド済みの dist/ が残っていると、サーバー側のコードを更新しても古いJSが配信され続けるという、典型的な「キャッシュの闇」でした。

対策として、ビルド時にコミットハッシュを書き込む仕組みを入れました。

// webui/client/write-build-info.mjs(npm run build の postbuild で自動実行)
const commit = execSync('git rev-parse HEAD').toString().trim();
writeFileSync('dist/BUILD_INFO.txt', `${commit}\n${new Date().toISOString()}\n`);

サーバー起動時にこれを読んで、今動いているサーバー自身のコミットと比較します。

Web client : built from 77d35b6 at 2026-10-10T06:17:45.577Z        ← 一致(正常)
WARNING: web client (8da353f, ...) does not match the running server (74cccb3).   ← 不一致(警告)

順当な修正のつもりだったのですが、実装中に気づいてしまいました。「ビルド済みdistをコミットする」というやり方そのものと、このハッシュ比較の仕組みが根本的に相性が悪いということに。

コミットは自分自身のハッシュを知らない

考えてみれば当たり前の話で、git commit でオブジェクトが作られる前に npm run build は完了しています。つまり BUILD_INFO.txt に書き込めるのは、そのコミットの「親」のハッシュまでです。「ビルド済みdistをコミットする」というコミット自身は、絶対に自分のハッシュを埋め込めません。

これで何が困るかというと、client/dist を直接コミットするたびに、コミット直後から毎回「古いビルドです」という誤検知が発生するということです。せっかく「不一致を検知する仕組み」を作ったのに、その仕組み自体が常に誤報を出す、という本末転倒な状態になってしまいました。

修正 – ハッシュの完全一致ではなく、履歴ベースで判定する

ハッシュが違うこと自体は「古い」の証拠になりません。本当に知りたいのは「ビルドした時点から現在までの間に、クライアントのソースが実際に変更されたかどうか」です。そこで判定ロジックを書き換えました。

// ハッシュが一致しなければ、ビルド元コミットから現在までの間に
// クライアントのソース(dist/自身は除く)に変更があったかをgit logで確認する
git(['merge-base', '--is-ancestor', builtCommit, currentCommit]);  // 祖先関係を確認
const changed = git(['log', '--oneline', `${builtCommit}..${currentCommit}`,
                      '--', 'webui/client', ':!webui/client/dist']);
const stale = changed.length > 0;

dist だけをコミットした(=ソースに変更なし)コミットなら stale: false、その後に本当にクライアントのソースを変更したコミットが来れば stale: true になります。これをユニットテスト8件で固めてから、実際にこのリポジトリの履歴に対しても試し、さらにわざと「distだけのコミット」を作って正しく「最新」と判定されることを一時的なコミットで検証しました。

ここまで直してようやく、webui-node12 ブランチに client/dist を直接コミットする運用に踏み切れました。Pi側のワークフローは git pull && ./start.sh だけになり、別マシンでビルドしてコピーする手間がなくなりました。


つまずいた点2. シャッフル再生なのに、最初の1本しか最大化されない

ボタンの表示が直ったあと、次の要望を追加しました。

シャッフル再生時、動画詳細画面が出るのは構わないけれど、動画の再生自体は最大化して再生するようにしてほしい

既存の「全画面再生」ボタンと同じ仕組み(video.requestFullscreen())を、シャッフルで動画が始まるたびに自動で呼ぶ機能が素朴に実装されました。

ところが実機(Chrome)で試すと、1本目は確かに全画面になるのに、自動で次の動画に進んだ瞬間に全画面が解除され、2本目以降は戻らないという現象が起きました。

原因はブラウザの仕様でした。requestFullscreen() は「ユーザーの直接操作(クリックなど)」がないと呼び出せません。シャッフルの自動送りは動画の ended イベントがトリガーであり、これはユーザー操作ではないため、ブラウザに黙って却下されます。実際にPlaywrightで時間を計測して確認しました。

video #1 id: 10 | fullscreenElement set: True      ← ボタンクリック直後、成功
auto-advanced to video #2 after 6.7s                ← 8秒の動画が自然に終わって自動送り
video #2: fullscreenElement set: False              ← ユーザー操作なしの全画面化は拒否される

同じ要素を使い回す

最初はdocument全体を全画面にする案が提案されましたが、それだとモーダルのUI(サムネイル帯やタグ編集欄)までまるごと画面いっぱいに映ってしまい、「動画の再生自体を最大化してほしい」という要望とはズレてしまいます。

次の提案は「全画面を要求するタイミングを工夫すれば通るのでは」という方向でしたが、これはブラウザのユーザー操作要件(トランジェント・アクティベーション)の仕様上、根本的に無理筋でした。

正解は別のところにありました。SceneExplorerのWebUIでは、動画が切り替わるたびに <video> 要素を key={video.id} で作り直していました。全画面中の要素がDOMから消えると、ブラウザは自動的に全画面を解除します。つまり毎回の自動送りで「全画面を解除してから、権限のない全画面リクエストを送って失敗する」を繰り返していたことになります。

修正は、シャッフル中は <video> 要素を使い回し、src だけ差し替えるというスタンスにしました。

// シャッフル中は key を固定し、動画が切り替わっても <video> 要素そのものは作り直さない
<video key={shuffling ? 'shuffle-player' : video.id} ref={player} ... src={api.streamUrl(video.id)} />

あわせて、動画の読み込み中に一瞬 video state を null に戻していた処理も削りました。ここをnullにすると、<video> を囲っている親要素ごとDOMから外れてしまい、keyを固定していても意味がなくなる(アンマウント=全画面解除のトリガーになる)ためです。

この2点を直したところ、最初の1本だけブラウザのユーザー操作を使って全画面に入り、以降は要素ごと生き続けているので全画面が自然に継続するようになりました。これは実はよくある動画プレイリストサイトの挙動と同じ仕組みで、「次の動画に進んでも全画面が解けない」のは偶然ではなく、同じ <video> 要素の中身を差し替えているからなんだな、と身をもって理解しました。

再度Playwrightで確認します。

video #1: fullscreen: True
auto-advanced to video #2 after 6.7s | fullscreen: True | same element: True
auto-advanced AGAIN to video #3 after 7.5s | fullscreen still: True

2回目・3回目の自動送りでも全画面が維持されることを確認できました。ラズベリーパイ側(実機のNode 12.17)でも同様に確認できています。

なお、本物の全画面中はブラウザの仕様上、カスタムUI(バッジや「次の動画へ」ボタン、閉じるボタン)はすべて動画の下に隠れて操作できなくなります。これはYouTubeなど他のサイトの全画面動画でも同じ挙動なので、バグではなく仕様として扱っています。


遭遇した問題と対応

No症状原因対応
1Node 12版だけ新機能のボタンが出ないNode 12自身はViteをビルドできず、古い dist を使い回す構造だったビルド元コミットを dist に刻み、起動時に突き合わせて警告。最終的に dist 自体をブランチにコミットする運用に変更
2その警告機能自体が、distだけコミットした直後に誤報を出すコミットは自分自身のハッシュを埋め込めない(ビルドは常にコミット前に行われる)ハッシュの完全一致ではなく、ビルド元から現在までの間に実際にクライアントのソースが変わったかを git log で判定
3シャッフル再生で1本目しか全画面にならないrequestFullscreen() はユーザー操作が必要。自動送り(ended)はユーザー操作ではなく拒否される<video> 要素を切り替えのたびに作り直すのをやめ、同じ要素のまま src だけ差し替えることで、1本目の全画面状態を後続の動画にも引き継がせる

3つとも「表面上は動いているように見えて、特定の条件(別ブランチでの運用、連続コミット、自動送り)で初めて壊れる」タイプの問題でした。特に2番目は、良かれと思って作った検知機構が自分自身の前提を壊すという、ちょっと面白い構造のバグだったなと思います。


理想の動画視聴体験に近づいた

SceneExplorerがぐっとメディアサーバーらしくなりました。シャッフル再生と全画面維持は、Windows機・ラズベリーパイの両方で実機確認が取れています。理想の動画視聴体験にまた一歩近づきました。しばらくこれで運用してみようと思います。

本日も最後までお読みいただきありがとうございました。

それでは、よい動画管理ライフを!


「今回の記事を読んで、自分も何か作ってみたい」と思われた方、そのアイデア、頭の中で寝かせておくのはもったいないかもしれません。

ビューローみかみでは、構想段階の壁打ちからPoC・実装・現場導入まで、現場で「使い続けられる」ものづくりを支援しています。

アイデアを短期間で「動くもの」にし、PoCで終わらせず、実運用まで伴走します。まずは「これ、作る価値ありますか?」という壁打ちからでも大歓迎です。

▶ PoC・MVP開発サービスの詳細はこちら

気になることがあればお気軽にご相談ください。

カテゴリ: Raspberry Pi, Tips

コメントする

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

上部へスクロール