Imported from yasamari/kurumi (
AGENTS.md). Install upstream withnpx skills add yasamari/kurumi. Copyright stays with the author.
AGENTS.md
Toolchain
flutter/dart are not on PATH. They come from the Nix flake devShell (.envrc → use flake). Prefix commands:
direnv exec . flutter analyze
direnv exec . flutter test
direnv exec . flutter test test/mirakurun_filter_test.dart # single file
nix build # Linux app package
dart run ... fails here (kurumi depends on flutter_test from sdk which doesn't exist). Always go through flutter pub run, e.g.:
direnv exec . flutter pub run build_runner build --delete-conflicting-outputs
Codegen
*.g.dart(riverpod_generator / json_serializable) and*.freezed.dartare committed. Regenerate and commit them; don't hand-edit them.- Every annotated file declares
part '<name>.g.dart'/part '<name>.freezed.dart'. riverpod_lintis a dev dependency but nocustom_lint/lint runner config exists, so none of its rules are active — onlyflutter_lints(analysis_options.yaml).mirakurun-openapi.json/konomitv-openapi.jsonat the repo root are reference specs only. DTOs are hand-written; nothing regenerates them.
Architecture
lib/src/ is layered, dependencies point inward (features → domain/core → data):
core/— router (go_router, 5-tabStatefulShellRoute), theme (Compose Material 3 の Dynamic Color)、settings (SharedPreferences-backedAppSettings+ immutableAppSettingsState)、sharedDio, utils,core/widgets/(画面をまたいで共有するウィジェット:ChannelCard/ChannelLogo/ProgramSymbolText)。チャンネルカードはテレビ画面と視聴画面のチャンネル切替タブの両方で使うためfeatures/tv/widgets/ではなくcore/widgets/に置く。domain/— backend-agnostic entities (freezed) and theTvRepositoryinterface.data/backends/{mirakurun,konomi}/— per-backend API client (dio), DTOs,*_filter.dart(pure mapping), repository.data/nx_jikkyo/— ニコニコ実況コメント (NX-Jikkyo)。TvRepositoryとは別系統で、Channelの network_id/service_id をjk<N>に変換する対応表と、2本の WebSocket セッション。features/— UI per tab,shell/holds the adaptive scaffold.
Key invariants when adding a backend (Mirakurun, KonomiTV, EDCB…): implement TvRepository, then add one line to the switch in domain/providers/backend_provider.dart plus the switch cases in BackendType.label, the capability getters (supportsVideos, supportsRecordingReservations), and AppSettingsState.activeBaseUrl, and a persisted URL + setter in core/settings/app_settings.dart. TvRepository doc comments state this explicitly. Nav availability follows automatically: features/shell/app_destinations.dart maps sections to those capabilities and never names a backend, so no update is needed there.
Filtering logic lives in pure functions (buildMirakurunChannelItems, buildKonomiChannelItems) with DateTime now / baseUrl injected — keep it that way so it stays unit-testable.
実況コメント (NX-Jikkyo)
接続先は data/nx_jikkyo/jikkyo_endpoints.dart の nxJikkyoBaseUrl でコードに固定。設定画面には出さない (ユーザーが選択済み)。
jk<N> への変換表 (jikkyo_channel_map.dart) は KonomiTV の server/static/jikkyo_channels.json (362行) を移植したもの。うち4組が同じ (network_id, service_id) を共有しているためキー数は358。重複組はどれも jk ID が同一なので畳んでも解決結果は変わらない。3点だけ KonomiTV 側と挙動が一致していない:
- 地上波の network_id は実 NID では 0x7880〜0x7FEF だが、表内では番線値 15 に束じてある。したがって
resolveJikkyoChannelIdは network_id を見ず、ChannelTypeで地上波か判定する。CATV の network_id (0x7CA0) が地上波の範囲に数値的に含まれうるため、NID の数値だけでは区別できない。 - 地上波では
sid,sid - 1,sid - 2の順に引く (NHK総合2・東京が 1つ前の SID を持つため)。 jikkyo_id: -1のエントリはnullとして落とさず保持する。落とすと sid-1 フォールバックが別地域の SID にマッチして誤検出しうる。登録済み判定は NX-Jikkyo のKNOWN_JIKKYO_CHANNEL_IDS(35件) との交差で行う (表にあるjk256などは接続時に 1008 で拒否される)。
セッションは 2 本: JikkyoWatchSession (/ws/watch) を張り続け、room で得た threadId / yourPostKey から JikkyoCommentSession (/ws/comment) を起こす。過去ログは「既存より古い」コメントとして届くので mergeJikkyoComments (jikkyo_comment_list.dart) で常にコメ番順に並べ直すこと。
実況コメントは Riverpod ではなく JikkyoCommentController (features/player/, ChangeNotifier) が所有する。 視聴画面は MediaQuery.orientationOf で Row と Column を切り替えるが、ウィジェットの型が変わるとその下の Element が作り直されるため、Row/Column の内側にある State は画面回転ごとに失われる。コメント接続は回転しても保たれる必要があるため、向きに依存しない _WatchLayoutState が controller を持ち (_WatchLayout に channelId をキーで渡し、チャンネル切替では作り直す)、dispose でソケットを閉じる。ここに ConsumerWidget や Riverpod provider を置くと回転のたびにタブが戻り、connecting に戻る。
情報パネルのタブは 3 つ (番組情報 / チャンネル / コメント)。選択位置は watchInfoTabProvider (@Riverpod(keepAlive: true)) が持つ。State のフィールドだとチャンネル切替でタブが戻る — 切替は context.go('/watch/<id>') で視聴画面ごと置き換えるため、回転と異なり映像スロットごと破棄される。keepAlive provider なら回転・チャンネル切替の両方で保たれる。なお IndexedStack は if で子を落とすとコメント選択時 (index 2) に子が1件只剩って範囲外.Assertion を出すので、未選択タブも SizedBox.shrink() で埋めて タブ数固定で渡す。この制約は PlayerInfoTabs (program_info_panel.dart) に集約してあり、tabCount と children.length / destinations.length の不一致は assert で検出する。
弾幕 (canvas_danmaku) は features/player/jikkyo_danmaku_overlay.dart。DanmakuScreen は LayoutBuilder で親の制約からサイズを取るため Positioned.fill が必須。置く場所は外側の Stack ではなく Video の controls ビルダーが返す Stack の中。Video は「映像テクスチャ → 字幕 → 標準コントロール」を内側の Stack で描画しているため、外側の Stack に重ねると (1) コントロールより上になる / (2) 順序を入れ替えると映像テクスチャより下になって見えない、のどちらかで破綻する。controls レイヤーの内側なら「映像の上・操作オーバーレイの下」におさまる。この制約は PlayerVideoView (player_video_view.dart) の controls ビルダーの中に閉じてあり、両画面はこのウィジェットを使うだけ。同じ Stack には StackFit.expand を指定する (Video 側は Positioned.fill で tight 制約を渡しており、標準コントロールに loose な制約を渡すとグラデーション等が縮む)。コントロール自動非表示時 (mount=false) でもビルダーの返り値はツリーに残るので、弾幕だけが消えることはない。ただし Video は FittedBox(fit: BoxFit.contain) で描くため、そのまま重ねるとレターボックス (黒帯・柱状) にも弾幕が出る。映像の表示アスペクト比を videoDisplayAspectOf (player_error.dart) で取り、JikkyoDanmakuLayer の Align + AspectRatio で矩形を絞る。FittedBox(contain) と同じ矩形になるので幅高を自前で計算しなくてよい。比が確定するまで (video-params 未着・音声のみ) 弾幕は描画しない。w/h (符号化サイズ) を使ってはいけない: 日本語デジタル放送には 1440x1080 を 16:9 に引き伸ばすチャンネルが多く mpv が PAR 12:11 として持つため、w/h は 1.333 になるが表示は 1.778。ここを間違えると矩形が縦に伸びて弾幕が黒帯に侵入し、かつ端に届かない。
弾幕の on/off は回転で破棄されない位置に持つ。 PlayerPlaybackController のフィールドで、通知を PlayerVideoView の外側 (ListenableBuilder) で受ける。映像の Stack 内に持つと回転でリセットされる。controller.liveComments (ライブ) は購読直後のバックログ (直近100件) を除外した新規コメントだけを送る。DanmakuOption.fontSize は画面全体で1つなので mail のサイズコマンド (small/big) は弾幕では表現できない。
弾幕エンジンは canvas_danmaku のフォーク (yasamari/canvas_danmaku、MIT。pubspec.yaml は rev ピン留め git 依存)。描画は決定論的: 各弾幕は共有時計 (DanmakuClock) 上の birth を持ち、位置は now - birth の純関数 (danmaku_track_assign.dart)。軌道割当も birth 順の純関数で、Random は使わない。計測 (幅) とラスタ化は初回描画時に遅延実行し、挿入時はソート挿入だけ (一括取得が重くならない)。CustomPainter 3層分離・Ticker 自動停止・バッチ描画の高速化機構は温存する。
弾幕エンジンの不変条件 (3 つとも守ること)。 破ると「消える少し前の弾幕が上段へ飛ぶ」症状 (描画中に yPosition が変わる) に戻る:
- 画面上の滞在時間は
durationちょうどで、幅に依存させない (scrollDanmakuGone)。scrollDanmakuXはW + wをdurationで進むのでx == -w(= 完全退出) は幅によらずbirth + duration。幅依存の transit を使うと消滅順が出生順とズレ、不可視の弾幕が軌道を占め続け、alive 窓の head 一括掃き出しも効かなくなる。 - 軌道割当は
nowを読まない (ScrollTrackAllocator)。割当は(birth, 幅, 画面幅, duration)だけで決まり、出生順に 1 個ずつ「最初の空き軌道」。store.pruneは 11 フレームごとに version を上げるので窓の再構築が 1 秒に約 5 回起きるが、now依存の判定 (「もう消えたので軌道から外す」) を挟むと再構築が逐次処理と結果 달라れ、描画中の弾幕が別の行へ移動する。消えている弾幕も軌道は取る (直後の弾幕がそれで押し出された値なので、逐次処理と一致させるには落とせない)。静的弾幕 (ue/shita) の_topLast/_bottomLastも同じ。 - 再構築の開始点は
now - 2 * duration(trackReplaySpanMs)。塞ぎ条件はどちらもelapsed < durationを要求するので、それより古い弾幕は画面上のどの弾幕も塞げない。窓をこれより狭くすると、後から作られた view (フルスクリーン / 回転) が既存 view と別の行割当てになる。
なお alive 窓のカーソルは添字ではなく birth 処理済み境界 (lowerBoundBirth で毎回引く)。prune が出生順リストの先頭を落とした分だけ添字がずれると、新着弾幕を黙って落とす。
描画状態 (DanmakuStore) と時計は映像の Stack の外に持つ。 media_kit のフルスクリーンは同じ controls ビルダーで別ルートに新しい Video を積むため、DanmakuScreen が2つになる。両方に同じ store/clock を渡すと、同じ時計窓を描くので切替時に描画中の弾幕が消えない。回転も同様 (作り直されたviewが時計窓から描き直す)。所有者はライブが _WatchLayoutState、録画が _VideoPlayerState (どちらも回転で生き残る)。コメント二重購読 (通常+全画面) は store の重複排除キーで吸収する。store の画像キャッシュは共有で、view 側で破棄しない (重複破棄は null 化で冪等だが、他viewの表示中テクスチャを消さないこと)。
- ライブ: 受信時の時計値を birth にする。保持は直近60秒 (
_liveDanmakuRetentionMs)。バッファ (paused-for-cache、player.stream.buffering) 中と一時停止中は時計を凍結する (danmakuClockShouldRunの純関数)。バッファ解消は壁時計から再開し (再生位置の基準が無いため合わせ直しはしない)、一時停止の再開は停止中の壁時計分だけ進めて (danmakuLiveResumeMsの純関数) ライブエッジに追従させる。 - 録画:
JikkyoPastCommentControllerは全件取得してstateに置くだけ (位置購読・送出ポインタは持たない)。オーバーレイがdanmakuBirthMsOf(postedAt - syncStart、jikkyo_danmaku_item.dartの純関数) で birth 換算して一括登録する。時計は_VideoPlayerStateがplayer.stream.position(ずれ400ms超で補正) +playing/bufferingの合成 (danmakuClockShouldRunの純関数、停滞中は凍結) で駆動し、通知間は壁時計で外挿する。シーク先の表示期間内コメントは途中位置から描画される。store 保持は番組尺+30秒。
ライブEIT (番組情報)
視聴画面の番組情報はライブEIT[p/f]からのみ取得する (/api/channels 由来の番組表示はしない)。EIT未受信時は「番組情報を取得中」と出す。TVタブの一覧・チャンネル切替は従来通り API 由来 (nowOnAirChannelsProvider)。
- EITパイプラインはバックエンド非依存で
data/ts/に置く (eit.dartセクション再構成+記述子パース /arib_text.dart+arib_tables.dartARIB文字列 /live_program.dartTvProgram組み立て /audio_layout.dart音声構成判定 /ts_sync.dart188B整列 /stream_tap_*.dartネイティブ tap 連携)。node-aribts と KonomiTVProgramUtils/LivePSIArchivedDataDecoderの移植。音声レイアウトは ariblib (ariblib/constants.pyCOMPONENT_TYPE) / KonomiTVTSInfoAnalyzerのprimary_audio_type判定に相当する。文字列整形はcore/utils/program_text.dartのformatProgramTextを使い回すこと (二重実装しない)。 - パケット源はバックエンド別: KonomiTV は PSIアーカイブAPI→
KonomiTsPackets(data/backends/konomi/)、Mirakurun等は stream tap (kurumi-ts://)。どちらもLiveSession(features/player/live_session.dart: mpvが開くURL + 188Bパケット列 + close) に包んで_LivePlayerStateが所有し、_openでセッション確立→LivePsiController起動→player.openの順に開く。開設中は読み込み表示、失敗は再試行表示。 - mpv の
stream_cbコールバック自体は Dart で書かない。 コールバックはmpv側スレッドから呼ばれるが、pure Dart のisolateLocal/Pointer.fromFunctionは作成スレッド以外から呼ぶとプロセスごと abort し、listenerは戻り値を返せない (read_fnはバイト数を返す必要がある)。ブロッキングする read はnative/stream_tap/の C (kurumi_tap_*リング+同期プリミティブ) が持ち、HTTP 取得と EIT 解析は Dart のまま。Dart→mpv 方向の呼び出しはmpv_stream_cb_add_roの1回だけで、features/player/mpv_stream_tap.dartに集約する。同一ハンドルへの再登録は mpv が -4 で拒否するため正常扱いにする。 - media_kit は temp playlist 経由 (
loadlist) で開くため、tap (kurumi-ts://) の再生にはload-unsafe-playlists=yesが要る。無いとプレイリスト由来の origin ゲートで UNSAFE 拒否され、後続ハンドラの NO_MATCH に上書きされて見かけ上 protocol unsupported 相当のエラーになる (_openで tap 時のみ設定)。 - ネイティブ tap の対応は Linux (
linux/CMakeLists.txtでバンドルlib/へ)・Android (android/app/src/main/cpp/CMakeLists.txt+externalNativeBuild)・Windows (windows/CMakeLists.txtで実行ファイルと同階層へ)。同期プリミティブの差異 (pthread / SRWLOCK+CONDITION_VARIABLE) はstream_tap.c内の#ifdef _WIN32で吸収し、API は共通。flake.nixの fileset にnative/を入れること (抜くとnix build成果物に.soが入らない)。nix 成果物はラッパー起動でバンドル相対が外れうるため、tap 用.soは別 derivation (stream-tap-lib) でも作りruntimeDependenciesに載せて soname 解決の保険にしている。macOS/iOS は未対応で、Mirakurun等はセッション開設失敗 (再試行表示) になる。 - 取得ポンプは worker isolate で回す (
StreamTapSessionがIsolate.spawnし、StreamTapPumpを走らせる)。フルTS (15〜24Mbps) の受取・188B整列・リングへの push をUI isolate で行うとイベントループが圧迫され、mpv への供給が途切れ途切れになって映像が僅かに遅くなり音声が途切れる (Mirakurun等だけ 발생。KonomiTV は mpegts を mpv が直接開くため無関係)。worker →メインisolate はTapWorker*メッセージで、EITバッチ (PID 0x12 のみ) しか運ばない。LivePsiControllerは受け取った 188B バッチを自前でさらに PID フィルタする (二重化しても無害だが、Konomi 経路はフルTS がそのまま流れるので必要)。ポンプは isolate 非依存なStreamTapPumpに分けてあり、test/stream_tap_session_test.dartは_FakeTapNativeを直接注入して単体テストする。 TsSectionAssemblerは PUSI=1 のとき、pointer_field が示すバイト数を前セクションの続きとしてバッファへ足してから新セクションに切り替える。実TSはセクションが詰めて置かれるため「長いセクションの末尾 + 次のセクションの開始」が同一パケットに入り、そのパケットは PUSI=1 + pointer_field>0 になる。これを捨てると複数パケットにまたがるセクションが丸ごと失われる (node-aribtsariblib/packet.pysections()のbuffer.extend(prev)に相当)。afcは 0b01 が adaptation 無し、0b11 が adaptation あり、0b10 はペイロード無し。stuffing (先頭 0xFF) で打ち切る。LivePsiControllerは PID 0x12 を組み立て前に抜く。フルTS (映像PES含む) をそのまま組み立てると他PIDの断片がバッファに溜まり続ける。- 音声レイアウト (主音声のみ / ステレオ / デュアルモノラル) もライブEIT[p/f] だけから判定する (
data/ts/audio_layout.dartのdetectLiveAudioLayout、API からは取らない = リアルタイム)。判定材料は EIT イベントの音声コンポーネント記述子 (0xC4) のcomponent_type(ARIB STD-B10-2 表6-5) で、eit.dartが既にAudioComponentDescriptorとしてパースしている (かつlive_program.dartが読み捨てている)。0x01=1/0モード・0x03=2/0モード →mainAudioOnly、0x02=1/0+1/0モード →dualMono、それ以外 (3/0以上・5.1/7.1・0x40音声解説・0x41) と記述子なしはunknown。判定するのは主・副の切替が要るかどうかだけで、マルチチャンネルは mpv の音声トラック認識に委ねる。PMT からは判定できない (通常のステレオと区別が付かない) ため PMT は見ない。main_component_flagが偽の記述子は副音声=別トラック (二重ステレオ) なので判定に使わず、mpv が2本出すのに任せる (KonomiTV の再エンコード画質は元の放送が dual mono でも右チャンネルを副音声トラックに分離して2本出すため、mpv のトラック数を最優先する)。 LivePsiController.audioLayoutは現在番組 (section_number == 0) の EIT からのみ更新し、unknownは反映しない (音声コンポーネント記述子を含まない再送で「選択可能だった音声」が突然できなくなるのを避ける)。チャンネル・画質切替ではコントローラーごと作り直されるためそのときに戻る。dualMonoと分かった時点で主音声を自動適用する (_LivePlayerState._syncAudioLayout→PlayerPlaybackController.selectAudio(DualMonoChoice(main)))。既定のdual_mono_mode=autoは Lch=主/Rch=副 を同時に出力するため。通知はValueNotifier<LiveAudioLayout>を_LiveSettingsButtonへ参照だけ渡し、ValueListenableBuilderは設定メニュー (_SettingsMenuPanel) 内で行う (ボタン側で購読すると映像の再構築に巻き込まれる。_WatchInfoPanelと同じ理屈)。- 設定メニュー側の分岐は純関数
audioMenuMode/audioMenuLabel(features/player/audio_switch.dart) に集約し、テスト対象にする。mpv の実トラック2本以上 → トラック一覧 /dualMono→ 主音声・副音声の2項目だけ / それ以外 → 1行だけ表示して押せない (表示は主音声)。録画再生 (video_play_screen.dart) は TS を一切見ないので notifier を渡さずunknownのまま = mpv に委ねる (Mirakurun の実 dual mono 録画では副音声を選べない)。
プレイヤー画面の共通層
ライブ視聴 (watch_screen.dart) と録画再生 (video_play_screen.dart) は同じ mpv でほぼ同じ構成の二つの画面だが、映像周りの組み立ては共通ウィジェットにしてある。破れやすい制約をここへ寄せ、画面側では mpv 固有の設定だけを書くこと。
player_playback_controller.dart—PlayerPlaybackController(ChangeNotifier)。mpv のPlayer/VideoControllerを所有し、エラー回復の判定 (player_error.dartの純関数を呼ぶだけ)、表示アスペクト比、二重モノラル、弾幕 on/off、再生位置ストリームを持つ。disposeでPlayerも解放する。Riverpod にはしない — 下節の回転耐性の要件。同上。player_video_view.dart—PlayerVideoView。Video+ モバイル/デスクトップ両方のMaterialVideoControlsテーマデータ +controlsレイヤー内の弾幕スロット + 再生エラーオーバーレイを一つにまとめる。向きには依存せず常に単一のStackを返す。コントロールの差異はPlayerControlsLayout(live/seekable) で表す。ボタン行は media_kit のテーマデータが非 nullable なので、既定のままにする側は分岐で切り替える (既定値を自前で書き写さない)。jikkyo_danmaku_overlay.dart—JikkyoDanmakuLayerが表示矩形の絞り込み、JikkyoDanmakuOverlayが store/clock 共有の描画 (ライブは受信時 birth、録画はbirthMsOf一括登録)。player_status_views.dart—PlayerPlaceholderScaffold/PlayerStatusOverlay/PlayerLoadingView/PlayerRetryView/PlayerErrorOverlay。読み込み・再試行・戻るボタンの土台をライブ/録画で共有する。player_control_buttons.dart—buildPlayerTopButtonBarが戻る→タイトル→字幕→音声→弾幕の並びを組み立てる。画質ボタンはtrailingで渡し、購読するConsumerWidgetを包む (購読すると映像まで作り直され、コントロール自動非表示中のメニュー選択がonSelectedに届かない)。program_info_panel.dart—PlayerInfoTabsが情報パネルのIndexedStack+NavigationBarを共有 (タブ数固定の制約はここに集約)。player_error.dartの純関数 (isPlayerRecovered/hasValidVideoSize/videoDisplayAspectOf/hasValidAudioFormat) とplayer_controls_theme.dartの純関数は移動しない。テスト対象。
画面側に残るのはセッションの種類 (tap/Konomi/HLS keep-alive)、EIT 取得の有無、番組名・チャンネル送り・画質一覧だけ。
プレイヤー画面のレイアウト
映像と情報パネルの配置は features/player/player_split_layout.dart の PlayerSplitLayout に集約する (矩形計算は純関数 playerSplitRects)。
向きで Row/Column を出し分けない。 ウィジェットの型が変わるとその下の Element がすべて作り直され、_LivePlayer / _VideoPlayer の State ごと破棄される。State が持つ PlayerPlaybackController (中の Player) と LiveSession が解放され、PSI 取得とライブ追従が中断する。
これが media_kit のフルスクリーンで映像が黒く止まる (音は出続ける) 直接の原因になる。フルスクリーンは先に Navigator へ同じ VideoController を使う新しい Video を積む (enterFullscreen)。その後に向きが変わると (デスクトップは GTK/ネイティブフルスクリーンでウィンドウサイズが変わる → MediaQuery 更新、Android は setPreferredOrientations で回転)、通常側は破棄されて新しい Player で開き直すが、フルスクリーン側は破棄済みの VideoController を参照したままになる。音声だけは作り直された Player から出て、結果として映像が黒いまま止まる。フルスクリーンから戻るとルートの pop で通常側の新しい Player が見えるので「復活」したように見える。画面回転だけでも同じ作り直しが起きる。
SafeArea で囲む場合も isLandscape ? content : SafeArea(child: content) と戻り値の型を変えない。有効辺 (left/top/…) を切り替えるだけにする。
Conventions
- Doc comments, test names, and user-facing strings are in Japanese. Match this.
- Imports inside
lib/are relative;test/imports viapackage:kurumi/src/.... - Only the TV tab is implemented; the other four tabs render
PlaceholderScreen. The 5-tab order is duplicated incore/router/router.dart(branches) andfeatures/shell/app_destinations.dart— keep both in sync. Breakpoints live inAdaptiveBreakpoints. - Tests cover pure logic only (no widget/golden tests, no network). Keep new logic in testable pure functions rather than widget code.
Native media stack (ARIB 字幕)
プラットフォームごとに libmpv の入手元が違う。編集時は 3 つ全部を見る。
- Linux —
flake.nixがffmpeg-headlessベースの最小構成 ffmpeg (withAribcaptionのみ足す。Android のdefault.shと同方針) + それにリンクした最小構成 mpv を、ルートのmpegts-tsreadex.patch付きでビルドする。このパッチは fileset に含まれていないが、flake の式がpatchesリストで直接参照するので sources には入る。カスタム構成のため初回は ffmpeg/mpv のローカルコンパイルが必要 (バイナリキャッシュなし)。 - Android —
packages/media_kit_libs_android_video(pub.dev 版 1.3.8 のベンダリング) をpubspec.yamlのdependency_overridesで path 差し替えている。そのandroid/build.gradleが 自前のyasamari/libmpv-android-video-buildリリースから.jarを取得し、ffmpeg に libaribcaption を有効化してある。 - iOS / macOS / Windows — pub.dev 版のまま。ARIB 字幕は非対応。
自前ビルドを更新したときは:
cd ../libmpv-android-video-build
# 変更 → commit → push → Actions 完了を待つ
gh release view <tag> --repo yasamari/libmpv-android-video-build
# 新しい .jar を tmp に落とし MD5 を控える
控えた MD5 は packages/media_kit_libs_android_video/android/build.gradle の filesToDownload の URL・md5・destination の $buildDir/<tag>/ の 3 箇所を更新する。書き換えが 1 つでも欠けると Gradle が MD5 verification failed で落ちる。更新後は direnv exec . flutter clean を挟む (古い $buildDir の jar が残る)。
features/player/mpv_options.dart の sub-lavc-o=sub_type=bitmap は libaribcaption の bitmap レンダラ (freetype 必須) を使うため、ARIBCC_NO_RENDERER=ON でビルドした libmpv では字幕が出ない。
KonomiTV の再エンコード画質では字幕が ID3 timed-metadata (TIMED_ID3) で流れ、mpegts-tsreadex.patch が ARIB ペイロード (PRIV/aribb24.js) を読んだ時点で初めて字幕ストリームへ追従する。mpv のトラック一覧は avformat_find_stream_info() の後にしか構築されないため、apply-profile low-latency 由来の demuxer-lavf-probe-info=nostreams (MPEG-TS ではプローブ省略が成立する) と demuxer-lavf-analyzeduration=0.1 をそのまま使うと字幕が一切出ない。同ファイルで両方を上書き (+demuxer-lavf-probe-info=auto / analyzeduration=0) している。低遅延を諦める apply-profile 行を消すのは非推奨。
Dynamic Color
Compose Material 3 の dynamicLightColorScheme() / dynamicDarkColorScheme() を Android 側で直接呼ぶ (android/.../DynamicColorBridge.kt)。返り値をロール単位の ARGB で MethodChannel に送って Flutter 側が割り当てる (lib/src/core/theme/dynamic_color.dart)。
dynamic_color パッケージを Android で使ってはいけない。 同パッケージは android.R.color.system_accent* / system_neutral*_* を受け取って Flutter 側で tonal palette を組み立て直すため、Compose と複数の role でずれる。特に surfaceContainer* / surfaceBright / surfaceDim が ColorScheme.fromSeed(primary) 由来になり、surface / onSurface / inverseSurface が neutral1 基準 (Compose は neutralVariant 基準) になる。Compose 側の実装 (DynamicTonalPalette.android.kt) も API で分岐しており、34+ は system_*_light/dark の role resource を直接読むが、31-33 は neutralVariant 基準の tone にマップし、system_* に無い tone (light の 98/96/94/92/87、dark の 24/22/17/12/6/4) だけを CAM16 + HctSolver で合成する。Flutter 側で再実装しても一致しない。
android/app/build.gradle.ktsのandroidx.compose.material3:material3:1.3.1が要る。UI 部品は参照しないので R8 が落とし、release APK への寄与は 約 +110KB。1.5.0-alphaは compileSdk 37 を要求するので使えない (SDK は android-36 まで)。- 1.3.1 の
ColorSchemeにprimaryFixedなどの fixed role が無い。Flutter 側の既定値に委ねている (本アプリでは未使用)。 background/onBackground/surfaceVariantは Compose にはあるが Flutter では非推奨なので意図的に転送していない (Flutter のフォールバックはsurface/onSurface)。- ロール名は Kotlin の
toRoleMap()と Dart のcomposeDynamicColorRolesで 1 対 1 に保つこと。片方だけ増やすと Dart 側で例外になる。 dynamic_colorは Android 以外 (macOS / Windows / GTK 系 Linux の accent color) のためだけに依存を残してある。Linux 版がnix build対象なので消すと挙動が変わる。
Gotchas
flake.nixbuilds from an explicitfileset(analysis_options.yaml,lib,linux,packages,pubspec.yaml,pubspec.lock, launcher icon). Addingtest/,assets/, or new platform dirs requires updating that list ornix buildbreaks.packages/はdependency_overridesの path 依存なので、ここを抜くと Android ビルド以外でもpub getが失敗する。pubspec.lockに path 依存がrelative: trueで記録される。packages/以下の相対パスを変えたら lock を作り直す。canvas_danmakuを直すには参照先の差し替えが要る。pubspec.yamlは git 依存の rev ピン留めなので、../canvas_danmakuを直しても解決先は~/.pub-cache/git/canvas_danmaku-<rev>/のままで反映されない。作業中はpubspec_overrides.yaml(gitignore 済み) で path 参照に差し替える。フォークを push してpubspec.yamlのref:を上げたら override を削除し、pubspec.lockを git 依存の記載に戻す — 残しておくと lock が../canvas_danmakuを指し、flake.nixの fileset にそのパスが無くnix buildが落ちる。canvas_danmakuの rev を上げたらflake.nixのgitHashesも更新する。buildFlutterApplicationは git 依存をfetchgit(固定出力) で取るので、gitHashes.canvas_danmakuは rev ごとの木ハッシュ。古いままだとnix buildが hash mismatch で落ちる。新しい値はhash = pkgs.lib.fakeHashでnix buildしてエラー出力のgot:を使う (空文字でも.hash を教えてくれる)。- Both backends are plain-HTTP LAN servers. Android is fine —
android/app/src/main/AndroidManifest.xmlalready declaresINTERNETandusesCleartextTraffic="true", andios/macosInfo.plistdeclareNSAllowsLocalNetworking(local network only; internet-bound cleartext stays blocked by ATS). - No CI config exists; verification is local
flutter analyze+flutter test(both currently clean).
