オフライン視聴 (PWA)
「録画を端末に保存して、電波の届かないところ (出先・機内) で観る。/offline で消したら、
次にオンラインへ戻ったときサーバ側の録画も自動で消える」の設計と実装の覚え書き。
消す判断は端末で済ませ、戻ってからもう一度消す二度手間をなくす。
下の「ダウンロード」は、端末に保存するためにブラウザが録画を受け取ることを指します (使い方は watch.md)。
実装の入口:
- src/lib/offline-db.ts … IndexedDB (ページと SW の共有層)
- src/lib/offline.svelte.ts … 画面側 (保存・進捗・削除・outbox)
- src/service-worker/index.ts … Background Fetch の受け取りとオフラインの入口
- src/routes/offline/+page.svelte … 保存済み一覧 + 内蔵プレイヤー
- src/routes/api/recordings/[id]/+server.ts …
DELETE(outbox の宛先)
[一覧・詳細] ──押す──▶ ダウンロード (Background Fetch) │ backgroundFetch.fetch() でブラウザに預ける (タブを閉じても続く) │ 動画は端末がデコードできるコーデック (既定 AV1、無理なら H.264) │ SW の backgroundfetchsuccess → IndexedDB に blob 保存 │ ├─観る──▶ オンライン: /watch が blob を使う (字幕・チャプター・データ放送も) │ オフライン: /offline の内蔵プレイヤー (動画だけ) │ └─/offline で消す──▶ IndexedDB から消す + 「サーバ側も消す」を予約 (outbox) │ online 復帰 ──▶ outbox を処理: DELETE /api/recordings/<id> → deleteRecordingFilesService Worker は配信のキャッシュに使いません (/api/ は素通し、キャッシュするのは殻だけ)。
足したのは Background Fetch の受け取りとオフラインの入口だけで、
/offline を install 時に控えて、ナビゲーションが繋がらないときの行き先にします
(ブラウザのダウンロード表示を押したときも backgroundfetchclick で /offline を開く)。
データモデル (IndexedDB)
Section titled “データモデル (IndexedDB)”DB 名 denpa-offline、ストア3つ (実体は offline-db.ts の型が正):
videos(key: 録画ID) … 保存した録画1本。video: Blobと付属のデータ (captionText:captions.jsonの JSON・poster: Blob・chapters/databroadcast: JSON) を1行にまとめ、 名前・局名・開始時刻・尺・どちらを保存したか (source: 'encoded' | 'alt')、 試みの印 (attempt)、状態 (state) を持つ。outbox(key: 録画ID) … オンライン復帰で処理する予約 ({op: 'delete', name, queuedAt})。resume(key: 録画ID) … オフライン中に進んだ視聴位置。復帰時にまとめてPOST /api/recordings/<id>/resumeで送る。
state は 'downloading' | 'ready' | 'failed' の3つ。失敗は消さずに残す。
通知 (トースト) は一瞬なので、控えごと消すと無かったことに見える。残った行がそのまま
「保存をやり直す」の入口になる。進み具合 (%) は IndexedDB に置かず、画面側のメモリだけで持つ (下記)。
一覧・視聴の UI はこの videos を参照して「保存済みか」を分岐する。
ダウンロード — Background Fetch API を使う
Section titled “ダウンロード — Background Fetch API を使う”- HEAD で下見してから預ける (
probeDownloads)。Background Fetch は 404 が1つでも混ざると全体が失敗になる (failureReason: bad-status) ので、付属のデータは 在るものだけに絞る。downloadTotalを超えても打ち切られるので、HEAD の実測合計 (+2%) を渡す。 どちらも実機の Edge で踏んだ。 backgroundFetch.fetch(id, urls, {title, downloadTotal})でブラウザに預ける (タブを 閉じても続く。/api/…/fileもほかの API と同じくログインの控えで通る)。登録IDはrec-<録画ID>-<source>-<試みの印>。同じIDが生きている間は再登録できないので やり直しのたびに印を変え、前回の残骸は登録前に中止する。遅れて届く残骸の中止の通知が 新しい控えを消さないよう、SW 側は印を照合する。- 進み具合は2秒おきに
get()で読み直す (watchProgress)。progressイベントだけだと、 遅い回線でブラウザが止めて再開したあとの通知が届かず、割合が張り付く。 - SW の
backgroundfetchsuccessで IndexedDB へ移してstate='ready'。backgroundfetchfail/backgroundfetchabortはstate='failed'として残す。 開き直したときは、「保存中」なのにブラウザ側にダウンロードが無ければ失敗に倒す (watchRunning)。 - 対応していないブラウザ (Safari / Firefox) はページ主導の fetch にフォールバック
(タブを開いたまま。進捗は一覧の行の3段目に出す)。機能検出は
'backgroundFetch' in swReg。仕分けは SW の受け取りと共有 (storeResponse)。
どちらを保存するか — 端末がデコードできるコーデック
Section titled “どちらを保存するか — 端末がデコードできるコーデック”既定は AV1 (library_path)。端末が AV1 をデコードできなければ H.264 (alt_path) を保存する。
判定は navigator.mediaCapabilities.decodingInfo() (video/mp4; codecs=av01.… の
supported)。H.264 しかデコードできない端末で alt_path が無い録画は、ダウンロード時に
「この端末では再生できない」と断る (保存できても観られないため)。
- オンラインの
/watchは、videosにreadyがあり、ファイルをそのまま読む方式を自動で選んだときはURL.createObjectURL(blob)をsrcにする (コーデックを選んだときや、サーバが流すものを選んだときはサーバから)。 src を差し替えるだけなので操作 (チャプター・CM飛ばし・速度・字幕・データ放送) はそのままで、付属のデータも端末のものを読む。 互換モードでは保存した Matroska をそのまま canvas に描く (mkv-source.ts。stream.md)。 - オフラインの
/offlineは素の<video controls>で、動画だけを流す (字幕・チャプター・データ放送は保存してあっても使わない)。位置は5秒ごとにresumeストアへ。 - 使い終わったら
URL.revokeObjectURLする。
削除とサーバ同期
Section titled “削除とサーバ同期”/offlineで削除 →videosから除去し、outboxに{op:'delete'}を積む (オンラインならその場で流す)。onlineイベント (と起動時のnavigator.onLine) でoutboxを処理:DELETE /api/recordings/<id>→ サーバはdeleteRecordingFilesを呼ぶ。 成功と 404 (もう無い) は済みとして消し、他の失敗は残して次の復帰で再試行。 視聴位置 (resumeストア) も同じ合図で送る。
コーデック (既定 AV1) とダウンロード方式 (Background Fetch) は上記。ほかに:
- 削除の API:
DELETE /api/recordings/[id]。 認証はほかの API と同じ。録画中は 409、 消し済みは 204、行が無ければ 404 (何度呼んでも同じところに落ちる)。 - 容量: 保存する前に確かめる (30分で約300MB)。
navigator.storage.estimate()の空きが HEAD で測った合計の 1.2 倍に満たなければ断り、navigator.storage.persist()で ブラウザに勝手に消させない。