Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ SQLite が拒むので、事実と状態が食い違いません。文字列で
| `/guide` | 番組表(グリッド)と番組検索。マスはジャンルごとに色を変える。**サブチャンネル (テレ東2・3、Eテレ2・3、BS朝日2・3 など) は既定で出す**が、並ぶのはその日に分割放送 (本チャンネルと違う番組) のあるサブだけで、本チャンネルのすぐ右に置く。そのときは「サブch」の切り替えも出て、切ると本チャンネルだけになる (端末ごとに cookie で覚える)。同じものを流している間 (名前の無い枠・重なる時間の本チャンネルの番組と同じ名前・番組が無い) は本チャンネルのマスが横に伸びて1つになる。分割放送の間だけサブに自分のマスが立つ (`#lib/subchannels.ts`)。ずっと本チャンネルと同じ番組のサブは列を立てない。本チャンネルとサブは地上波・BS とも同じ TS (`services.channel`。BS朝日1〜3 は `BS01_3`) で束ね、サービスID のいちばん若い局を本チャンネルとする。イベント共有 (サブが本チャンネルと同じ番組を流すこと) は同じ TS の中でしかできない決まり (ARIB TR-B14 第四編 17.2、TR-B15 第一部 第四編 17.2)。WOWOW のプライム・ライブ・シネマは同じ会社でも TS が別なので別の局のまま。CS・SKY は束ねない (1つの TS に別々の会社の局が乗る)。隠しても録画のルールには関わらない。ライブの一覧も、いま本チャンネルと同じ番組を流しているサブは同じ決め方で外す (`epg.airing`)。詳細から予約・取消と、エンコードできていれば録れたものをそのまま観られる。「ルールを作る」でルールの画面へ (`/rules?program=<番組ID>`)。いま流れている番組には「視聴」が出て、その局でライブ画面が開く。音声は**放送が付けた名前**で出す (「主音声ステレオ」「解説ステレオ」)。符号だけだと解説放送で同じラベルが2つ並ぶ |
| `/live` | **いま流れているものを見る** ([stream.md](stream.md))。左に映像、右に局の一覧。前回見ていた局から開く (覚えていなければリモコン番号のいちばん若い局。番組表の「視聴」から来たときはその局)。局の並びは番組表と同じ。操作列は自前 (備え付けは使わない) で、止めた所から見られる (追いかける速さを選べ、追いついたら自分でライブに戻る)・音声を選べる・エンコード方式 (H.264 / AV1) を見ながら選べる・字幕を出せる・いまの1コマを字幕ごと切り抜ける・切り替えの間は前の映像を貼る。d と切り抜きは右上の縦列 (観る画面と同じ場所・同じ並び)。右上の録画ボタンで、いま観ている番組の録画が始まる (手動予約と同じ流れ)。画質の切り替えで MPEG-2 を選ぶと、エンコードせずに放送そのままを WASM でデコードして見られる ([stream.md](stream.md) §5.5)。データ放送も出せる (d ボタン。指で押せるリモコンが右に並ぶ)。Hybridcast は行き先を出し (別のタブで開く)、データ放送が起動を求めたら映像の上に開く ([stream.md](stream.md#58-hybridcast)) |
| `/chase/<id>` | **追っかけ再生** ([issue #16](https://github.com/danything/denpa/issues/16))。録画中の録画を頭から観る。一覧の予約側の録画中の行の「追っかけ」から (録れている最中の行はまだ予約の側にいる)。元のTSはブラウザで読めないので、サーバが伸びるファイルを追い読みしてライブと同じ経路 (fMP4 → WebSocket → MSE) で再エンコードする。チューナーは使わない。押し方は観る画面と同じ (空白・矢印・画面内クリック、指は端2回で10秒)。速さも同じところに覚える。シークバーは番組の全長で、右端 (いま録れているところ) は伸びていく。手元に残っている範囲は即座に、外は `chase` の送り直し (ffmpeg 立て直し) で飛ぶ。視聴位置は15秒おきにサーバへ写すので、エンコードが終わったあとの `/watch` が続きから拾う。録り終えた録画で開くと `/watch` へ回す (エンコードを待っている間も) |
| `/watch/<id>` | **録画をブラウザで観る。** 画面の構成と操作 (2段組・全画面・端2回で10秒・CM飛ばし・字幕・速さ・続き・その場で削除) は [library.md](library.md#観るのはブラウザで)。録り終えた録画はどれもここで観る。何で観るかはサーバが出せるもの (`api/recordings/<id>/playback`) から画面が選ぶ (エンコード済みそのまま・詰め替え・その場の変換。[library.md](library.md#何で観るかはサーバが出せるものから画面が選ぶ))。コーデックのメニュー (自動・H.264・AV1。端末ごとに覚える) は観られる方式が2つ以上あるときだけ。音声トラックも選べる。二カ国語・二重音声をエンコードしたものは両方入っているので、ブラウザがコンテナから出せたときだけ切り替えを出す (`video.audioTracks`。ライブと違いサーバは再エンコードしない)。番組表から消えた録画でもジャンル・音声・詳細のラベルは出る。録り始めに `recordings` へ写してあり、詳細の種はそれを既定の null で潰さない (`detail.svelte.ts`) |
| `/watch/<id>` | **録画をブラウザで観る。** 画面の構成と操作 (2段組・全画面・端2回で10秒・CM飛ばし・字幕・速さ・続き・その場で削除) は [library.md](library.md#観るのはブラウザで)。録り終えた録画はどれもここで観る。何で観るかはサーバが出せるもの (`api/recordings/<id>/playback`) から画面が選ぶ (エンコード済みそのまま・詰め替え・その場の変換。[library.md](library.md#何で観るかはサーバが出せるものから画面が選ぶ))。コーデックのメニュー (自動・H.264・AV1。端末ごとに覚える) は観られる方式が2つ以上あるときだけ。音声トラックも選べる。二カ国語・二重音声・解説をエンコードしたものは全部入っている。ブラウザがコンテナから出せればそこで切り替え (`video.audioTracks`)、出せなければ (Chrome・Firefox の既定) 選んだときにその場で流すほう (`/stream?audio=`) へ移る ([library.md](library.md#何で観るかはサーバが出せるものから画面が選ぶ))。番組表から消えた録画でもジャンル・音声・詳細のラベルは出る。録り始めに `recordings` へ写してあり、詳細の種はそれを既定の null で潰さない (`detail.svelte.ts`) |
| `/api/recordings/<id>/resume` | どこまで観たかを覚える (POST)。端末ではなくサーバに置くので別の端末でも続く。末尾まで観たものは忘れる |
| `/api/recordings/<id>/captions.json` | 字幕を文字の配置で返す。入れ物の中の放送の字幕 (S_ARIBSUB) を抜いてサーバでデコードする (まだエンコードしていない録画は元の TS から抜き、TS の頭からの秒で返す)。字幕を持たない録画と、ずっと前にエンコードした録画 (画像の字幕 PGS。もう読まない) は 404 (画面はボタンを出さない) |
| `/api/recordings/<id>/chapters` | エンコードしたものに入っているチャプター (CM飛ばしの行き先)。DB ではなくファイルから読む。CMを切ってエンコードしたものと引き継いだ録画では DB と食い違う。まだエンコードしていない録画は検出した区間 (`cm_ranges`。元の TS の頭からの秒) から組む。時刻は `playback` の物差しの秒で、テレビのアプリも使う ([api.md](api.md#録画のチャプター-get-apirecordingsidchapters)) |
Expand Down
5 changes: 5 additions & 0 deletions docs/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ Chromium で `canPlayType` が `probably` を返し、実機の録画で再生
コーデックのメニュー (この端末で観られる方式が2つ以上あるときだけ) で「自動」か H.264・AV1 を選べ、
選んだものは端末ごとに覚えます。AV1 だけエンコードした録画でも H.264 を選べば観られます (サーバが変換する)。

**ファイルをそのまま読んでいるときの音声の切り替えは、ブラウザが入れ物の音声を出せるか次第です**
(`video.audioTracks`)。Chrome・Firefox は既定で出さないので、そのままでは2本目 (副音声・解説) を選べません。
そこで2本以上あれば `playback` の名前で並べ、2本目を選んだら、いまの位置からその場で流すほうへ移ります
(`ts/playback.ts` の `streamForAudio`。同じコーデックの詰め替えを先に)。

**その場の変換には同時に走らせる本数の上限があります** (設定の「GPU」。自動では GPU が無ければ 2本、
あれば 4本。[encode.md](encode.md#録画のその場の変換も同じエンコード方式本数に上限を置く))。埋まっていると
「サーバが混んでいます」と出し、サーバに変換させずに観られるコーデックがあればそれを勧めます。
Expand Down
28 changes: 28 additions & 0 deletions src/lib/ts/playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type PlaybackInfo,
pickCanvas,
pickOffer,
streamForAudio,
transcodes,
} from './playback';

Expand Down Expand Up @@ -106,6 +107,33 @@ describe('どれで観るか (pickOffer)', () => {
});
});

describe('音声を選ぶために流すほうへ移る (streamForAudio)', () => {
test('同じコーデックの詰め替えを先に', () => {
expect(streamForAudio(av1Only, chrome, 'av1')).toEqual({
way: 'stream',
codec: 'av1',
type: AV1_MP4,
});
expect(streamForAudio(h264Only, chrome, 'h264')).toEqual({
way: 'stream',
codec: 'h264',
type: H264_MP4,
});
});

test('同じコーデックを MSE が受け取れなければ、受け取れる変換', () => {
expect(streamForAudio(av1Only, browser([], [H264_LIVE]), 'av1')).toEqual({
way: 'stream',
codec: 'h264',
type: H264_LIVE,
});
});

test('MSE が何も受け取れなければ null', () => {
expect(streamForAudio(av1Only, browser([], []), 'av1')).toBeNull();
});
});

describe('コーデックのメニュー (codecChoices)', () => {
test('この端末で観られる方法があるものだけ', () => {
expect(codecChoices(av1Only, chrome)).toEqual(['h264', 'av1']);
Expand Down
14 changes: 14 additions & 0 deletions src/lib/ts/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ export function pickOffer(
return first === undefined ? null : asPicked(first);
}

/**
* **音声を選ぶために、その場で流すほうへ移るときの行き先。** ファイルをそのまま読んでいて、ブラウザが
* 入れ物の音声を出さない (`video.audioTracks` が無い。Chrome・Firefox の既定) ときに使う —
* そのままでは2本目の音声 (副音声・解説) を選べない。流すほうはサーバが選んだ1本を流す (`/stream?audio=`)。
*
* 同じコーデックの詰め替え (サーバは軽い) を先に、無ければ `pickOffer` と同じ順の変換。MSE が
* 受け取れるものが無ければ null (= 移れないので、音声の切り替えは出さない)
*/
export function streamForAudio(info: PlaybackInfo, can: MediaAbility, codec: PlayCodec): Picked | null {
const streams = info.offers.filter((offer) => playsStream(offer, can));
const chosen = streams.find((offer) => offer.codec === codec) ?? streams[0];
return chosen === undefined ? null : asPicked(chosen);
}

/**
* **コーデックのメニューに並べるもの。** この端末で観られる方法があるコーデック (H.264 → AV1)
*/
Expand Down
53 changes: 46 additions & 7 deletions src/routes/watch/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
pickOffer,
retryWait,
STREAM_RETRIES,
streamForAudio,
transcodes,
} from '#lib/ts/playback.js';
import {
Expand Down Expand Up @@ -735,9 +736,9 @@
* ここで選んだものだけを有効にして切り替える。ライブと違い**サーバはエンコード
* 直さない** — コンテナの中に入っているものを選ぶだけ。
*
* **出せない端末では空のまま = 切り替えは出さない。** `audioTracks` は
* ブラウザ任せで、Matroska の副音声を出さないものもある。押しても効かない
* 操作を並べないため、2本以上あるときだけ出す (`hasCaptions` と同じ考え)
* **出せない端末 (Chrome・Firefox の既定) では `playback` の名前で並べ、選んだらその場で流すほうへ
* 移る** (`moveForAudio`。サーバが選んだ1本を流す)。移れる先も無ければ空のまま = 切り替えは出さない。
* 押しても効かない操作を並べないため、2本以上あるときだけ出す (`hasCaptions` と同じ考え)
*/
let audios = $state<{ label: string }[]>([]);
let audioIndex = $state(0);
Expand Down Expand Up @@ -1136,7 +1137,14 @@
}
const tracks = audioTrackList();
if (tracks === null) {
audios = [];
/*
* **ブラウザが入れ物の音声を出さない** (Chrome・Firefox の既定)。流すほうへ移れるなら
* `playback` の名前で並べ、選んだら移る (`selectAudio`)。移れなければ出さない
*/
audios =
audioFallback() === null
? []
: (info?.audios ?? []).map((audio, i) => ({ label: audio.label.trim() || `音声 ${i + 1}` }));
return;
}
const list: { label: string }[] = [];
Expand All @@ -1160,14 +1168,45 @@
return;
}
const tracks = audioTrackList();
if (tracks === null) return;
if (tracks === null) {
moveForAudio(index);
return;
}
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (track !== undefined) track.enabled = i === index;
}
audioIndex = index;
}

/** 音声を選ぶために移る先 (`streamForAudio`)。ファイルをそのまま読んでいないなら null */
function audioFallback(): Picked | null {
if (info === null || picked?.way !== 'file') return null;
return streamForAudio(info, ability(), picked.codec);
}

/**
* **ファイルのままでは選べない音声を、その場で流すほうへ移って選ぶ。** いまの位置から観直す
* (`chooseCodec` と同じ作り直し)。いまの音声 (ファイルでは1本目) を選んだだけなら何もしない
*/
function moveForAudio(index: number): void {
if (index === audioIndex) return;
const fresh = audioFallback();
if (fresh === null) return;
const position = video?.currentTime ?? 0;
// 端末のコピーはそのまま読むときだけ (`chooseCodec` と同じ)
if (localSrc !== null) {
URL.revokeObjectURL(localSrc);
localSrc = null;
}
busy = false;
broken = false;
audioIndex = index;
retryAt = position;
switchAt = position;
picked = fresh;
}

/**
* チャプターの位置。**エンコードしたものから読む** (`api/recordings/<id>/chapters`)。
*
Expand Down Expand Up @@ -1970,8 +2009,8 @@
/>
{/if}
<!--
**音声トラックの切り替え。** ブラウザがコンテナから2本以上
出せたときだけ (二カ国語・二重音声)。`/live` の音声選びと
**音声トラックの切り替え。** 2本以上あって選べるときだけ (二カ国語・二重音声・解説。
ブラウザが出せなければ流すほうへ移って選ぶ。`syncAudio`)。`/live` の音声選びと
同じ見た目。1本しか無ければ出さない
-->
{#if audios.length > 1}
Expand Down
Loading