# 同時視聴 — round 2

2人用の登録不要・合言葉付きチャット、音声通話、YouTube 同時視聴ツール。iPhone Safari を主対象にした縦持ち1カラム UI です。HTML / JavaScript / CSS のみで、ビルド、API キー、有料サービス、自前サーバー、Cloudflare Access は不要です。

## 納品・検証の到達点

4ファイルが実装一式です。本回答環境ではコマンド実行、Cloudflare への配備、既存サイト更新、PR 作成、2端末実測を行っていません。前回の動作報告は提供された情報であり、この改訂版の検証結果ではありません。配備前に `node --check app.js` と下記の実機試験を実行してください。iPhone の背面通話を確認済みとは主張しません。

## 配置

1. `index.html`、`app.js`、`style.css`、`README.md` を同じディレクトリ（リポジトリなら `apps/watchparty/`）へ置きます。
2. 独立した Cloudflare Pages プロジェクトに、このディレクトリの内容を直接配置します。ビルドコマンドなし。Git 連携の出力ディレクトリは `apps/watchparty`、そのディレクトリをルートにした場合は `.` です。
3. HTTPS の公開 URL を開きます。Access は掛けません。Quartz 等の既存サイトを変更する必要はありません。
4. `/astra/` などサブディレクトリにも配置可能です。招待 URL は現在の pathname を維持します。

外部スクリプトは指定の PeerJS 1.5.5（unpkg）と YouTube IFrame Player API だけです。PeerJS の公開接続サービスと Google の公開 STUN を利用します。TURN、Worker、npm、サービスワーカー、独自シグナリングは使用しません。`file://` は非対応です。PC の localhost で静的配信する試験は可能ですが、iPhone からのアクセスには HTTPS を用意してください。

## 新しい使い方

1. **部屋をつくる**：名前を入力します。合言葉は初期状態で96ビットのランダム値（24桁の16進数）を入力済み。変更できます。8文字未満なら入力直下に赤字が出て、下のボタンが「合言葉をあと○文字」となり無効になります。
2. **URL を送る**：下の大きい「URL を共有する」を押します。Web Share API 対応なら共有画面、それ以外はコピーします。コピー成功は画面下に通知します。
3. **合言葉は別に伝える**：「招待 URL・合言葉」で表示・コピーできます。URL とは別のメッセージ・連絡手段で伝えてください。共有 API へ渡すのは URL だけです。
4. **友達が入る**：友達が招待 URL を開き、名前と同じ合言葉を入力します。上部のバッジが「接続済み」になり、相手の名前が大きく出ます。3人目は入れません。
5. **通話に参加**：先にイヤホンをつなぎ、双方が押してマイクを許可します。マイク取得はこの操作の後だけです。自分の声のモニターは初期状態 ON、音量60%です。スピーカーではハウリングするので、必要なら「音声設定」で OFF にしてください。
6. **YouTube URL を貼る**：どちらかが URL または11文字の動画 ID をセットします。初期状態は停止。「再生」を押します。自動再生を拒否された端末では、表示された「タップして動画を再生・追随」を押してください。
7. 再生・一時停止・停止して先頭へ・±10秒・位置スライダー・速度が共有されます。動画の音声ミュートは自分だけです。通話なしでもチャットと動画を使えます。
8. チャットは自分が右、友達が左です。時刻と未達／到着マークを表示します。到着は相手端末の ACK であり、既読ではありません。
9. ABEMA は折りたたみを開き、各自の ABEMA を用意して5秒後の合図で手押しします。映像は送らず、停止・シークも同期しません。別画面でカウントが見えない場合は通話で合図してください。
10. 退出時は「再接続・退出」から退出します。マイク、接続、部屋復帰情報、メモリー内の履歴を破棄します。

手順の現在位置を強調し、済んだ手順にはチェックを付けます。上部には常時接続バッジ、下部には現在の主操作と動画・チャット・通話への移動を固定表示します。細かい機能は折りたたみです。配色は端末のライト／ダーク設定に追従し、入室後の「表示・画面の自動消灯」で固定もできます。初回ガイドは「わかりました」以降表示しません。

## 音声設定

### 入力

- `enumerateDevices()` でマイク一覧を表示します。許可前の名前非公開状態では「マイクを許可すると一覧が出ます」と案内します。
- 「マイクを許可して一覧を更新」も通話参加の操作です。単に機器を表示するためだけに、ページ表示時から勝手にマイクを取得することはありません。
- 通話中の入力変更は、新しい音声トラックを取得して `RTCRtpSender.replaceTrack()` で差し替えます。既存通話を意図的に切断・再発信しません。成功後に以前のマイクを止め、モニターも差し替えます。失敗時は以前のマイクを維持します。
- `echoCancellation: true`、ノイズ抑制、自動ゲイン制御を要求します。ブラウザや機器がすべてを適用する保証はありません。
- 機器変更イベントで一覧を更新します。保存した deviceId が許可後にも存在しなければ既定へ戻します。取得時にも NotFound / Overconstrained なら既定を試します。使用中のトラック自体が終了した場合は明示的な再参加を案内します。
- マイク切替中のミュート状態は維持します。ミュートは相手への送信と自分のモニターの両方を止めます。

### 出力

- `'setSinkId' in HTMLMediaElement.prototype` で判定します。対応環境では相手の声の audio とモニター用 audio の両方へ `setSinkId()` を適用します。
- 非対応でも出力欄を消さず、グレーアウトして「この端末では OS 側で切り替えます（コントロールセンター）」と表示します。
- 対応するブラウザでは `selectAudioOutput()` の許可・選択ボタンも出します。許可と利用可能な出力の範囲はブラウザ依存です。
- 変更に失敗した場合は以前の出力、それも不可なら既定を試し、エラーをその欄に表示します。
- この設定で YouTube IFrame 内の出力先は変更できません。YouTube の出力は OS 側で切り替えます。

### 自分の声のモニター

経路は `AudioContext → MediaStreamSource → AnalyserNode → GainNode → MediaStreamDestination → audio` です。Gain の値は0〜1、初期0.6。相手へ送るトラックはこの経路とは別で、モニター音量は相手への音量を変えません。MediaStreamDestination のストリームを再生する audio に出力先を適用できます。

AudioContext の作成・最初の resume は参加・設定変更等のユーザー操作から行います。OS が AudioContext を中断した場合は、前面でタップして有効にするボタンを出します。自動再生を回避する無音再生は行いません。モニターと相手の声は、それぞれ再生許可の拒否・一時停止を検出して再生ボタンを出します。

自分と相手にそれぞれ AnalyserNode を用意して音量を表示します。受信側の解析経路をスピーカーへ重ねて接続しないため、相手の声を二重再生しません。メーターは前面でだけ更新し、波形・音声は保存しません。メーターの数値は簡易 RMS 表示で、校正済み dB や可聴性の証明ではありません。

ループバックはページ開始時 ON、音量60%。イヤホン／スピーカーを確実に識別する API がないため、ON の間は常にハウリング警告を出します。エコーキャンセルがあってもハウリング防止は保証されません。Bluetooth の遅延、通話プロファイルによる音質変化、自分の声が遅れて聞こえる現象があります。

## 部屋の復帰と寿命

- 作成者だけ、部屋コード・合言葉・作成者の役割を sessionStorage に保持します。ニックネーム、チャット、動画の視聴状態は保存しません。
- **同じタブの同じ URL を再読み込み**すると「作成した部屋に戻る」と表示されます。名前を入力して再開すると、同じ部屋 ID を再登録します。以前の登録が公開接続サービスに残っている場合は5秒間隔で再試行します。
- 相手は自動で再接続します。再認証時に参加者のリビジョン下限と時計標本をリセットするため、再開した作成者のリビジョン0の状態を受け入れます。古い動画は停止し、新しく動画をセットし直します。
- マイクの自動取得はしません。再読み込みした本人は通話に再参加してください。チャット履歴・未達・重複抑制情報も復元されません。
- sessionStorage が拒否された場合は画面に警告します。その環境では作成者の再読み込み復帰はできません。
- 通常の別タブ・別端末には作成者権限を引き継ぎません。タブ複製・ブラウザのセッション復元では sessionStorage がコピー／復元される場合があります。同じ作成者を複数タブで開かないでください。使用中の ID を奪う処理はありません。
- タブ終了での sessionStorage の削除・復元はブラウザ仕様に従います。確実にこのアプリの復帰情報を消すには「部屋から退出」を使ってください。復帰情報を失ったら新しい部屋が必要です。
- 作成者が不在・凍結中は、新規入室や動画操作の確定ができません。永続サーバーや作成者の移譲機能はありません。

## 保持する骨格と設計

### 認証・接続

- 部屋コードは192ビットの乱数で URL のフラグメントに置きます。通常の HTTP リクエスト・Referer にフラグメントは含まれません。合言葉は URL に載せません。
- Peer ID は `watchparty-v1-` と部屋コードの組み合わせ。参加者はランダム ID。PeerJS 公開サーバーを使い、STUN を明示して TURN なしの構成にします。
- 信頼性のある WebRTC データ接続でチャット、相互確認、時計合わせ、動画状態を送ります。音声は1対1の WebRTC MediaConnection、映像トラックは要求しません。
- 部屋ごとの PBKDF2-HMAC-SHA-256（210,000回）で鍵を作り、双方の nonce、セッション ID、Peer ID、役割・名前を含む HMAC チャレンジ応答で合言葉を相互確認します。平文の合言葉や再利用可能な固定ハッシュは送信しません。
- 認証前にチャット・動画状態を配りません。着信音声は認証済み相手・セッションが一致し、本人が通話参加済みの場合だけ受けます。認証前候補4本、20秒タイムアウト、メッセージ長・キュー上限を設けます。認証済み相手は1人だけです。
- これは監査済み PAKE や本人確認ではありません。部屋コードを知る攻撃者の妨害、認証交換からの弱い合言葉の辞書攻撃を完全には防げません。初期生成のような長いランダム値を使ってください。
- 公開接続サービスの切断だけでは既存の直接接続を捨てず、再接続します。音声接続が正常な場合、データの心拍遅れだけで即座に通話を破棄しません。実際の接続失敗、相手の退出、手動再接続時は音声を再確立します。

### 時計と動画

- 作成者の時計と状態が基準。双方の操作を作成者が受理順に直列化し、リビジョンを増やします。同時操作は最後に受理されたものを優先します。
- `performance.timeOrigin + performance.now()` と4タイムスタンプの往復測定で時計差・RTT を推定。直近8標本の最短 RTT を採用し、接続・復帰時は4回のバースト、その後5秒ごとに測定します。
- 動画 ID、位置、再生状態、速度、時刻、リビジョンを共有。参加者の操作には連番・動画 ID を付け、重複・別動画への遅延操作を抑えます。
- 前面で目標時刻から位置を外挿し、約2秒ごとに補正。差が再生中0.85秒、停止中0.25秒を超えたらシークします。強制同期時の閾値は0.2秒。小差を毎回シークして音切れを増やす設計にはしません。
- IFrame の再生・停止・速度イベントと250msの位置観測で内部操作も拾います。同期反映後1.2秒はイベントを抑制し、反射ループを防ぎます。この間の内部操作や微小シークを取りこぼす場合があるので、アプリの共有ボタンを推奨します。
- YouTube が提供する速度だけを使い、独自の微小速度補正はしません。非対応速度は画面に案内します。
- 広告、バッファリング、ライブ/DVR、年齢・地域・ログイン制限、埋め込み禁止は厳密に同期できません。通常の公開 VOD が対象です。URL の開始時刻は取り込まず、再生リストは単一動画として扱います。
- 自動再生が拒否されたらタップを案内します。「位置だけ一致して停止している」状態を再生成功と表示しません。

### チャットと保存の範囲

- チャットにはランダム ID と ACK。未達を再送し、重複表示を抑制します。表示200件、未達100件、重複検出500件の上限で RAM のみ保持します。送信時刻を再送しても維持します。
- 相手の再読み込みで重複検出情報が消えた場合、ACK を失ったメッセージが再表示される可能性があります。永続的な exactly-once 配達ではありません。
- 受信文字列は textContent で表示します。HTML として解釈せず、URL も検証します。
- 録音・録画、チャット保存、ファイル出力、独自解析・ログ送信はありません。
- **round 2 で許可された保存の例外**：localStorage に入力・出力 deviceId、配色、初回ガイド既読フラグ。sessionStorage に作成者の部屋コード・合言葉・役割。ループバック ON/OFF と音量は保存せず、ページ開始時 ON / 60% に戻します。
- 合言葉は復帰のため sessionStorage に平文で保持されます。端末を共有する相手、同一オリジン上のスクリプト、配信元・外部依存を信頼する必要があります。招待欄から本人が明示的に表示・コピーできます。
- アプリはメール・電話番号等を要求しません。ただし外部の Cloudflare、PeerJS、STUN、YouTube、接続相手には IP 等の通信情報が届き得ます。外部サービスのログ・Cookie の扱いは本アプリの制御外です。

## iPhone・背面の制約

目標は**通話・チャットの継続**であり、YouTube の背面再生ではありません。

- 背面になったらこの端末の YouTube だけを停止し、共有の停止命令にはしません。マイク、通話の受信 audio、チャット接続は意図的に止めません。
- 前面復帰直後に保持状態で追随を試み、状態要求・時計合わせ・再接続を行います。自動再生拒否、回線復帰待ち、OS の中断では追加タップや待ち時間が必要です。
- 両者が背面でも共有の論理再生時刻は進みます。両者とも離席するなら、先に共有の一時停止を押してください。OS による時計・タイマー停止の影響は完全には除去できません。
- Screen Wake Lock を前面で取得し、復帰時に再取得します。非対応、省電力設定、ブラウザによる拒否、手動ロック、OS のタブ終了には効きません。電池消費が増えます。
- iPhone の背面・ロック中の通話の実可聴性、チャットの即時配達、複数 audio と YouTube の同時出力は OS・端末・イヤホン次第で未保証です。AudioContext だけ中断し、音声メーターや自分の声が止まることもあります。
- 音声が競合した場合は、まずループバック OFF、次に動画ミュートを試してください。通話継続が必要で本ツールでは続かない場合、通話だけ電話・LINE 等へ切り替えてください。
- OS がページを凍結するとチャット ACK、再接続、カウントダウンも止まります。復帰まで未達を待機し、タブ破棄時には失われます。
- 動画音声の抽出・別 audio への移送、無音の疑似再生、背面再生を狙う処理はありません。Web Push、PWA manifest、サービスワーカーは実装しません。

| 条件 | iPhone Safari | Android Chrome | PC ブラウザ |
|---|---|---|---|
| 前面・点灯 | この改訂版は実機未測定 | 未測定 | 未測定 |
| 別タブ／別アプリ | 音声・データを維持する実装。可聴性は要実測 | 同左。省電力・凍結の影響あり | 同左。タブ破棄・OS休止の影響あり |
| 手動ロック | Wake Lock は防止不可。継続未保証 | 継続未保証 | スリープ中は継続未保証 |
| ホーム画面追加 | 改善を約束しない。独自PWA対応なし | 同左 | 対象外 |
| YouTube 背面 | 実装で停止、復帰で追随 | 同左 | 同左 |
| 出力選択 | setSinkId 非対応なら OS で変更 | API の実検出と許可次第 | API の実検出と許可次第 |

過去の憲章にある「不可能」の一律断定より、後の裁定にある「通話継続の実可聴性は未確定」を採用します。パケット数や live トラックから、連続して聞こえたとは断定しません。

## 接続できない場合

1. 作成者が同じ部屋を開いているか確認。再読み込み後は作成者側で「この部屋を再開する」を押します。新しいタブや sessionStorage 喪失時は新しい部屋を作成してください。
2. 合言葉は NFC 正規化後の完全一致。大文字、小文字、空白を区別します。誤った場合は退出して招待 URL を開き直します。
3. 「再接続する」を試します。古い相手の接続の解消には時間が掛かることがあります。
4. Wi-Fi とモバイル回線を変更。TURN リレーがないため、対称 NAT、企業・学校のファイアウォール、UDP 制限等で接続できない場合があります。
5. 無料の公開接続サービスには障害・混雑・制限があり、可用性を保証できません。
6. YouTube エラー153では HTTPS、Referrer-Policy、拡張機能・広告ブロッカーを確認してください。この HTML は strict-origin-when-cross-origin を指定しています。

## 2端末試験

最初にイヤホンを用意してください。2タブを同じ端末で開いたループバック試験はハウリングしやすく、実際の2端末の代替にはなりません。

- [ ] `node --check app.js` を実行。開発者コンソールに構文・参照エラーがないことを確認。
- [ ] iPhone 縦持ちで名前空欄、合言葉3文字、空白だけを試す。入力直下の赤字・ボタン無効・不足文字数表示を確認。初期合言葉が生成済みであることも確認。
- [ ] 正常に部屋作成。手順チェック、固定バッジ、下部主操作、ライト／ダーク、フォーカス表示、入力中のキーボードとの干渉を確認。
- [ ] URL 共有／コピー／共有キャンセル／クリップボード拒否を試す。共有 URL に合言葉がなく、合言葉コピーと別操作であることを確認。
- [ ] B が誤った合言葉で拒否され、正しい合言葉で入室できること。C の3人目が入れないこと。
- [ ] 双方から日本語、改行、絵文字、HTML 風文字列を送る。左右・色・時刻・到着マークを確認。機内モード後の未達再送も確認。
- [ ] マイク許可前の案内、拒否時の音声欄エラー、許可後のデバイス名表示を確認。マイクを許可するまで取得しないこと。
- [ ] 初期ループバック ON / 60%、音量0/100%、OFF/ON、警告、双方のメーターを確認。音が聞こえたかを耳でも確認。
- [ ] 双方通話参加、ミュート、退出・再参加。通話中にマイクを切り替え、相手に新しいマイクの声が届き、通話が切れないことを確認。
- [ ] 出力変更が相手の声・自分のモニター両方へ適用されること。iPhone 等の非対応では欄が残り、無効・OS 操作案内となること。
- [ ] 機器の抜き差し、devicechange、一度保存した機器を外して再読み込み、選択の復元・既定へのフォールバックを確認。
- [ ] 公開 VOD で双方から再生・停止・30秒以上のシーク・±10秒・0.5/1/1.5/2倍を操作。各操作直後と1分後の実位置−目標を記録。初回再生拒否時のタップも確認。
- [ ] 作成者が再読み込みして、同じ URL・保存済み合言葉で再開。参加者の再認証、古い動画停止、新しい動画の同期、音声の再参加を確認。チャット履歴が復元されないこと。
- [ ] A が話し続け、B を背面30秒・ロック1分・5分。実際に B で聞こえ続けたか、時刻付きチャットが届いたかを記録。A/B を入れ替えて再試験。
- [ ] 前面復帰で動画追随、消灯防止の再取得、停止した音声モニターの有効化を確認。受信数は可聴性と別に記録。
- [ ] ABEMA のカウント表示差と、遅い復帰で古い「再生！」を再表示しないことを確認。
- [ ] 退出でマイク表示が消え、sessionStorage の部屋情報が消えること。録音・チャット等がストレージにないこと。

測定には日付・時刻（JST）、端末・OS・ブラウザ版、回線、イヤホンを付記してください。未測定を0秒や成功と扱わないでください。

| 時刻 JST | 端末・OS・回線 | 入室秒 | 往復 ms | 動画実位置−目標 秒 | 音声遅れの測り方と結果 | 背面の実可聴性 | 備考 |
|---|---|---:|---:|---:|---|---|---|
| 未実施 | 未測定 | — | — | — | 未測定 | 未測定 | 配備後に記入 |

## 一次資料の確認先

この回答環境では外部ページの取得・実読を実施していないため、取得日はすべて **未取得** です。最新の規約を確認済みと装うものではありません。配備時に照合してください。

- PeerJS API: https://peerjs.com/docs/
- YouTube IFrame Player API: https://developers.google.com/youtube/iframe_api_reference
- YouTube API Services Developer Policies: https://developers.google.com/youtube/terms/developer-policies
- ABEMA 利用規約: https://abema.tv/about/terms
- WebRTC: https://www.w3.org/TR/webrtc/
- Web Audio: https://www.w3.org/TR/webaudio/
- Audio Output Devices API: https://www.w3.org/TR/audio-output/
- enumerateDevices: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
- replaceTrack: https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/replaceTrack
- setSinkId: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId
- Screen Wake Lock: https://www.w3.org/TR/screen-wake-lock/
- WebKit の動画ポリシー: https://webkit.org/blog/6784/new-video-policies-for-ios/
- WebKit の WebRTC 方針: https://webkit.org/blog/7763/a-closer-look-into-webrtc/
- Chrome Page Lifecycle: https://developer.chrome.com/docs/web-platform/page-lifecycle-api

YouTube の背面再生を行わない判断は、提供された憲章と裁定を採用しています。

---

## 追補 — α 側の司令塔による改善（2026-09-07 00:0x JST）

ユーザー指示「Astra が作った方を改善して欲しい」。**動いているところは触っていない**——
2ブラウザの実測で同期・チャット・通話・合言葉の関門・再読み込み復帰がすべて通ることを先に確かめ、
**壊れている所を直すのではなく、欠けている所を足した。**

### 先に測った（改善前・ローカル2タブ・YouTube 実物・2026-09-07 00:0x JST）

| 測ったこと | 値 |
|---|---|
| 部屋作成 → 招待 URL | 2,813 ミリ秒 |
| 参加者の入室（合言葉の確認まで） | 3,457 ミリ秒 |
| チャット往復 | 双方向とも到着 |
| **動画の定常ずれ**（YouTube の `<video>` を直接読んだ実測・4標本） | **−0.129 秒**（ばらつき 0.002 秒） |
| **シーク追随（＋10秒）** | **0.024 秒** |
| 一時停止 | 双方 `paused: true` で一致 |
| 通話 | 双方「声の接続ができました」・受信 228／193 パケット |
| 違う合言葉の人 | **入れない**（ホストは「待っています」のまま・発言も届かない） |
| 参加者の再読み込み | 入り直せる |
| 作成者の再読み込み | 同じ招待 URL で戻れる（友達に送り直し不要） |

**測り方の注意**: 画面表示ではなく **YouTube の iframe の中の `<video>` の `currentTime` と `paused`** を直接読んだ。
「位置は合っているが実は止まっていて、毎回シークで当てているだけ」という壊れ方を、表示だけ見ていると見逃すため。
β はこの罠に落ちていない（両者とも `paused: false` のまま進んでいた）。

### 足したもの（4点）

1. **PeerJS を同梱にした**（`vendor/peerjs.min.js`）。unpkg から読んでいたので、**CDN が落ちる・塞がれると部屋に入れなかった。**
   実測: unpkg への通信 0 件、同梱を読んでいることを確認。
2. **通知**。題名の件数（`(2) 同時視聴`）だけだったので、**画面を離れているあいだに来たものを通知で知らせる**ようにした。
   サーバは要らない（このページは背面でも動いているので、その場で出せる）。見ているときは何も出さない。
   知らせるのは3つ——相手の発言・相手がつながった・接続が切れた。使えない端末では題名の件数だけが残る。
3. **ホーム画面に追加できるようにした**（`manifest.webmanifest`・`icon.svg`・apple メタ・favicon）。
   **iOS で通知が使える条件**でもある。ついでに favicon の 404 が消えた。
4. **名前とゲストの合言葉を覚える**。作成者は既に合言葉を `sessionStorage` に持っていたので、**同じ作法を参加者にも広げた**。
   iPhone の Safari はタブを捨てるので、24 字の合言葉を打ち直させるのは現実的でない。
   名前は `localStorage`（秘密ではない）、合言葉は `sessionStorage`（そのタブだけ・閉じれば消える）。
   実測: 再読み込み後、**参加者も作成者も「入る」を押すだけで戻れた**（作成者の招待 URL は同じまま）。

### 改善後の実測（同じ手順・2026-09-07 00:1x JST）

動画の定常ずれ **0.017 秒**（4標本）／シーク追随 **−0.004 秒**／一時停止 一致／ページのエラー なし。
通知: 前面 0 件・背面 2 件＋題名 `(2)`・戻ると消える。再読み込み復帰: 参加者・作成者とも「入る」だけ。

### 触っていないもの

同期の仕組み・合言葉の相互確認・通話・音声デバイス選択・ループバック・ABEMA のカウントダウン・
画面の色・Wake Lock。**これらは実測で動いていたので、手を入れていない。**

---

## 追補2 — 実機で使った友達ふたりの要望に応えた（2026-09-07 09:3x JST）

ユーザーと友達が iPhone で実際に使い、チャットで出した要望を実装した。

| 要望 | 入れたもの |
|---|---|
| **YouTube とチャットをスクロールせず同時に見たい** | **観る面**を作った。動画が上に貼りつき、チャットが残り全部を使い、入力欄は画面内に固定。ふだん使わないもの（設定・通話・招待・ABEMA・診断）は**道具箱**（全面シート）へ畳んだ |
| **音量ミキサー** | **動画・相手の声・自分の声**を別々に。すべて自分にだけ効く。値は端末に残る |
| **ループと画面消灯防止を既定オフ** | どちらも**切**で始まる。入れた状態は覚える |
| **設定保持** | 音量3つ・ループバック・画面消灯防止・読み上げ・読む速さ・名前を端末に保存。**書く直前に読み直して混ぜる**ので、別のタブが先に書いていても消さない |
| **チャット読み上げ** | 相手の発言だけを読み上げる（自分のは読まない）。速さも変えられる。動画を観ているあいだ、耳で受け取れる |
| **タイマー** | **ふたりで共有**。3/5/10/30分の釦と分指定。残り時間はドックに出続け、1分を切ると色が変わる。時間になると通知・読み上げでも知らせる |
| **もう少しかわいく** | 色をやわらかく（桜色の差し色）、角をまるく、釦を丸型に、吹き出しを大きめの角丸に。部屋にいるあいだはヘッダーを薄くしてチャットに高さを返す |

### 実測（iPhone 相当の 390×844・2026-09-07 09:3x JST）

| 測ったこと | 値 |
|---|---|
| **縦スクロール量**（部屋にいるとき） | **0 px** |
| 動画・チャット・入力欄がすべて画面内 | **はい**（動画 200px／チャット **219px**／入力欄 50px） |
| ミキサー「動画 25%」 | **YouTube の `<video>.volume` が 0.25**（実体で確認） |
| ミキサー「相手の声 40%」 | `remoteAudio.volume` が 0.4 |
| 読み上げ | 相手の発言だけ読んだ。**自分の発言は読まない** |
| タイマー（2分） | ホスト「残り 1:59」→ **ゲストにも「残り 1:59」** |
| 既定 | ループバック **切**・画面消灯防止 **切** |
| 設定の保持（再読み込み後） | 相手の声 40%・動画 25%・名前も復元 |
| 同期の回帰 | 定常ずれ **−0.010 秒**／シーク追随 **−0.001 秒**／一時停止 一致 |
| ページのエラー | なし |

### 途中で見つけて直した欠陥（2件）

1. **93px 余分にスクロールしていた。** `main` に `height:100dvh` を与えていたが、ヘッダーがその上に積まれるので画面より高くなっていた。
   `body` を縦のフレックスにして、`main` に「残り」を渡す形に直した。
2. **URL 欄が道具箱の 2,657px 下にあった。** よく使う順（動画→音量→通話→読み上げ→タイマー→招待→ABEMA→表示→診断）に並べ替え、**178px** の位置に上げた。

**まだ入れていない**: 「ゲーム追加」。何のゲームかで作りが変わるので、ユーザーに確かめてから。

---

## 追補3 — 要望の整理と、チャット履歴・使い勝手（2026-09-07 09:4x JST）

ユーザー裁定（要望整理・09:4x JST）:

| 決めたこと | 中身 |
|---|---|
| **本命は β** | 以後の要望は β だけに入れる。α（`/`）は残すが更新しない |
| **ゲームは後回し** | 「いまは要らない」 |
| **次の優先** | ①使い勝手のさらなる改善 ②チャット履歴を残す |

**α にしかない「自分の動画・直リンクの同期」は移植を保留した。**
ユーザー自身が「そもそも動画は YouTube だ」と述べており、移すと二重作業になるだけだから。必要になったら移す。

### 入れたもの

**チャット履歴をこの端末に残す**（既定オン）
- 部屋ごとに `localStorage` へ。**相手にも外にも送らない。** 上限 200 件。
- 再読み込み・アプリ切り替えで消えない。復帰したときは「ここまでが前回まで」の区切りを出す。
- **消えるのは「部屋から退出」を押したときだけ。** 設定に「履歴をいま消す」も置いた。
- 設定で切れる（切ると、その場でいまの分も消す）。

**使い勝手（実測で裏づけたもの）**
- **到着の印を、直近の1件だけにした。** これまで自分の発言すべてに「✓ 相手端末に到着」が付き、
  受け取った言葉にも「✓ この端末に到着」が付いていた。**まだ届いていないものは、古くても出したまま**にする（隠すと気づけない）。
- **受け取った言葉の到着印は消した。** 出ていること自体が到着の証拠なので、行を1つ使う意味がない。
- **補助文を 16px → 14px に。** 本文と同じ大きさだったので場所を食っていた。吹き出しの余白も詰めた。

### 実測（iPhone 相当 390×844・本番 `/astra/`・2026-09-07 09:4x JST）

| 測ったこと | 値 |
|---|---|
| **チャット欄の高さ** | **233〜243px**（前回 219px。到着印と補助文の整理で増えた） |
| 縦スクロール量 | **0 px** |
| 自分の発言3件で出る到着印 | **1件だけ** |
| 受け取った言葉に付く印 | **0 件** |
| **再読み込み後の履歴** | **4件すべて復元・区切りも出た** |
| 復帰後に届いた新しい言葉 | 履歴に続けて残った |
| 履歴を切ったとき | 再読み込み後は空。案内も「残しません」に変わる |
| 同期の回帰 | 定常ずれ **0.009〜0.012 秒**／シーク追随 **−0.002 秒** |
| ページのエラー | なし |

**配備で1つ学んだ**: Cloudflare Pages は `index.html` と `app.js` で反映のタイミングがずれる。
**HTML だけ見て「反映済み」と判断すると、古い JS を測ってしまう**（実際に一度そうなった）。
以後は**変更したファイルそのもの**で確認する。

---

## 追補4 — 背面の答えを、ふつうに使っているだけで出す（2026-09-07 09:5x JST）

**残っていた唯一の関門**は「iPhone の背面で通話が続くか」だった。
これまでは別ページ `/bgtest` を開いて測ってもらう形にしていたが、**実際には測ってもらえなかった**
（釦を並べすぎて肝心のものが押されなかった／そのあとも空いたまま）。

**別ページに行かせるのをやめた。** β はもともと背面区間の秒数と受信パケット数を記録していたが、
**道具箱の奥の `<details>` の中**にあり、しかも「連続性は断定できません」という但し書きだけで、
**誰も読まない場所に、読めない形で**置かれていた。

### やり方

通話に参加したまま画面を離れて戻るたび、**その端末の答えがその場で出る。特別な操作は要らない。**

- 音声は 20 ミリ秒ごとに 1 つ届く（**毎秒およそ 50**）。これを物差しにする。
- **毎秒 30 以上 → ◎**（ほぼ途切れずに届いた）／**5 以上 → △**（一部だけ）／**それ未満 → ✗**（止まっていた）
- **4 秒未満の短い出入りは数えない**（画面を切り替えただけのものを判定に混ぜない）
- **戻ってきた瞬間に画面へ出す**（道具箱を開かなくても見える）
- 「この端末でこれまでに続いた最長」を覚えて、使うほど確かになる
- 但し書きは残す——**戻った直後にまとめて届いた分も含む**ので、耳でも確かめてほしい

### 実測（本番 `/astra/`・2026-09-07 09:5x JST・ヘッドレス Chromium 2ページ・実マイク相当）

| 手順 | 結果 |
|---|---|
| 通話中に 8 秒 離れて戻る | **◎ 502 パケット・毎秒 63**／戻った瞬間に画面へ表示 |
| 続けて 12 秒 離れて戻る | **◎ 682 パケット・毎秒 57**／最長が 12.0 秒に更新 |
| 2 秒の短い出入り | **判定は変わらない**（数えていない） |
| `/bgtest` への導線 | 「画面を離れるとき」から開ける |
| ページのエラー | なし |

**これは Chromium の値であり、iPhone の値ではない。** 仕組みが正しく動くことの確認まで。
**iPhone の答えは、あなたが友達と通話しながら画面を消した次の瞬間に出る。**

---

## 追補5 — ユーザーの4つの指摘に応えた（2026-09-07 10:1x JST）

| 指摘 | やったこと |
|---|---|
| 「音量ミキサーは、YouTube とかの音量と通話とかの音量を個別に上げ下げしたいって事ね」 | **意図は合っていた。作りが悪かった。** 🔊 を押すと設定画面ごと開き、動画から離れてしまっていた。**その場に出る小窓**に変えた |
| 「YouTube 今 URL 貼り付けにしてるけど、直接このウェブツール内で検索できるようにしたい」 | **キー無しではできない**（下記）。キー無しでできる分——**「YouTube で探す」→ 共有からコピー →「コピーした URL を貼る」の一押し**——を入れた |
| 「それ全然可愛くないから。デザインこだわって」 | `style.css` を書き直した（下記） |
| 「メッセージ欄狭すぎてチャット全然追えない」 | **動画の大きさを3段階で切り替えられるようにした**。あわせて無駄な行を削って高さを返した |

### ★ アプリ内の YouTube 検索について（できないことの説明）

**アプリの中で検索するには YouTube Data API のキーが要る。** 無料枠はあるが、
**Web ページに置いたキーは誰でも見られる**（このツールにサーバは無いので隠す場所が無い）。
憲章の「有料サービスとキー禁止」にも触れる。**だからユーザーの判断が要る。**
キー無しで検索する正規の手立ては存在しない（検索結果ページの取得は YouTube の規約に反する）。

**入れた代わりの導線**: 「YouTube で探す ↗」（新しいタブで `m.youtube.com/results` を開く）→
動画の共有からリンクをコピー → 戻って「**コピーした URL を貼る**」（`navigator.clipboard.readText()`）。
自動で貼れない端末には「長押しでペースト」と案内する。

### 画面の大きさ 3段階 — チャットの高さの実測（390×844）

| 画面 | 動画 | **チャット** |
|---|---:|---:|
| **大**（16:9） | 197px | **326px** |
| **小**（帯） | 118px | **405px** |
| **音だけ**（映像なし） | 0px | **549px** |

いずれも**縦スクロールは 0**。切り替えは動画の下の釦（大 → 小 → 音だけ → 大）。**選んだ大きさは覚える。**

**あわせて回収した無駄**（改善前 233px → 大でも 326px になった内訳）:
- チャットの案内文（43px）を**ふだん出さない**ようにした（未達があるときだけ1行）
- 動画の状態文（22px）を**気にすべきときだけ**出すようにした
- 操作バーの折り返し（22px）と、シーク・ドックの余白

### 音量の小窓

**動画から離れずに触れる。** 動画・相手の声・自分の声の3本。画面の下側（親指の届くところ）に固定で出る。
最初は動画の上に重ねたが、**画面が小さいと上へはみ出した**ので、実測して置き場所を変えた。

### デザイン

- 色: 桜色を主に、あたたかいクリーム地（暗いときは深い葡萄色）。背景にごく淡い階調。
- 形: 角丸 22px、釦は丸型、吹き出しは 20px の角丸に小さな尻尾。
- 動き: 新しい発言がふわっと出る（`prefers-reduced-motion` では止める）。
- 文字: 補助文 13.5px、吹き出しの名前と時刻 11.5px、本文 15.5px。
- 知らせ（toast）は**部屋では上に出す**——下に出すと入力欄を覆っていた。

---

## 追補6 — 自分が入れた規約違反を取り消した（2026-09-07 17:4x JST）

**「音だけ」（プレイヤーの高さを 0 にして音だけ流す）は入れてはいけなかった。**

YouTube Developer Policies **III.I.9**:

> 「create, include, or promote features that play content, including audio or video components,
> from a **background player, meaning a player that is not displayed in the page, tab, or screen
> that the user is viewing**」

**高さ 0 のプレイヤーは「表示されていないプレイヤー」そのものである。**
チャットの高さを稼ぎたくて入れたが、**このプロジェクトが最初から「できない」と結論していた当のことをやっていた。**

### 直したこと

**3段階目を「音だけ」から「小」に変えた。プレイヤーは 76px で見えたままにする。**

| 動画の大きさ | 動画 | チャット |
|---|---:|---:|
| **大**（16:9） | 197px | 326px |
| **中**（帯） | 118px | 405px |
| **小**（見える最小） | **76px** | **447px** |

**実測（本番）**: 小のとき **350×76 ・`display: block` ・`visibility: visible` ・画面内**。
以前の設定（`audio`）は読み込み時に `mini` へ読み替える。

**チャットは 233px → 447px（最大）**。「音だけ」を諦めても、**+214px は残った。**

## 追補7 — 画面と処理の突き合わせを、走る道具にした

前回「以後は突き合わせる」と書いたが、**書いただけでは忘れる。**

`apps/watchparty/check.py`（α と β の両方を見る）

```
python3 apps/watchparty/check.py            # 突き合わせる
python3 apps/watchparty/check.py --selftest # 検査器そのものを確かめる
```

見るのは3つ——①**JS が触るのに画面に無い id**（通話カードを消した事故はこれ）
②**画面で重複している id** ③画面にあって JS が触らない id（警告のみ）。

`$('step' + i)` のような組み立てや `error('videoError', …)` 経由も数えるので、誤検出しない。
**自己検査 6/6 通過**（捕まえるべき3件を捕まえ、捕まえてはいけない3件を見逃す）。
いまの α・β はどちらも食い違いなし。

---

## 追補8 — ツール内の YouTube 検索（2026-09-07 19:1x JST・ユーザー裁定「API おく」）

### 鍵の置き場所 — ブラウザにもリポジトリにも置かない

憲章 規律5 は「API キーをフロントに埋めない」。**それを守ったまま検索を入れる。**

```
ブラウザ ──→ /api/search?q=… ──→ Pages Function ──→ YouTube Data API v3
（鍵を知らない）                （鍵は暗号化された環境変数から読む）
```

- 鍵は **Cloudflare Pages の暗号化された環境変数** `YOUTUBE_API_KEY` にだけ置く。
- **リポジトリに鍵は入らない**（`grep -rn 'AIza' apps/watchparty/` が空であることを確認済み）。
- ブラウザに渡すのは、動画 ID・題名・チャンネル名・サムネイルの URL だけ。

### 無料の範囲から出ない（一次資料で確かめた・2026-09-07 実読）

**★ 前に書いた「1日 10,000 単位・検索 1 回 100 単位」は誤り。** 原典はこう書いている:

> Projects that enable the YouTube Data API have a default quota allocation of
> **100 `search.list` calls**, 100 `videos.insert` calls, and 10,000 units per day
> combined for all other endpoints.
> （developers.google.com/youtube/v3/getting-started）

**検索は 1日 100 回までという別枠。** 1日の回数は同じだが、根拠が違うので直す。
超過分についての課金の記載は無い（枠の追加は申請制）。

> Accounts on the Workers Free plan have a daily request limit of 100,000 requests, resetting at
> midnight UTC. … When a Worker exceeds this limit, Cloudflare returns Error 1027.
> （developers.cloudflare.com/workers/platform/limits/）

**Cloudflare も、超えたら止まるだけで課金されない。**

**★ ただし、このアカウントの契約プランは司令塔の権限では確認できなかった**
（`/accounts/*/subscriptions` は Authentication error）。**「無料のはず」に寄りかからず、関門を自分で掛けた。**

| 関門 | 中身 | 実測 |
|---|---|---|
| よそのページからの呼び出し | `Origin`/`Referer` の host が違えば断る | **403** |
| 連打 | 同じ相手から **1分に6回**まで | **6回目から 429** |
| 1日の上限 | **90回**で自分で止める（YouTube の100回に余白を残す） | 同じ数え方が上で効くのを確認 |
| 空の問い合わせ | 断る | **400** |
| 取り置き | 同じ言葉の結果を **6時間**使い回す | — |

数える器は Cache API（無料・binding 不要）。
**地域ごとの取り置きなので厳密な合計ではない。「正確に90回」ではなく「暴走は止まる」ための関門である。**

上限に達したら理由を書いて**「URL を貼って入れる」を自動で開く**。鍵が未設定のときも同じ形で回り道へ案内する。

### 配備の注意（ここで一度つまずいた）

**Pages Functions は「wrangler を走らせた場所」の `functions/` を見る。**
リポジトリのルートから `wrangler pages deploy apps/watchparty` と打つと、
`apps/watchparty/functions/` は**ただの静的ファイルとして上がるだけで、関数として動かない**（実際にそうなった）。

```
cd apps/watchparty
npx wrangler@4 pages deploy . --project-name=cb-watchparty --branch=main
```

**`apps/watchparty` の中で打つこと。** 「✨ Compiled Worker successfully」「✨ Uploading Functions bundle」が出れば正しい。

なお `functions/api/search.js` は静的ファイルとしても配信されるが、**鍵は入っていない**（変数名を書いてあるだけ）。

### 鍵の入れ方（ユーザー操作・1回だけ）

1. Google Cloud Console → プロジェクトを作る → **YouTube Data API v3** を有効化
2. 認証情報 → **API キー**を作る → **キーの制限で「YouTube Data API v3」だけ**に絞る
3. Cloudflare ダッシュボード → Workers & Pages → **cb-watchparty** → Settings →
   Variables and Secrets → Add → 種類 **Secret**／名前 **`YOUTUBE_API_KEY`**／値 = 作った鍵
4. **入れたら司令塔に一声かける**（環境変数は次の配備から効くので、こちらで配備し直して確かめます）

**鍵の値はチャットに貼らないでください。** 貼らなくても設定できます。
