自宅の Jellyfin サーバにラジオ録音とポッドキャストを溜めていて、それを通勤や車の中で聴くための Android アプリを作りました。名前は Kikidame(聴き溜め)。録り溜めた回を、時間のあるときに手元で聴く、というアプリの用途そのものです。
- ソース: https://github.com/t-seki/kikidame (MPL-2.0)
- 入れ方: GitHub Releases の APK、または Obtainium に上の URL を登録
この記事は前半が「何ができるか」、後半が「どう作ったか」です。後半には、再生位置をサーバに送らない設計、多言語化の方式、Jellyfin 10.10 対応で踏んだ穴、そしてコードのほぼ全部を Claude Code に書かせた開発の進め方と、その結果ストアに載せられなかった話を書きます。
何のためのアプリか
Jellyfin には公式アプリ、Findroid、Finamp と良いクライアントがありますが、どれも映像か音楽が中心です。ラジオ録音やポッドキャストのような「回が積み上がる音声」を聴くには、2 つ足りないものがありました。
- 番組単位で自動的に手元に置き、聴き終えたら消すこと
- サーバが落ちていても完全に成立すること
Kikidame はこの 2 点だけを目的にしています。逆に、音楽(回が積み上がらない)とオーディオブック(古い章から順に聴く)は対象外です。
保持ルールで自動ダウンロード
番組ごとに「同期対象にするか」と、2 つの保持ルールを決めます。
- 最新 N 回まで保持 — 公開日の新しい順に N 回を超えた分は手元に置かない
- 再生済みなら削除 — 聴き終えた回は次の同期で消す
あとは 6 時間ごとの同期が、不足分をダウンロードし余剰分を消します。手動でダウンロードした回は「固定」になり、ルールでは消えません。Wi-Fi のみの設定もあります。
オフライン優先
聴くのは常に手元のファイルです。再生位置と再生済みも手元だけで持ち、サーバへは送りません(理由は後述)。電波の無い場所でも、サーバのメンテナンス中でも、アプリの動作は何も変わりません。
再生位置の記録と再開
2 時間のラジオ録音を一気に聴くことはまず無いので、「どこまで聴いたか」の扱いはこのアプリの中心です。
- 再生中は 10 秒ごとに位置を保存し、次に開いたときはそこから始まります。一覧の行にも進捗バーが出ます
- 末尾の 2 分以内まで進んだら再生済みになります。エンディングの曲を飛ばして止めても再生済みになり、次に開くと先頭から。再生済みは手動でも切り替えられます
- 開いて数秒で閉じた回(5 秒未満)は記録しません。⏭ の連打や誤タップで「途中の回」が増えないようにするためです
- 再生記録は、保持ルールでファイルが消えても残ります。再ダウンロードしても続きから聴けます
再開は端末をまたいでも切れません。車の Android Auto で 30 分聴いて降りると、スマホのミニプレイヤーに同じ回が同じ位置で載っています(Auto はスマホの投影なので、実は同じプロセスが動いているだけです)。アプリのプロセスが死んだ後でも、Bluetooth ヘッドセットの ▶ や車との接続で最後の回が続きから始まり、Auto の「続きから」タブには途中の回が番組をまたいで最近順に並びます。
その他
- ±10 秒、倍速(1.0〜2.0)、スリープタイマー(時間指定/この回の終わりまで)
- Android Auto 対応。車の画面で「続きから」「よく聴く」「番組」をたどって再生でき、車を降りたらスマホのミニプレイヤーに同じ回が載っています
- 英語と日本語の UI(Android 13 以上ならアプリごとに切り替え)
- 対応サーバは Jellyfin 10.10 以上
想定は 1 サーバ・1 ユーザー・1 ライブラリで、「聴くのはこのアプリだけ」です。ここから先は、その前提がなぜ設計に効いているかの話です。
設計 1: 再生位置をサーバに送らない
普通に考えると、再生位置はサーバに保存して端末間で同期したくなります。Jellyfin にもそのための API(UserData の PlaybackPositionTicks)があります。最初の設計では「手元が正、オンライン復帰時に一方向で送る」にしていました。
ところが調べると、Jellyfin の音楽ライブラリでは再生位置がほぼ使われません。映像なら「続きから再生」が出ますが、音楽アイテムの resume 挙動はサーバ設定に左右され、2 時間のラジオ録音の位置を預けても、設定次第で失われます。送る先が使わない値を、時計のずれやタイムスタンプの粒度を気にしながら同期する意味は薄い。
そこで「送らない」に倒しました。
- 再生位置と再生済みは Room のテーブル
playback_statesだけが持つ - 「聴くのはこのアプリだけ」という前提を明示し、他端末や Web との併用は想定しない
- 将来 iOS 版や複数端末が要るなら、そのとき送信を復活させる
再生位置の規則は core/domain の純粋関数にまとめてあり、UI・サービス・Android Auto のどこから再生しても同じ判断になります。上で書いた「末尾 2 分」「5 秒」はここの定数です。
object PlaybackRules { val PLAYED_THRESHOLD = 2.minutes // 末尾からこの範囲に達したら再生済み val NOT_STARTED_THRESHOLD = 5.seconds // これ未満で未再生なら「聴き始めていない」 fun isNearEnd(position: Duration, runtime: Duration): Boolean = position > Duration.ZERO && runtime - position <= PLAYED_THRESHOLD /** 位置の更新。末尾付近なら再生済みを立てる。一度立ったら、シークで戻しても自動では下ろさない */ fun advance(state: PlaybackState, position: Duration, runtime: Duration, now: Instant) = state.copy(position = position, played = state.played || isNearEnd(position, runtime), updatedAt = now) /** 再生開始時の位置。残りが閾値以下なら先頭から、そうでなければ保存位置から */ fun resumePosition(position: Duration, runtime: Duration): Duration = if (isNearEnd(position, runtime)) Duration.ZERO else position }
Android 側はこれを呼ぶだけです。PositionPersister が Player.Listener で 10 秒ごとに advance を Room に書き、再生画面も Auto の onSetMediaItems も resumePosition で開始位置を決めます。規則が 1 箇所にあるので、後から Auto を足したときに「同じ規則で記録される」ことを新しく検証する必要がありませんでした。
もう一つ、この設計で効いたのが再生記録のライフサイクルです。手元のファイルが保持ルールで消えても、再生記録は残します。消えると「再生済みの回が、次の同期で再ダウンロードされ、また再生済みなので消える」という往復が起きるからです。記録は各回がサーバに存在する限り生き、ファイルとは別の寿命を持ちます。
設計 2: 多言語化 — 文字列を解決するのは Compose だけ
最初は UI の文字列が Kotlin に日本語で直書きされていました(241 箇所)。英語圏の Jellyfin ユーザーにも使ってもらうために strings.xml に出したのですが、問題は Composable の外で作る文言でした。同期のスナックバー(「各回 12 を取得しました」)、ViewModel のエラー、再生失敗の通知など、約 70 箇所が String を Flow で流していました。
選択肢は 2 つです。
- (A)
Contextを注入して作る側でgetStringし、Stringのまま流す - (B)
UiText(Res(id, args)/Plural/Plain/Joinedの sealed interface)を流し、表示する Composable がresolve()する
(B) の UiText はこれだけの型です。
sealed interface UiText { data class Plain(val text: String) : UiText data class Res(@StringRes val id: Int, val args: List<Any>) : UiText data class Plural(@PluralsRes val id: Int, val quantity: Int, val args: List<Any>) : UiText data class Joined(val parts: List<UiText>, val separator: UiText = Plain("")) : UiText } @Composable fun UiText.resolve(): String = when (this) { is UiText.Plain -> text is UiText.Res -> stringResource(id, *args.toTypedArray()) is UiText.Plural -> pluralStringResource(id, quantity, *args.toTypedArray()) is UiText.Joined -> parts.map { it.resolve() }.joinToString(separator.resolve()) }
作る側はこう書きます。Context は出てきません。
// LibraryRefresher(同期の結果をスナックバーへ) say(UiText.Plural(R.plurals.sync_program_checked, episodes)) // → 英語 "Checked 242 episodes"、日本語「各回 242 を確認」 // 表示側(Composable) LaunchedEffect(Unit) { viewModel.messages.collect { snackbarHostState.showSnackbar(it.resolve(context)) } }
(A) は Android でも普通に使われる現実的な方法で、既存の型を変えずに済みます。それでも (B) にしました。決め手はテストです。(A) だと 150 箇所の日本語アサーションを context.getString(...) に書き換えて Robolectric に載せることになり、テストの意味は変わらない。(B) なら assertEquals(UiText.Res(R.string.sync_fetched, 12), actual) で「どのメッセージが、どの引数で選ばれたか」を検証でき、プレーン JVM のままです。ついでに、保持されているエラー文言が言語切替に追従するようになり、Service・Worker・UI が共有するシングルトンに Context を混ぜずに済みました。
作る側 5 つ、出す側 5 箇所の改修で、241 箇所の抽出本体に比べれば 1 割程度の上乗せでした。
英語の文言は、CONTEXT.md(用語集)に各用語の英語名を先に決めてあったので、Program / Episode / Publisher / Pinned / Starred / On Hold / Gone のように機械的に揃えられました。用語集を先に作っておく効果は、多言語化のときに一番はっきり出ます。
設計 3: Jellyfin 10.10 で踏んだ穴
対応範囲を「10.10 以上」と宣言する以上、10.10 で本当に通るかを確かめる必要があります。jellyfin-sdk-kotlin 1.9 は Jellyfin 12 向けで、古いサーバの応答をデコードできない箇所があります。
コンテナで 10.10.7 と 12.0.0 を立て、合成ライブラリ(ffmpeg で作った無音の m4a にタグを付けたもの)を入れて、JellyfinGateway の全メソッドを実サーバに当てる統合テストを書きました。環境変数が無ければスキップされるので、CI の ./gradlew test はそのままです。
落ちたのは 1 箇所でした。番組の存在確認に GET /Items/{id} を使っていたのですが、この API は MediaSources 込みの全フィールドを返します。10.10 は MediaStream.IsOriginal を返さず、SDK 1.9 はそれを必須と見なすので、デコードで落ちる。
// 変更前: 全フィールドが返り、10.10 では MediaStream.IsOriginal の欠落でデコードに失敗する val album = try { api.libraryApi.getItem(UUID.fromString(id)).content } catch (e: InvalidStatusException) { if (e.status == 404) return null else throw e } if (album.type != BaseItemKind.MUSIC_ALBUM) return null // 変更後: 基本フィールドだけ返り、無い ID・番組でない ID は空の一覧になる val result = api.libraryApi.getItems( GetItemsRequest(ids = listOf(UUID.fromString(id)), includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM)), ).content return result.items.firstOrNull()?.toServerProgram()
「404 なら消失」だった判断が「空なら消失」に変わっただけで、決定そのものは同じでした。
このとき、設計の決定記録(ADR)に「/Items/{albumId} を取り、404 なら Gone」と旧実装の詳細が書いてあったのをレビューで拾われ、書き直しています。決定記録に実装の詳細を書きすぎると、こういう保守が要ります。
Android Auto で踏んだ穴
Media3 の MediaLibraryService で Auto に対応したときに 2 つ。
- 各回の 2 行目に公開日を出そうと
MediaMetadata.subtitleを設定しても、Auto には番組名が出る。Media3 のLegacyConversionsはdisplayTitleが無いと title → artist → album の順で 3 行を埋め、subtitleを捨てます。ブラウズ用の項目にだけdisplayTitleを置くと通ります - 「最後に聴いていた回を再開する」
onPlaybackResumptionを実装しても、システムからは一切呼ばれない。原因は Manifest にandroidx.media3.session.MediaButtonReceiverが宣言されていないこと。Media3 はこのレシーバの有無で再開機能ごと有効・無効を切り替えます(canResumePlaybackOnStart()の中身はbroadcastReceiverComponentName != nullだけ)。バイトコードを読むまで分かりませんでした
<!-- これが無いと Media3 は再開(SystemUI の isRecent / onPlaybackResumption)を有効にしない --> <receiver android:name="androidx.media3.session.MediaButtonReceiver" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MEDIA_BUTTON" /> </intent-filter> </receiver>
再開そのものは、Room から「最近聴いた回」を 1 件引いて、アプリの再生画面と同じ番組のキューを組むだけです。
override fun onPlaybackResumption(session: MediaSession, controller: ControllerInfo) = scope.future { val recent = libraryRepository.getRecentlyListened(limit = 1).firstOrNull() ?: return@future MediaItemsWithStartPosition(emptyList(), C.INDEX_UNSET, C.TIME_UNSET) programQueueStartingAt(recent) // 番組の手元にある回を古い順に積み、resumePosition から }
車が無くても Desktop Head Unit(DHU)で確認できます。WSL2 で動かすには libc++ を入れ、stdin を閉じないように起動する、初回はスマホ側の許可画面を 5 秒以内に進める、といった細かい罠があり、docs/development.md にまとめてあります。
Claude Code で書いた、という話
ここからは開発の進め方です。正直に書くと、Kotlin のコードはほぼ全部 Claude Code が書いています。私は Java と C# の経験はありますが Kotlin と Compose は学習中で、自分で書いたコードはほとんどありません。代わりに、次のことを人がやりました。
決めること。 用語集(CONTEXT.md)を先に作り、「番組」「各回」「配信元」「固定」「判断保留」のような言葉の意味と、使わない言葉(「アルバム」「トラック」「お気に入り」)を決めました。設計上の分岐は、着手前に Claude と一問一答で潰し(推奨案を出させ、私が選ぶ)、決定は ADR に落とします。この記事の「設計 1〜3」は、その ADR をほぼそのまま書き起こしたものです。
分けること。 実装は issue 単位で subagent に振り、それぞれが git worktree で別ブランチを切ります。同時に 2 本走らせるときは「触るファイル」を先に分け、衝突は strings.xml の追記だけになるように issue に書きます。
レビューを回すこと。 PR が来たら、別の read-only な subagent に 5 つの観点(規約、バグ、履歴、過去のレビュー指摘、コード内コメントとの整合)でレビューさせ、指摘を実装側に戻します。このレビューで拾われた本物のバグがいくつもあります。たとえば「最近聴いた回」を SQL で limit * 2 件引いてから Kotlin で絞る実装は、limit = 1 のとき上位 2 件が条件外だと空になる — 2 人のレビュアーが独立に同じ指摘をしました。閾値を SQL のバインドパラメータに渡す形に直しています。
実機で確かめること。 adb でスクリーンショットを撮って Claude に読ませ、DHU は名前付きパイプで tap x y を送って操作しました。IzzyOnDroid 向けのスクリーンショットは、実機に一時的な別ユーザーを作り(pm create-user。メインユーザーのデータは触らない)、テスト用コンテナに英語の架空の番組を入れて撮っています。
うまくいかなかったことも書いておきます。Claude は一度、Android Auto の再開が効かない原因を「通知コントローラにライブラリコマンドが無いから」と推定して直したのですが、実機で試すと変わらず、真因は上に書いた MediaButtonReceiver でした。推定は「実機で確かめるまで信じない」が正解で、実機に入れる手順が軽いことがそのまま品質になります。
ストアには載せられなかった
配布は GitHub Releases と Obtainium です。当初は IzzyOnDroid(開発者署名の APK をそのまま配る F-Droid 系のリポジトリ)に載せる予定で、Fastlane の説明文とスクリーンショットまで用意しました。ところが申請の直前に App Inclusion Policy を読むと、こうあります。
We are strongly opposed to apps which are fully or in part created by generative AI tools. Vibe-coded apps will be rejected.
申請テンプレにも「AI 使用レベル(None〜Dominant)」の必須項目があります。正直に書けば Dominant で、却下対象です。虚偽の申告はしたくないので、申請しませんでした。F-Droid 本家も同じ方針です。
ストア側の判断は理解できます。人が読んでいないコードが大量に流れ込めば、審査は成り立ちません。一方で、用語集と ADR で決定を人が持ち、レビューと実機確認を回した上で出しているコードと、プロンプト一発のものが同じ扱いになるのは、今の段階では仕方がないとも思います。F-Droid クライアントから入れたい人のために、自前のリポジトリを GitHub Pages で配ることを次の候補にしています。
まとめ
- 用途を「回が積み上がる音声」に絞ると、再生位置をサーバに送らない・手元のファイルだけで再生する、という割り切りができる
- 文言の解決を Compose に寄せると、多言語化がテストと両立する
- 対応サーバの版は、コンテナで実サーバに当てるテストで担保する
- Claude Code に書かせるなら、人は「言葉と決定」を持ち、レビューと実機確認を仕組みにする。そして、それでもストアの AI ポリシーには引っかかる
ソースと課題は https://github.com/t-seki/kikidame にあります。Jellyfin にラジオ録音を溜めている人は、試してもらえると嬉しいです。


