Android implementation of shepherd-media #72

Merged
albert merged 68 commits from u/albert/70/shepherd-media-android into main 2026-07-09 02:49:14 +00:00
Owner

Fixes #70

Relies on #71's Android deps helper to land first

Fixes #70 Relies on #71's Android `deps` helper to land first
The Linux shepherd-media binary is stateless: shepherdd spawns it per
activity with a --library argument plus flags, so there is nothing to
persist. The Android build has no shepherdd and only one install per
device, so it must keep its own state: the configured libraries, each
library's caching options, and which one is currently selected.

This new crate is the home for that state. It is pure Rust with no UI,
network, subprocess, or Android dependency, so it is testable on the
desktop and reusable by any platform binary:

- AppSettings / LibraryEntry / LibrarySource: the persisted model, with
  source kinds (saf-toml, http-toml, m3u, youtube-playlist) mirroring the
  dispatch the Linux binary already does on its --library argument.
- Library management: add / remove / reorder / select-active with
  validation and typed errors; atomic TOML load/save.
- Caching policy: CacheMode (mirrors the two VideoCache strategies),
  PosterPolicy, and Quality (ytdl_format kept identical to the Linux CLI).

Also captures the full Android architecture design under docs/ai/history.

Step 1 of the Android build; see that design doc for the remaining steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Step 2 of the Android build. A new shepherd-media-android crate provides
the Android app as a cdylib that the APK loads via NativeActivity:

- MediaApp: cross-platform eframe/egui UI over shepherd-media-core and
  shepherd-media-app. Library switcher, settings page (add / remove /
  reorder / select-active, plus per-library cache mode, quality, poster
  policy, and size cap), an add-library form, and a placeholder browse
  grid. All mutations go through AppSettings, which persists to the app's
  private storage automatically.
- StubPlayer: a no-op PlayerHandle standing in until the libmpv backend
  lands, so the session wiring can be exercised without a decoder.
- android_main entry behind cfg(target_os = "android"); the same UI runs
  on the host via the desktop_preview example for fast iteration.
- A pure-NativeActivity Gradle project under android/ whose preBuild runs
  cargo-ndk and stages the .so into jniLibs, producing an installable APK
  (Rust -> cargo-ndk -> AGP).

Verified end to end: host build/clippy/tests clean, the cdylib
cross-compiles for aarch64-linux-android (exports android_main /
ANativeActivity_onCreate), and ./gradlew assembleDebug produces an
app-debug.apk with the arm64-v8a library packaged inside.

Android-only deps (android-activity, android_logger, jni) are target-gated
so the host workspace build never pulls the NDK-linked crates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Add a resolve module that turns a configured LibrarySource into a parsed
shepherd_media_core::Library, and wire the grid screen to show the real
items instead of a placeholder.

- resolve(): local/file:// TOML, HTTP(S) TOML, and .m3u/.m3u8 (local or
  HTTP) via ureq+rustls. content:// SAF and YouTube sources return a typed
  Unsupported error until their bridges land. A saf-toml/m3u source whose
  locator is a plain path or file:// URI is read directly, so the desktop
  preview can load a real local library.
- Resolution runs on a worker thread: Android throws
  NetworkOnMainThreadException for network on the UI thread. The grid holds
  a Loading(Receiver) / Loaded(Library) / Failed(String) state, polls the
  worker, and requests a repaint when the result lands. The result is
  cached per library so re-opening doesn't re-resolve.
- Grid screen renders item title/kind/category with a loading spinner and
  error state.

ureq uses rustls (no OpenSSL), matching the Linux binary, so it
cross-compiles cleanly for Android. Verified: host clippy/9 tests clean,
the crate (incl. rustls/ring) cross-compiles for aarch64-linux-android,
and the APK still builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Add a posters module that loads poster bytes (local file or HTTP) and
decodes them (JPEG/PNG/WebP via the image crate) on worker threads, then
the UI thread uploads them as egui textures. Honors PosterPolicy: Never
skips; WifiOnly behaves like Always until the connectivity JNI bridge
exists.

The grid renders a thumbnail per item (spinner while loading, kind glyph
as fallback). Decoded posters arrive over an mpsc channel and are cached
per item id.

Posters are fetched per session (not yet persisted to an on-disk cache
with the per-library size cap; that's a follow-up).

Verified: host clippy/13 tests clean, the crate (incl. the image
decoders) cross-compiles for aarch64-linux-android, and the APK builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Reuse core's existing libmpv PlayerHandle (and its GL render path) on
Android instead of reimplementing it:

- Vendor a prebuilt arm64 libmpv.so + its ffmpeg dependencies under
  vendor/libmpv/arm64-v8a/, extracted from the dev.jdtech.mpv:libmpv AAR.
  build.rs adds that dir to the link search path so the -lmpv emitted by
  libmpv2-sys resolves; android/app/build.gradle.kts packages the same
  .so into the APK's jniLibs.
- Enable core's libmpv feature for the Android target only, and select
  the player by platform: LibmpvPlayer on Android, StubPlayer on the host
  (so the playback path still compiles and runs in the desktop preview).
- The player is constructed and GL-bound once at startup, when eframe
  exposes the proc-address loader, and reused across libraries/items.
- Add playback.rs: a touch/keyboard PlaybackView adapted from the Linux
  binary's ui/playback.rs that composites mpv's GL output into the eframe
  surface with a play/pause, +/-10s, scrub, and back overlay.
- Grid items are now playable: tapping resolves the item's platform
  source and hands it to the player; EOF/close/error returns to the grid.

Verified: host clippy and 13 tests clean; the cdylib cross-compiles for
aarch64-linux-android and now has NEEDED libmpv.so; the 37 MB APK packages
the cdylib plus libmpv + ffmpeg + libc++_shared. On-device video playback
has not been run on hardware yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Posters were re-fetched over HTTP on every launch. Add a PosterCache that
stores remote posters under the app's cache dir with a 6-hour TTL and a
stale-as-offline-fallback policy (mirroring the Linux binary): a fresh
entry is served without touching the network, a stale entry triggers a
refresh but is reused if that refresh fails, and local posters are read
straight from their path.

The cache is cheap to clone, so each poster worker thread gets its own
handle. MediaApp now takes a cache_dir (the app's internal cache dir on
Android, a temp dir in the desktop preview).

Verified: host clippy and 15 tests clean; cross-compiles for
aarch64-linux-android and the APK builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Make the per-library cache mode and size cap (already in the settings UI)
functional. A new VideoCache, rooted per library under the app cache dir:

- Playback prefers a cached local copy: start_playback consults the cache
  for direct-http sources and, on a hit, hands the player a Local source.
- Download-after-play: when an item finishes and the library's cache mode
  is not Off, the source URL is downloaded on a worker thread so the next
  play is local.
- LRU eviction by file mtime (touched on each cache hit) keeps the
  directory within the per-library byte cap; downloads go to a .part file
  and are renamed into place.

Only direct-http sources are cached (file:// is already local; YouTube
needs yt-dlp). QueueAll's eager prefetch-at-launch is still TODO, so both
non-Off modes currently cache after play.

Verified: host clippy and 20 tests clean (path/LRU/eviction covered);
cross-compiles for aarch64-linux-android and the APK builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Add YouTube support via youtubedl-android (Android)
Some checks failed
CI / ShellCheck (pull_request) Successful in 10s
CI / CI image (pull_request) Successful in 28s
CI / Rustfmt (pull_request) Successful in 7s
CI / Test (pull_request) Successful in 4m19s
CI / E2E (pull_request) Successful in 4m24s
CI / Build (pull_request) Successful in 4m29s
CI / Clippy (pull_request) Successful in 4m23s
CI / Firewall E2E (pull_request) Failing after 2m11s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 31s
5289977cd7
YouTube playlists now resolve into browseable libraries and YouTube items
play through libmpv, both driven by yt-dlp:

- New youtube module. The parsing is pure and host-tested: yt-dlp
  --dump-json --flat-playlist NDJSON -> playlist entries, and -g output ->
  a direct stream URL. Execution is behind a YtDlp trait (a fake backs the
  unit tests).
- resolve(): a youtube-playlist source runs yt-dlp flat-playlist and feeds
  core's build_library_from_entries, so the grid shows real items with
  thumbnails.
- Playback: tapping a YouTube item resolves its stream URL on a worker
  (best[height<=?720] for a single progressive URL, since mpv's ytdl hook
  can't run without yt-dlp on PATH), then plays a DirectHttp source. A
  "Resolving…" state covers the wait. YouTube items aren't video-cached
  (signed URLs expire).
- On Android the YtDlp provider bridges to youtubedl-android over JNI
  (jni + ndk-context); the AAR (bundled Python yt-dlp) is a Gradle
  dependency, packaged into the APK. Dropped hasCode="false" since the APK
  now carries the youtubedl-android classes, and set useLegacyPackaging
  for the extractable Python libs.

Verified: host clippy and 27 tests clean; cross-compiles for
aarch64-linux-android; the APK builds online and packages libpython +
the youtubedl classes alongside libmpv. The JNI execution path is
unverified on hardware (see the caveat in src/youtube.rs, incl. the
FindClass-from-native-thread classloader gotcha).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSXDqEiPCKyssHQX9t8HGM
Opening a YouTube library aborted the process (SIGABRT) on the resolver
worker thread: the implicit FindClass behind a class-name lookup resolves
against the bootstrap classloader on a Rust-spawned thread, which can't see
the app's DEX classes, so it threw ClassNotFoundException for
com.yausername.youtubedl_android.YoutubeDL and the next NewStringUTF aborted
via CheckJNI.

Resolve the youtubedl-android classes through the application classloader
(Context.getClassLoader().loadClass(...)) and call through the resulting
jclass, and clear any pending exception if getInstance fails during init so
it can't poison a later JNI call.

Verified on a Pixel 10a (Android 16, arm64-v8a): the playlist now resolves
with titles + thumbnails and the stream URL resolves over JNI. Also records
the on-device validation of libmpv playback and caching in docs/ai/history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
Follow-up to the youtubedl-android JNI crash fix, from continued on-device
validation on a Pixel 10a:

- Surface yt-dlp failures instead of crashing. A YoutubeDLException (e.g.
  "Requested format is not available", private/blocked video, network) was
  propagating uncaught from the resolver worker thread and killing the process
  via a Java FATAL EXCEPTION, because the pending JNI exception wasn't cleared
  before the thread detached. Funnel throwing calls through
  take_pending_exception, which reads the message and clears the exception so it
  becomes a recoverable Err shown in the status bar.

- Support separate video + audio streams. YouTube no longer reliably offers a
  progressive muxed file, so resolve bv*[vcodec^=avc1]+ba (preferring H.264,
  which the device decodes; the best DASH video is usually VP9/AV1 and renders
  black) and hand the audio URL to the player as an external track via the new
  PlayerHandle::set_external_audio (libmpv attaches it through length-quoted
  loadfile options so commas/colons in the URL are safe).

- Add an env-gated verbose libmpv log (SHEPHERD_MPV_LOG) for on-device debugging.

On-device finding: with the bundled youtubedl-android 0.18.1, `yt-dlp -F`
returns only audio-only formats (itag 139) for these videos — YouTube gates
video behind PO tokens/SABR for that client — so video can't yet be obtained.
The plumbing here is correct and will produce video once the dependency serves
a video format. Details in docs/ai/history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
The youtubedl-android AAR is already the latest published release (0.18.1), so
there is no dependency version to bump. The only lever — updating the bundled
yt-dlp at runtime via YoutubeDL.updateYoutubeDL(NIGHTLY) — throws
ExceptionInInitializerError from inside the library's own updater on device, so
it can't replace the binary. Getting YouTube video needs an upstream change
(newer/forked youtubedl-android or a self-managed yt-dlp) plus a PO-token
provider; the runtime-update experiment was reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
Play YouTube video on Android: android_vr client + in-app yt-dlp refresh
Some checks failed
CI / CI image (pull_request) Successful in 39s
CI / ShellCheck (pull_request) Successful in 10s
CI / Rustfmt (pull_request) Successful in 13s
CI / Clippy (pull_request) Successful in 4m12s
CI / E2E (pull_request) Successful in 4m25s
CI / Build (pull_request) Successful in 4m29s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 36s
CI / Firewall E2E (pull_request) Failing after 3m34s
CI / Test (pull_request) Successful in 7m21s
2151f96810
YouTube playback previously yielded audio-only (black video): the default
`android` player client only returns a PO-token-gated audio itag, and the
yt-dlp bundled in the youtubedl-android AAR is too old (its updateYoutubeDL
also throws). Two changes, verified end-to-end on a Pixel 10a:

- Resolve streams with the `android_vr` player client
  (--extractor-args youtube:player_client=android_vr). It serves the full DASH
  ladder (H.264 video + audio) with no PO token, so no BotGuard/DroidGuard
  token generator is needed. Confirmed via `yt-dlp -F`.
- Refresh yt-dlp in-app: after YoutubeDL.init(), download the latest yt-dlp
  zipapp from GitHub and atomically replace the AAR's payload at
  <noBackupFilesDir>/youtubedl-android/yt-dlp/yt-dlp (ureq+rustls HTTPS,
  weekly, best-effort, sanity-checked), bypassing the broken updateYoutubeDL.
  youtubedl-android runs that file with its bundled Python and doesn't clobber
  it.

The existing separate-stream path (set_external_audio) hands H.264 video +
audio to libmpv, which hardware-decodes via MediaCodec and renders (verified
visually; note `adb screencap` cannot capture the hardware video overlay, so
it shows black even while video plays — trust the `video=playing` decode log).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
The app was touch/keyboard only. egui 0.34 already moves focus with the arrow
keys (Android maps the D-pad to them), but it can't bootstrap focus from
nothing, so the remote did nothing on the menus. Wire it up for 10-foot use:

- Keep a widget focused at all times (move_focus(Next) when nothing is focused),
  which also auto-focuses the first control whenever a screen appears.
- Make the focus highlight unmistakable on a TV: a focused widget renders with
  widgets.active, so give that a bright fill + thick white ring + expansion.
- BACK navigates up the screen stack. Android delivers BACK as BrowserBack (not
  Escape/Backspace, confirmed on device); Grid/Settings -> Switcher, AddLibrary
  -> Settings. The center button arrives as Enter and activates the focused
  widget.
- Scroll the focused grid item into view as the D-pad moves.
- Playback overlay: center (Enter) toggles play/pause and BACK (BrowserBack)
  leaves, alongside the existing ◄/► seek.

Verified on a Pixel 10a driving the full loop (switcher -> grid -> play ->
transport -> back) with adb D-pad keyevents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
The Android browse screen was a placeholder vertical list; the Linux binary has
a much nicer responsive poster grid. Extract that grid (and its theme) into a
new platform-agnostic crate, shepherd-media-ui, and render it on both:

- New crate shepherd-media-ui: the poster grid (220x280 tiles, aspect-correct
  posters, focused-tile highlight, kinetic drag-scroll, scroll-to-focus) plus
  theme. Depends only on egui + shepherd-media-core. `grid::draw` returns the
  selected item id; the caller owns input, poster fetching, and playback. The
  egui_extras image loader is installed by each binary.
- shepherd-media (Linux): use the shared grid via `shepherd_media_ui::grid`;
  delete the local copy; drop the two theme consts only the grid used.
- shepherd-media-android: replace the list with the shared grid. Posters now
  flow as encoded bytes (egui_extras decodes/uploads on demand) instead of
  hand-decoded textures. The grid's focus is an index moved by the D-pad/arrow
  keys (its tiles are custom-painted, so egui's own focus is skipped on this
  screen); center/Enter or a tap selects. Install egui_extras image loaders.

Verified on a Pixel 10a: HTTP and YouTube libraries render as poster grids
(YouTube thumbnails letterboxed correctly), D-pad moves the highlight,
center plays, BACK returns to the switcher. Linux + Android both build;
host + android-target clippy and tests clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
Was com.armeafamily.shepherdmedia. Updated the Gradle namespace and
applicationId (the only references; nothing in the Rust, manifest, or other
config hardcodes the id). Verified on device: builds, installs, and launches as
com.armeafamily.shepherd.media.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
Collapse switcher header; Settings top-right; focus first library
Some checks failed
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 22s
CI / Rustfmt (pull_request) Successful in 11s
CI / Clippy (pull_request) Failing after 2m5s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 36s
CI / Firewall E2E (pull_request) Successful in 3m45s
CI / Build (pull_request) Successful in 7m4s
CI / Test (pull_request) Successful in 7m7s
CI / E2E (pull_request) Successful in 7m33s
811c9c1a69
On the library switcher, merge the "shepherd-media" title row and the
"Libraries / Settings" row into a single top row: app title (and any status) on
the left, the Settings button on the right. The switcher now draws this itself
(the shared top bar is skipped for this screen).

Focus behavior for the D-pad: the first *library* is focused on entry instead of
Settings. Since egui's spatial focus can't connect the top-right Settings button
to the left library column, Up-from-first-library and Down-from-Settings are
wired explicitly (cancelling egui's own pending move so it doesn't overshoot),
keeping Settings reachable by remote.

Verified on a Pixel 10a: single header row, Settings top-right, first library
focused on launch, Up→Settings, Down→first library, Down within the list works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KM9jpEseaWh4gzK2F2N1h8
Author
Owner

the icon for this app should be the same shield from the BLE management app (#71) but with a play triangle inside

the icon for this app should be the same shield from the BLE management app (#71) but with a play triangle inside
# Conflicts:
#	Cargo.lock
The shepherd-media-android cdylib cross-compiles for aarch64-linux-android
via cargo-ndk, which needs an installed NDK. Add ndk;27.2.12479018 (the
version the crate is validated against) to the sdkmanager package set in
install_android_sdk, so `./scripts/shepherd deps install android` provisions
it on a dev host — and, because .ci/Dockerfile.android runs that same command
and hashes deps.sh into its image tag, the CI Android image rebuilds and bakes
the NDK in too. is_android_sdk_installed now also verifies the NDK dir, and the
installer prints the ANDROID_NDK_HOME to export. Docs updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
android-portability only cargo-checks shepherd-media-core; nothing in CI
exercised the actual device build of the shepherd-media-android cdylib. Add
an android-media job that cross-compiles it for aarch64-linux-android via
cargo-ndk — the same command android/app/build.gradle.kts's cargoNdkBuild
task runs — so a break in the Rust device build (or its link against the
vendored libmpv) fails CI without paying for the full AGP/APK assembly.

It runs on the Android CI image for the prebuilt NDK; cargo-ndk and the
aarch64-linux-android target are installed per run and cached so they cost
at most once per Cargo.lock. ANDROID_NDK_HOME is pointed at the image's
$ANDROID_SDK_ROOT/ndk so the build never relies on auto-detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The android-media job is a compile-and-link gate, not a producer of a
shippable artifact, so a debug build catches the same breakage in roughly
half the wall-clock time (2m22s vs 3m16s cold on the reference host). Drop
--release from the cargo-ndk invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test: cover shepherd-media-ui grid helpers
Some checks failed
CI / Version harmony (pull_request) Successful in 6s
CI / ShellCheck (pull_request) Successful in 9s
CI / CI image (pull_request) Failing after 23s
CI / CI image (Android) (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Test (pull_request) Has been skipped
CI / E2E (pull_request) Has been skipped
CI / Clippy (pull_request) Has been skipped
CI / Rustfmt (pull_request) Has been skipped
CI / Firewall E2E (pull_request) Has been skipped
CI / Android portability (shepherd-media-core) (pull_request) Has been skipped
CI / Android companion (unit tests) (pull_request) Has been skipped
CI / Android media (cargo-ndk build) (pull_request) Has been skipped
34ac4d4304
The shared poster-grid crate had no tests. Add unit tests for its two pure
helpers: initials() (the placeholder shown when a poster is missing) and
fit_centered() (the aspect-preserving letterbox/pillarbox that keeps a 16:9
thumbnail from being squashed into the tile's image slot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shepherd-media/src/ui/theme.rs duplicated shepherd-media-ui's theme
constants and install() verbatim (identical RGB values and body), even
though the Linux binary already links shepherd-media-ui for the poster
grid. Point ui/mod.rs and ui/playback.rs at shepherd_media_ui::theme and
delete the copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Quality enum and its ytdl_format() selectors were duplicated byte-for-byte
in shepherd-media/src/cli.rs and shepherd-media-app/src/quality.rs, guarded
only by a drift-detecting test. Make shepherd-media-app the single definition:
add an optional `clap` feature that derives ValueEnum (so the enum doubles as
the Linux `--quality` value type) and have the Linux binary depend on
shepherd-media-app with that feature, re-exporting Quality from cli.rs.

The Android cdylib doesn't enable the feature, so it never builds clap.
Android's own YouTube stream selector stays separate by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both front-ends independently implemented the same pure parser for
`yt-dlp --dump-json --flat-playlist` NDJSON (YtDlpEntry + PlaylistInfo +
parse loop), each feeding core's build_library_from_entries. Move that
parser (and PlaylistInfo) into shepherd-media-core::youtube_playlist next
to the entry type it produces, and have both binaries call it. The three
pure-parse unit tests move to core; the Android crate keeps only the
provider-wiring test. yt-dlp invocation (Linux subprocess vs Android JNI)
and the Linux-only on-disk playlist cache stay platform-specific.

Drops now-unused serde/serde_json deps from the Android crate; adds
serde_json to core (pure JSON, no network — consistent with the module's
no-I/O design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both front-ends independently implemented the same URL-hash-keyed poster
disk cache (6h TTL, write-back, and the #64 offline stale-fallback). Extract
that into shepherd-media-app::poster_cache::RemotePosterCache — dir + TTL +
a resolve() that serves fresh entries, fetches on miss/stale, and falls back
to stale bytes when the fetch fails. Networking and image decoding stay at
the call sites (the Linux binary's ureq fetch + lazy egui loader; the Android
app's capped download + worker-thread decode), which now just wrap the shared
cache.

The offline stale-fallback tests move to shepherd-media-app and cover both
platforms at once — closing the previously untested Android fallback branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both video caches independently implemented the same evict-to-cap loop
(sum sizes, sort by mtime ascending, delete oldest until within the byte
cap). Extract it as a generic shepherd_media_app::lru::evict_to_cap over
LruEntry { path, size, recency }, with an on_evict hook for paired
bookkeeping. The Linux binary passes SystemTime recency and uses the hook
to drop the paired .done sentinel + log; the Android app passes FileTime
recency and a no-op hook.

The rest of each cache stays platform-specific by design: the caches key
files differently (item id + .done sentinels vs URL hash), and the ureq
download/directory-scan logic differs enough that sharing it would need
more parameters than it removes (and would pull ureq into this crate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "serve fresh, else fetch, else fall back to stale" decision was written
out in each cache. Extract it once as the generic
shepherd_media_app::cache::resolve over Resolution<T> + Freshness. The poster
cache's Resolution becomes Resolution<Vec<u8>>, and the Linux playlist
metadata cache (the remaining hand-written copy, with its own CacheFreshness
enum) now goes through the same helper — keeping its JSON write-back and
offline warning via the returned variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into u/albert/70/shepherd-media-android
Some checks failed
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 20s
CI / Clippy (pull_request) Failing after 3m30s
CI / Rustfmt (pull_request) Successful in 9s
CI / E2E (pull_request) Failing after 4m10s
CI / Build (pull_request) Failing after 4m31s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 48s
CI / CI image (Android) (pull_request) Successful in 5m10s
CI / Android companion (unit tests) (pull_request) Successful in 1m29s
CI / Test (pull_request) Failing after 6m48s
CI / Firewall E2E (pull_request) Successful in 4m3s
CI / Android media (cargo-ndk build) (pull_request) Successful in 3m15s
bbe81a7896
The shepherd-media-android app shipped no icon, so it showed the default
Android launcher glyph. Give it an adaptive icon matching the management
companion app — the same white guardian shield on the green (#1B5E20)
background — but with a right-pointing play triangle in place of the
companion's checkmark, and reference it from the manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump yoke 0.8.2 -> 0.8.3 to fix a transitive build break
Some checks failed
CI / Version harmony (pull_request) Successful in 6s
CI / ShellCheck (pull_request) Successful in 9s
CI / CI image (pull_request) Successful in 21s
CI / CI image (Android) (pull_request) Successful in 40s
CI / Rustfmt (pull_request) Successful in 6s
CI / Firewall E2E (pull_request) Failing after 2m54s
CI / Test (pull_request) Failing after 4m5s
CI / Clippy (pull_request) Failing after 4m1s
CI / E2E (pull_request) Failing after 4m16s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 49s
CI / Android companion (unit tests) (pull_request) Successful in 56s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m29s
CI / Build (pull_request) Successful in 15m2s
9fcdafa186
yoke 0.8.2 compiles src/zero_from.rs whenever its `zerofrom` feature is on,
but that module uses `stable_deref_trait`, which yoke only pulls in via its
separate `alloc` feature. The workspace's feature unification resolves yoke
as `derive,zerofrom` (no `alloc`), so a fresh full-workspace build fails with
`can't find crate for stable_deref_trait` (seen in the Clippy CI job). yoke
0.8.3 makes stable_deref_trait a non-optional dependency, so it is always
available regardless of the `alloc` feature.

Lock-only change (only yoke moved). Verified with the CI command
`cargo clippy --all-targets -- -D warnings` across the whole workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ -0,0 +1,411 @@
//! Playback view: composites the player's GL output into the eframe surface and
//! draws a touch-friendly control overlay.
//!
//! This is adapted from the Linux binary's `ui/playback.rs`, trimmed to touch +
Author
Owner

I'm not sure why you couldn't just import it

I'm not sure why you couldn't just import it
Author
Owner

this was cleaned a ton in 7175688376

this was cleaned a ton in 7175688376
albert marked this conversation as resolved
The media app rendered as a plain rectangle, so on phones with a camera
cutout or rounded corners the browse UI could sit under the camera or be
clipped. Render the window edge-to-edge (windowLayoutInDisplayCutoutMode
= shortEdges) so eframe's background clear fills the whole non-rectangular
display, then inset the browse/settings screens into the safe rectangle.

Video playback is deliberately NOT inset: the libmpv frame is composited
full-screen and keeps using the entire display exactly as before (only the
navigation and settings were the problem). MediaApp::ui runs playback on the
full Ui and returns early; every other screen draws inside a child Ui shrunk
to the safe area.

The safe insets — the display-cutout safe insets widened per edge by the
adjacent rounded-corner radii — are read from the activity's decor-view
WindowInsets over JNI (new src/insets.rs), refreshed about once a second so a
rotation that moves the cutout between short edges is followed. The activity
handle comes from AndroidApp::activity_as_ptr() (ndk_context's context is the
Application, which has no getWindow()); the query clears any pending exception
so a JNI failure can't abort the render loop. Everything falls back to zero
insets on the host build, older APIs, or any error.

Verified on a Pixel 10a (landscape): browse content insets by 152px on the
cutout edge and 115px (the rounded-corner radius) elsewhere, while a played
clip fills the screen edge-to-edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both PlaybackViews carried a near-identical copy of the mpv/PlayerHandle GL
compositor: an off-screen FBO-backed texture the player renders into, then
paints into egui. Extract it as shepherd_media_ui::video::VideoCompositor
(plus the identical format_time and the touch scrubber). The eframe texture
registration is passed in as a closure, so shepherd-media-ui gains a glow
dependency but not eframe.

Each binary keeps what legitimately differs: the transport overlay layout,
theme (Linux themed colors vs the Android default), input model (gamepad +
keyboard vs touch + D-pad), and the playback driver — Linux composites
through core's Session state machine, Android drives a bare PlayerHandle.
Both now call compositor.composite(size, register, render) and
video::paint_frame/format_time/touch_slider.

Verified on a Pixel 10a: a played clip still renders full-screen; host +
Android-target builds and clippy are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shepherd-media/src/platform.rs::current() hardcoded
PlatformInfo { platform: Platform::Linux }, duplicating core's
PlatformInfo::current() (which cfg-selects the platform and already returns
Linux for this target) — the same helper the Android app calls directly.
Delete the module and point its five call sites at core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `$XDG_CACHE_HOME` → `$HOME/.cache` → shepherd/media derivation was
copied byte-for-byte in three places (posters/videos/playlists), differing
only in the leaf directory. Collapse them into paths::media_cache_dir(leaf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android settings picker had its own quality_label() mapping Quality to
"Best"/"1080p"/"720p"/"480p" — a fourth copy of the resolution spellings
already carried by Quality's serde rename and clap value names. Add a
label() to Quality in shepherd-media-app and use it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor: share the playback transport overlay and key mapping
All checks were successful
CI / Version harmony (pull_request) Successful in 6s
CI / ShellCheck (pull_request) Successful in 9s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 34s
CI / Rustfmt (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 4m23s
CI / E2E (pull_request) Successful in 4m47s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 35s
CI / Test (pull_request) Successful in 5m2s
CI / Clippy (pull_request) Successful in 5m22s
CI / Firewall E2E (pull_request) Successful in 4m52s
CI / Android companion (unit tests) (pull_request) Successful in 42s
CI / Android media (cargo-ndk build) (pull_request) Successful in 34s
857b4f18ea
The two PlaybackViews carried a byte-for-byte copy of the transport overlay
(~100 lines: header/title/back, scrubber row, ±10s / play-pause button row —
identical geometry) plus the keyboard key→action mapping, differing only in
theme colors and in driving a Session vs a PlayerHandle.

Add a Transport trait to core (the common subset of PlayerHandle and Session,
which already share these method signatures; blanket-impl'd for PlayerHandle
and impl'd for Session). Then extract into shepherd-media-ui::video:
- transport_overlay(ui, rect, &mut impl Transport, title, &OverlayTheme)
  -> OverlayAction, with the layout, toggle_pause, and button;
- TransportIntent + key_intent + TRANSPORT_KEYS for the keyboard mapping;
- the shared SEEK_DELTA_SECONDS / CONTROLS_VISIBLE_FOR constants.

Each front-end now passes a 4-color OverlayTheme (Linux themed / Android
default) and applies OverlayAction::Leave its own way (Session StopPlayback
vs a leave bool). The gamepad path stays Linux-only. Verified on a Pixel 10a:
the overlay renders full-screen over a playing clip with the Android theme.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ -0,0 +7,4 @@
//!
//! [`PosterPolicy::Never`] skips loading entirely. `WifiOnly` currently behaves
//! like `Always`; gating it on a metered/Wi-Fi connection needs the Android
//! connectivity JNI bridge, which is not wired yet.
Author
Owner

looks like some stale comments need to be removed

looks like some stale comments need to be removed
Author
Owner

also couldn't this be shared too?

also couldn't this be shared too?
albert marked this conversation as resolved
@ -0,0 +7,4 @@
//! `NetworkOnMainThreadException` for any network on the main thread — so the
//! grid screen resolves on a worker and polls the result.
//!
//! Not yet handled (they need the Android JNI bridges / youtubedl-android):
Author
Owner

more stale comments

more stale comments
albert marked this conversation as resolved
@ -0,0 +7,4 @@
//! is downloaded afterward so the next play is local. LRU eviction (by file
//! mtime, touched on each cache hit) keeps the directory within the cap.
//!
//! Download and eviction are blocking and run off the UI thread.
Author
Owner

why does this need its own implementation

why does this need its own implementation
albert marked this conversation as resolved
docs: refresh comments made stale by the shared-crate extractions
All checks were successful
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 20s
CI / CI image (Android) (pull_request) Successful in 33s
CI / Rustfmt (pull_request) Successful in 8s
CI / Build (pull_request) Successful in 4m31s
CI / E2E (pull_request) Successful in 4m41s
CI / Test (pull_request) Successful in 4m58s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 35s
CI / Firewall E2E (pull_request) Successful in 4m41s
CI / Clippy (pull_request) Successful in 5m19s
CI / Android companion (unit tests) (pull_request) Successful in 40s
CI / Android media (cargo-ndk build) (pull_request) Successful in 32s
e09ce4f5bc
- Android playback.rs no longer describes itself as "adapted from" the Linux
  view; both now share the shepherd-media-ui compositor + transport overlay.
- Linux playback.rs lifecycle now points at the shared compositor rather than
  the old inline FBO/egui_glow allocation.
- core youtube_playlist entries are produced by parse_flat_playlist (now in
  core), not "by the platform binary".
- Quality is the shared --quality enum, not a mirror of the Linux binary's.
- The shared grid is used by both front-ends, not "adapted from" a Linux grid.

Comment-only changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

re: the remaining code "duplication": Claude had the following to say:

YouTube playback: Android vs Linux

The two front-ends are fundamentally forked on who runs yt-dlp, and when — and
almost everything else about the YouTube path follows from that one decision.

The fork: mpv resolves (Linux) vs the app resolves (Android)

Both platforms use the same core LibmpvPlayer, which enables mpv's built-in
youtube-dl hook:

init.set_property("ytdl", "yes")?;                 // core/src/player.rs:227
init.set_property("ytdl-format", ytdl_format)?;    // :228

mpv's ytdl_hook is a Lua script that shells out to the yt-dlp binary on $PATH.
That single fact splits the two platforms:

  • Linuxyt-dlp is a real binary on PATH, so mpv does everything. The app just
    hands mpv the YouTube watch URL:

    ClassifiedUri::DirectHttp(url) | ClassifiedUri::YouTube(url) | ... => Ok(url.to_string())
    // core/src/player.rs:261-263
    

    mpv's hook resolves the URL, applies ytdl-format, muxes the DASH video+audio, and
    plays it. Linux never resolves a stream URL itself and never calls
    set_external_audio.

  • Android — there is no yt-dlp binary and mpv's hook can't run, so the app must
    resolve the URL and feed mpv a plain, already-resolved stream. That is the entire
    reason shepherd-media-android/src/youtube.rs exists.

Linux path (short — mpv carries it)

YouTube on Linux only needs youtube.rs for playlist metadata: fetch_playlist
runs yt-dlp --dump-json --flat-playlist as a subprocess (main.rs:83) to build the
browse library. Playback is just: watch URL → mpv → done.

Android path (long — the app carries it)

ui.rs:783 — when the item URI is ClassifiedUri::YouTube, playback is a two-phase,
off-UI-thread dance:

  1. Resolve on a worker thread (ui.rs:786-796): spawn a thread, call
    resolve_stream_url, deliver the result via a channel into playback_pending,
    polled each frame (poll_playback_pending, ui.rs:894). Network must never touch
    the UI thread.

  2. resolve_stream_url (youtube.rs:70) runs yt-dlp with -g (print URL, don't
    download), producing separate video-only + audio-only DASH URLs (StreamUrls).

  3. Play + mux manually (ui.rs:920-928): the resolved video URL is played as a
    plain ClassifiedUri::DirectHttp (mpv streams it with no ytdl involved), and the
    audio URL is attached as an external track:

    p.set_external_audio(streams.audio);   // ui.rs:927
    

    which core turns into mpv's audio-file option (core/src/player.rs:288). So
    set_external_audio in the shared core exists specifically for Android — Linux's
    mpv-driven path never needs it.

Why the Android side is ~5× the code — the device-specific gotchas

Each of these is a real, hard-won workaround (see
docs/ai/history/2026-06-28 001 shepherd-media-android on-device validation.md), none of
which Linux needs:

  • youtubedl-android over JNI — yt-dlp isn't a subprocess; it's the
    com.yausername.youtubedl_android AAR (yt-dlp bundled with a Python runtime), called
    through a YtDlp JNI trait (youtube.rs provider() / jni_impl).
  • Classloader trap (youtube.rs, load_class) — the worker thread's implicit
    FindClass resolves against the bootstrap classloader, can't see the app's DEX,
    throws ClassNotFoundException, and the next JNI call aborts the process via CheckJNI.
    Fixed by resolving through Context.getClassLoader().loadClass(…).
  • android_vr player client (youtube.rs:94) — the default android client returns
    audio-only (PO-token gated); android_vr serves the full DASH ladder without a PO
    token.
  • H.264 requirement (youtube.rs:53, bv*[vcodec^=avc1]) — YouTube's best DASH is
    usually VP9/AV1, which fails to hardware-decode on many mobile GPUs (audio plays, video
    stays black); H.264 decodes reliably. Linux doesn't care (desktop GPUs handle VP9/AV1).
  • In-app yt-dlp refresh (refresh_ytdlp / download_ytdlp, youtube.rs) — the AAR's
    bundled yt-dlp is too old (returns only audio-only itags) and the library's own updater
    throws, so the app drops the latest yt-dlp zipapp from GitHub into the payload dir
    itself (with a shebang/size sanity check).
  • Exception funneling (take_pending_exception) — yt-dlp failures surface as Java
    YoutubeDLExceptions that, left pending, would kill the process when the worker thread
    detaches; every throwing JNI call must convert them to a recoverable Err.

What's shared vs. not

Shared (core/app) Platform-specific
Playlist URL detection + parse_flat_playlist core
LibmpvPlayer (ytdl-format, set_external_audioaudio-file) core
Stream resolution mpv's hook (Linux) vs youtubedl-android JNI (Android)
Muxing mpv-internal (Linux) vs manual set_external_audio (Android)

Bottom line

The divergence isn't stylistic. On Linux, mpv + yt-dlp-on-PATH does resolution,
format selection, and muxing for free, so the YouTube path is nearly nothing. On Android
none of that infrastructure exists, so the app reimplements the resolve-and-mux pipeline
over JNI and works around the android_vr / H.264 / stale-yt-dlp / classloader realities
of doing YouTube extraction inside an Android app. The shared core deliberately provides
the one hook Android needs that Linux doesn't — set_external_audio — and otherwise both
front-ends ride the same LibmpvPlayer.

re: the remaining code "duplication": Claude had the following to say: > # YouTube playback: Android vs Linux > > The two front-ends are fundamentally forked on **who runs `yt-dlp`, and when** — and > almost everything else about the YouTube path follows from that one decision. > > ## The fork: mpv resolves (Linux) vs the app resolves (Android) > > Both platforms use the *same* core `LibmpvPlayer`, which enables mpv's built-in > youtube-dl hook: > > ```rust > init.set_property("ytdl", "yes")?; // core/src/player.rs:227 > init.set_property("ytdl-format", ytdl_format)?; // :228 > ``` > > mpv's `ytdl_hook` is a Lua script that **shells out to the `yt-dlp` binary on `$PATH`**. > That single fact splits the two platforms: > > - **Linux** — `yt-dlp` is a real binary on `PATH`, so mpv does everything. The app just > hands mpv the YouTube *watch URL*: > > ```rust > ClassifiedUri::DirectHttp(url) | ClassifiedUri::YouTube(url) | ... => Ok(url.to_string()) > // core/src/player.rs:261-263 > ``` > > mpv's hook resolves the URL, applies `ytdl-format`, muxes the DASH video+audio, and > plays it. Linux **never** resolves a stream URL itself and **never** calls > `set_external_audio`. > > - **Android** — there is no `yt-dlp` binary and mpv's hook can't run, so *the app* must > resolve the URL and feed mpv a plain, already-resolved stream. That is the entire > reason `shepherd-media-android/src/youtube.rs` exists. > > ## Linux path (short — mpv carries it) > > YouTube on Linux only needs `youtube.rs` for **playlist metadata**: `fetch_playlist` > runs `yt-dlp --dump-json --flat-playlist` as a subprocess (`main.rs:83`) to build the > browse library. Playback is just: watch URL → mpv → done. > > ## Android path (long — the app carries it) > > `ui.rs:783` — when the item URI is `ClassifiedUri::YouTube`, playback is a two-phase, > off-UI-thread dance: > > 1. **Resolve on a worker thread** (`ui.rs:786-796`): spawn a thread, call > `resolve_stream_url`, deliver the result via a channel into `playback_pending`, > polled each frame (`poll_playback_pending`, `ui.rs:894`). Network must never touch > the UI thread. > 2. `resolve_stream_url` (`youtube.rs:70`) runs yt-dlp with `-g` (print URL, don't > download), producing separate **video-only + audio-only DASH URLs** (`StreamUrls`). > 3. **Play + mux manually** (`ui.rs:920-928`): the resolved *video* URL is played as a > plain `ClassifiedUri::DirectHttp` (mpv streams it with no ytdl involved), and the > audio URL is attached as an external track: > > ```rust > p.set_external_audio(streams.audio); // ui.rs:927 > ``` > > which core turns into mpv's `audio-file` option (`core/src/player.rs:288`). So > `set_external_audio` in the shared core exists **specifically for Android** — Linux's > mpv-driven path never needs it. > > ## Why the Android side is ~5× the code — the device-specific gotchas > > Each of these is a real, hard-won workaround (see > `docs/ai/history/2026-06-28 001 shepherd-media-android on-device validation.md`), none of > which Linux needs: > > - **youtubedl-android over JNI** — yt-dlp isn't a subprocess; it's the > `com.yausername.youtubedl_android` AAR (yt-dlp bundled with a Python runtime), called > through a `YtDlp` JNI trait (`youtube.rs` `provider()` / `jni_impl`). > - **Classloader trap** (`youtube.rs`, `load_class`) — the worker thread's implicit > `FindClass` resolves against the *bootstrap* classloader, can't see the app's DEX, > throws `ClassNotFoundException`, and the next JNI call aborts the process via CheckJNI. > Fixed by resolving through `Context.getClassLoader().loadClass(…)`. > - **`android_vr` player client** (`youtube.rs:94`) — the default `android` client returns > **audio-only** (PO-token gated); `android_vr` serves the full DASH ladder without a PO > token. > - **H.264 requirement** (`youtube.rs:53`, `bv*[vcodec^=avc1]`) — YouTube's best DASH is > usually VP9/AV1, which fails to hardware-decode on many mobile GPUs (audio plays, video > stays black); H.264 decodes reliably. Linux doesn't care (desktop GPUs handle VP9/AV1). > - **In-app yt-dlp refresh** (`refresh_ytdlp` / `download_ytdlp`, `youtube.rs`) — the AAR's > bundled yt-dlp is too old (returns only audio-only itags) and the library's own updater > throws, so the app drops the latest yt-dlp zipapp from GitHub into the payload dir > itself (with a shebang/size sanity check). > - **Exception funneling** (`take_pending_exception`) — yt-dlp failures surface as Java > `YoutubeDLException`s that, left pending, would kill the process when the worker thread > detaches; every throwing JNI call must convert them to a recoverable `Err`. > > ## What's shared vs. not > > | | Shared (core/app) | Platform-specific | > |---|---|---| > | Playlist URL detection + `parse_flat_playlist` | ✅ core | — | > | `LibmpvPlayer` (`ytdl-format`, `set_external_audio` → `audio-file`) | ✅ core | — | > | **Stream resolution** | — | mpv's hook (Linux) vs youtubedl-android JNI (Android) | > | **Muxing** | — | mpv-internal (Linux) vs manual `set_external_audio` (Android) | > > ## Bottom line > > The divergence isn't stylistic. On Linux, mpv + `yt-dlp`-on-`PATH` does resolution, > format selection, and muxing for free, so the YouTube path is nearly nothing. On Android > none of that infrastructure exists, so the app reimplements the resolve-and-mux pipeline > over JNI and works around the `android_vr` / H.264 / stale-yt-dlp / classloader realities > of doing YouTube extraction inside an Android app. The shared core deliberately provides > the one hook Android needs that Linux doesn't — `set_external_audio` — and otherwise both > front-ends ride the same `LibmpvPlayer`.
Author
Owner

Code looks reasonable and works on a phone (Pixel 10a), just needs testing on more devices — particularly Fire TV and Google TV devices

Code looks reasonable and works on a phone (Pixel 10a), just needs testing on more devices — particularly Fire TV and Google TV devices
Adding a library on a TV remote means typing three free-text fields with
a D-pad. Two of them need not be typed at all: derive the id and label
from the source when left blank, so only the locator is required.

- shepherd-media-app: LibrarySource::suggested_id()/suggested_label()
  (file stem for file/URL sources, list= id for YouTube, per-kind
  fallbacks) plus AppSettings::unique_id() to dedupe a derived id within
  the 64-char budget. Covered by new unit tests.
- shepherd-media-android: the Add-library form's Id and Label are now
  optional (hint "optional — from source"); blank fields are derived on
  Add, a derived id is de-duplicated, and an explicit duplicate still
  errors so the user learns about it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding an offline library on a keyboard-less TV meant typing a path. The
standard SAF document picker can't help here: it returns an opaque
content:// for a single file, so a .toml/.m3u can't reach media in its
own folder, and receiving the pick would need a Java shim the pure
NativeActivity can't host. Instead, browse to the file in-app and hand
the resolver a real path — relative media then resolves against the
file's directory with no changes to resolve.rs or media-core.

- storage.rs (new): JNI helpers for shared-storage access — browse root,
  All-files-access check, and a request that opens the system settings
  screen. Android-gated with host fallbacks; no Java/Kotlin.
- ui.rs: a D-pad-navigable Screen::FilePicker (dirs first, then
  .toml/.m3u, bounded ".." at /storage), reached from a "Browse device…"
  button on the add form. Picking a file fills the locator, sets the
  source kind from the extension, and leans on the id/label derivation.
- AndroidManifest: MANAGE_EXTERNAL_STORAGE (+ legacy READ maxSdk 32).

Verified on a Pixel 10a end-to-end: grant flow, browse, pick a relative
m3u, and the library's items resolve against the picked directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showing the soft keyboard on D-pad focus is possible (JNI InputMethodManager
driven by egui_wants_keyboard_input), but a keyboard-focused field does not
capture the typed text — only a pointer-focused one does. Root cause is the
NativeActivity/winit/egui IME binding (winit 0.30 has no Android IME; on-screen
text input needs GameActivity + GameTextInput). Reverted the show-keyboard
bridge as a false affordance; captured the finding and the real options in the
history doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing a URL on a TV remote is the painful case the file browser can't
help with. Instead the TV hosts a tiny LAN page a phone (with a real
keyboard) submits the URL to.

- handoff.rs (new): a dependency-light hand-rolled HTTP/1.1 server on an
  ephemeral port (std::net, no server crate) serving a form page and a
  /submit endpoint, delivering the URL over a channel; plus a QR helper
  (new core-only qrcode dep). Cross-platform, unit-tested.
- ui.rs: a PhoneHandoff screen showing the address + QR, reached from an
  "Add from phone..." button on the add form for URL sources. It polls
  for the submission, detects the source kind (YouTube vs HTTP), fills
  the form, and returns to it (id/label then auto-derive). An explicit
  repaint request paints the add form since the submission arrives with
  no input on the TV.

LAN-only and unauthenticated, matching a home TV + phone on one Wi-Fi.
Verified on a Pixel 10a end-to-end (form served, POST fills the form
hands-free, both URL kinds detected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QR was drawn at a fixed ~320pt and, stacked under the address text,
ran off the bottom of the landscape screen. Lay the screen out in two
columns (instructions left, QR right) and size the QR to the space
actually left in its column, so it always fits with its quiet-zone
margin. draw_qr now takes a max side and fits whole-pixel modules within
it. Verified on a Pixel 10a: the full QR is visible and the hand-off
still receives and fills the form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(media-android): serve the phone hand-off with axum
All checks were successful
CI / Version harmony (pull_request) Successful in 21s
CI / ShellCheck (pull_request) Successful in 23s
CI / CI image (pull_request) Successful in 10m54s
CI / Clippy (pull_request) Successful in 4m17s
CI / Rustfmt (pull_request) Successful in 9s
CI / CI image (Android) (pull_request) Successful in 7m17s
CI / Firewall E2E (pull_request) Successful in 3m3s
CI / Build (pull_request) Successful in 7m40s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 34s
CI / E2E (pull_request) Successful in 8m27s
CI / Test (pull_request) Successful in 9m6s
CI / Android companion (unit tests) (pull_request) Successful in 2m49s
CI / Android media (cargo-ndk build) (pull_request) Successful in 3m0s
d2406f1b0a
Replace the hand-rolled std::net HTTP/1.1 handler with axum on tokio —
the same server stack the shepherd-http management API uses. axum's Form
extractor handles the form/percent decoding, so the bespoke request
parsing, percent-decode, and response writing are gone. The server runs
on a background thread with a current-thread tokio runtime; the port is
bound synchronously first so it's known before the runtime starts.

Tests now spin up the real server and round-trip GET/POST over HTTP.
Verified on a Pixel 10a: the tokio/axum server runs on-device, serves
the form, and a POST fills the add form hands-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

URL fields can now be added via a form on a separate device to ease the TV workflow (still needs to be tested on an actual TV)

URL fields can now be added via a form on a separate device to ease the TV workflow (still needs to be tested on an actual TV)
Some Fire TV sticks (e.g. AFTHA004 "hazel") run a 32-bit Fire OS and
report only armeabi-v7a — installing the arm64-only APK fails with
INSTALL_FAILED_NO_MATCHING_ABIS. Add armeabi-v7a as a second packaged
ABI so one APK covers both 32-bit sticks and 64-bit phones/TVs.

- Vendor the 32-bit libmpv + ffmpeg .so set under vendor/libmpv/armeabi-v7a/,
  extracted from the same dev.jdtech.mpv:libmpv 1.0.0 AAR (mpv v0.41.0)
  the existing arm64-v8a libs byte-match.
- build.rs: map target_arch "arm" to the armeabi-v7a vendor dir.
- Gradle: rustAbis now lists both ABIs (drives abiFilters + cargo-ndk).
- Cargo.toml: force libmpv2-sys's use-bindgen on Android. Its pregenerated
  bindings bake in a 64-bit struct layout whose const layout-asserts fail to
  compile for 32-bit armv7; bindgen regenerates per-ABI (cargo-ndk supplies
  the target/sysroot via BINDGEN_EXTRA_CLANG_ARGS). Host keeps the
  pregenerated path.

Verified on the AFTHA004: APK carries both ABIs, installs, and launches
(32-bit libmpv + cdylib load, NativeActivity reaches Resumed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(media-android): focus Add on the empty switcher, and exit on BACK
All checks were successful
CI / Version harmony (pull_request) Successful in 19s
CI / ShellCheck (pull_request) Successful in 25s
CI / CI image (pull_request) Successful in 32s
CI / CI image (Android) (pull_request) Successful in 1m2s
CI / Rustfmt (pull_request) Successful in 16s
CI / Firewall E2E (pull_request) Successful in 3m51s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 31s
CI / Android companion (unit tests) (pull_request) Successful in 58s
CI / Test (pull_request) Successful in 7m37s
CI / Build (pull_request) Successful in 8m15s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m6s
CI / E2E (pull_request) Successful in 8m54s
CI / Clippy (pull_request) Successful in 9m6s
1d9495f96f
Two D-pad/TV UX fixes found while testing on a Fire TV:

- The empty library switcher focused nothing (it's excluded from the
  generic focus bootstrap because it normally focuses the first library),
  so the remote's center button had no target and the first library could
  never be added. Focus the "Add a library" button when nothing else is.

- BACK on the top-level switcher did nothing; it should leave the app the
  way BACK from a TV home screen does. ViewportCommand::Close doesn't
  reliably finish a NativeActivity, so add an `exit` module that calls
  Activity.finish() over JNI (mirroring the insets/storage activity-handle
  pattern). This returns to the launcher; Android keeps the process cached,
  as expected.

Both verified on the AFTHA004.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A focused egui TextEdit locks the arrow keys for cursor movement, so on a
remote the add-library form's Id/Label/Location fields trap focus with no
way out, and BACK (the only escape) left the whole screen.

- After each field, override its focus lock filter to free the vertical
  arrows: Up/Down now move focus between the stacked fields and off to the
  buttons. Horizontal arrows stay with the cursor (text is entered via the
  phone hand-off / file browser, not typed on the remote; still useful in
  the desktop preview).
- BACK while a field is focused now leaves the field (stop_text_input)
  rather than the screen; a second BACK navigates up as before.

Verified on the AFTHA004: Down moves Id -> Label, BACK releases the field
and stays on the screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
egui's spatial focus walks the vertically-aligned left-column buttons
(Back / Browse / Add) and skips the offset field column, so a remote's
Down never reached the fields — you had to press Right.

- Drive Up/Down explicitly in tab order: capture each control's Response
  (Back, Id, Label, Source, Location, Browse/Phone, Add) and request_focus
  the previous/next, cancelling egui's spatial move. Down now steps through
  every control in order.
- Source ComboBox, which egui only closes on a pointer click or Escape:
  - Enter now closes the popup (detect the kind change, Popup::close_all,
    and keep focus on the combo).
  - BACK dismisses an open popup instead of leaving the screen (snapshot
    popup-open before render; handle it ahead of the field/screen cases).
    Form Up/Down stepping is skipped while the popup is open.

Verified on the AFTHA004: Down reaches every field; the Source dropdown
opens, navigates, selects, and BACK dismisses it, all via remote.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The library editor (Settings screen's per-library caching_editors) had the
same remote faults as the add form: Down skipped the Cache/Quality/Posters
combos, and a D-pad Enter didn't close a combo popup. Pull the add-form
logic into shared helpers and apply them to both screens.

- tv_combo(): a D-pad-friendly ComboBox that closes its popup on Enter and
  keeps focus. Replaces all four inline combos (source kind + the three
  caching combos); cache_mode_label/poster_label are now dead and removed.
- tv_focus_step(): the explicit Up/Down tab-order stepping, extracted. The
  caller passes the ordered response list; caching_editors returns its three
  combo responses so settings_screen can include them (plus the active
  toggle and move/remove buttons) in one order. The Limit drag value is left
  out (owns its arrow handling; reachable via Left/Right).
- BACK-dismisses-open-popup already lives once in the update loop, so it
  covered the editor with no change.

Verified on the AFTHA004: in the editor, Down steps Back -> Add -> active ->
Cache -> Quality -> Posters -> Remove, and each combo opens/selects/closes.
Add-form behavior re-checked after the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playback on the Fire TV (AFTHA004) was choppy. Measuring showed it wasn't
decode: top had the app at ~35% CPU with the system >50% idle, and logcat
showed hardware H.264 decode (OMX.amlogic.avc.decoder) at 720p. But
SurfaceFlinger --latency showed the app presenting a new frame only every
~4th vsync on a 60 Hz panel — ~15 fps. The bottleneck was mpv's GL render:
its default high-quality scaler/dither/deband can't upscale 720p to the
1080p surface within a frame on the Amlogic Mali GPU.

- Apply mpv's `fast` profile (bilinear, no dither/deband) via a new
  `LibmpvPlayer::new(_, fast_render)` argument. Android passes true; the
  desktop binary passes false (desktop GPUs handle full quality). This alone
  took the measured present rate ~15 -> ~60 fps.
- Drive the render at display rate while playing: request a repaint every
  frame during playback (mpv's render API is host-driven) instead of relying
  on the update-callback cadence; paused playback still idles slowly.

Verified on a signed release APK: HW decode, ~60 fps present (median 16.7 ms)
at ~44% CPU, smooth video.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two remote-only faults:

- Combo popups couldn't be navigated: egui doesn't reliably move keyboard
  focus into an open combo popup, so Down did nothing or escaped to a
  neighbour, and Enter never closed it (egui closes combos only on a pointer
  click). tv_combo now owns the D-pad while its popup is open — Up/Down cycle
  the value in place, Enter commits and closes and re-focuses the combo. It
  recomputes the combo id like from_id_salt so it can query ComboBox::is_open.

- The Fire TV screensaver blanked the screen mid-video: with vo=libmpv there's
  no player window to inhibit it. New `screen` module toggles the
  KEEP_SCREEN_ON window flag via AndroidApp::set_window_flags while a video is
  playing or resolving, cleared otherwise.

Combo fix verified on the AFTHA004 (Quality selector cycles and closes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
YouTube resolution took ~15s, CPU-bound in the bundled Python/yt-dlp: with
the default execute() youtubedl-android passes --no-cache-dir, so every
resolve re-downloads and re-parses YouTube's player JS (the nsig challenge).

The library exposes an execute(request, processId, useCache) overload
(default false); call it with useCache=true so yt-dlp caches the extracted
player in the app cache dir. Repeat resolves — and the next launch — then
skip that work. The URL is the process id (unique per call). Both the
playlist fetch and stream resolve go through this path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-frame keep-awake toggle deadlocked the app on play. set_window_flags
takes android-activity's activity lock, which winit already holds on the same
thread while dispatching input/redraw into the eframe update loop — calling it
from App::update re-locks it re-entrantly and hangs the render thread, so the
app ANRs and crashes exactly when playback starts and the flag first flips on
(logcat: "the focused window has not finished processing all of the input
events").

Set the flag once from android_main before eframe::run_native instead, where
nothing holds that lock. The screen stays on while the app is foreground
(fine for a TV media player) and the render loop never touches window flags.
Drops the `screen` module and the per-frame toggle.

Verified: dumpsys window shows fl=KEEP_SCREEN_ON at launch, and playback no
longer hangs (confirmed on the device with the remote).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the Linux binary's `--reverse` flag as a per-library checkbox in the
settings editor. `LibraryEntry` gains a `reverse: bool` (serde default, so
existing settings files still load). A freshly resolved grid flips its item
order in `poll_grid` when the flag is set; toggling the checkbox reverses the
already-loaded grid in place (no slow YouTube re-fetch), and the two paths
stay consistent.

The checkbox sits in the per-library group and is wired into the screen's
D-pad focus order. Verified on the AFTHA004: toggling it reverses the grid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some playlist videos (licensed PBS/Muppets "full episode" uploads) failed to
resolve: the android_vr client reports them "not available", and the tv client
shows why — they're DRM-protected, so their DASH formats are Widevine-encrypted.
YouTube still serves the legacy progressive itag 18 (360p H.264+AAC, non-DRM),
but only to the `android` client, which android_vr doesn't expose.

Query both clients (player_client=android_vr,android). yt-dlp merges their
formats; the selector keeps 720p DASH for normal videos and falls back to the
muxed 360p for the DRM ones, so they play instead of erroring.

Found via host yt-dlp reproducing the app's resolve across the whole playlist
(3 of 62 failed); verified all three now resolve to itag 18 while normal videos
still get 720p.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playback could wedge: after a video, seeking and immediately closing it left
the app unable to play anything — each new video was torn down the instant it
started. mpv emits an END_FILE event when the UI stops playback, but the app
stops draining player events once it leaves the playback screen, so that event
(and the idle-active after it) stays queued. The next play() then reads the
stale END_FILE as this file ending and stops — and each stop re-queues another
END_FILE, so it stays stuck.

Drain the event queue in play() before loadfile so a new session never inherits
a previous one's events. Reproduced and verified fixed on the Pixel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(media-android): retry playback on a transient stream error
All checks were successful
CI / Version harmony (pull_request) Successful in 23s
CI / ShellCheck (pull_request) Successful in 25s
CI / CI image (pull_request) Successful in 34s
CI / CI image (Android) (pull_request) Successful in 1m10s
CI / Rustfmt (pull_request) Successful in 23s
CI / Clippy (pull_request) Successful in 5m42s
CI / Test (pull_request) Successful in 6m16s
CI / Build (pull_request) Successful in 6m32s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 54s
CI / E2E (pull_request) Successful in 6m53s
CI / Firewall E2E (pull_request) Successful in 5m59s
CI / Android companion (unit tests) (pull_request) Successful in 1m13s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m1s
3c8025c043
An intermittent failure bounced a just-started video back to the grid: mpv
surfaces an END_FILE whose error field is set (a flaky googlevideo connection
dropping right after the stream opens) as a wait_event error, which poll_event
maps to PlayerEvent::Error. run_playback treated that like a clean end — silent
stop, no feedback, no recovery — so the video "wouldn't play" until you tried
again.

Distinguish a clean end (EOF/close) from an error. On an error, retry the same
resolved source up to MAX_PLAYBACK_RETRIES before giving up (PlayingItem now
carries the source + external audio so no re-resolve is needed), then surface
the error as a status message instead of a silent bounce. Healthy playback is
unaffected (retry fires only on Error, bounded, logged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ~3s yt-dlp resolve dominated initial load on every play (measured on the
phone: 3.2-3.4s resolve regardless of length; the demux adds ~1.2s only on very
long videos, and no mpv demuxer tuning meaningfully cuts it). Hide the resolve
instead: once focus settles on a grid item (400ms dwell), resolve its stream on
a worker and cache it by watch URL (4h TTL, under googlevideo's expiry), one at
a time. Tapping play reuses the cached resolution through the existing
poll_playback_pending path, so playback starts without the resolve wait; a
normal play also banks its resolution so replays are instant.

Verified on-device: a prefetched 4-hour video went from selection to play() in
5ms (was ~3.2s), cutting total start from ~4.8s to ~1.7s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A PlayerEvent::Error while Playing went straight to ERROR + RETURNED_TO_MENU, so
a flaky stream (e.g. a network connection dropping right after the file opens)
tore playback down to the menu with no recovery. This is the shared-session
equivalent of the Android-side retry; the Linux binary drives playback through
Session::tick, so it had none.

Restart the same item in place on an Error (emitting WARNING reason=playback-retry
and staying Playing) up to MAX_PLAY_RETRIES before surfacing the error and
returning to the menu; the counter resets when a new item starts. Covered by two
protocol integration tests (recover-on-transient, give-up-after-exhaustion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prefetch made the same item resolvable twice at once (a background prefetch
racing the play). Both calls passed the watch URL as youtubedl-android's process
id, and the library rejects a second execute() with a live id
("Process ID already exists"), failing the play — seen deploying to the Fire TV.

Use a unique per-call process id (shepherd-resolve-<seq>), and have start_playback
adopt an in-flight prefetch's receiver for the same item instead of starting a
second concurrent resolve. Verified on the 32-bit Fire TV: HW decode + audio, no
collision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bounded "restart on transient error" retry lived in two places — the Android
event loop and the shared Session — each with its own MAX_*_RETRIES = 2 constant
that could drift. Extract the policy (count + threshold) into a small RetryBudget
in shepherd-media-core::player, used by both: Session holds one (reset when a new
item starts) and the Android PlayingItem holds one.

The mechanics stay per-front-end (Android drives its own loop and resolves async;
Linux goes through Session with a protocol emitter), so this shares what can be
shared without migrating Android onto Session. Behaviour is unchanged — the
session integration tests pass as-is; added unit tests for RetryBudget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the focused-item prefetch into a background warm-up that hides cold-start
latency. A single-slot driver (one resolve at a time, to spare a weak TV) runs
while the app is idle with priority: focused grid item, then any unresolved
playlist, then the first PREFETCH_FIRST_N (5) videos of each resolved playlist —
so "all playlists first, then their first-N videos" falls out of the ordering.

The in-flight slot is now an enum (Library | Video); resolved libraries land in a
new library_cache (display order, reverse applied) so opening one is instant, and
first-N videos feed the existing stream_cache. ensure_grid_loading/poll_grid use
library_cache; a prefetch_attempted set keeps a failing resolve (e.g. a DRM video)
from looping in the background.

Verified on the cold Fire TV: playlist warms on the switcher, then leading videos
one at a time; opening the library is instant and a warmed item plays on a cache
hit with the first frame in the same second.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(media-android): warm 10 leading videos and the focused item's neighbours
Some checks failed
CI / Version harmony (pull_request) Successful in 22s
CI / ShellCheck (pull_request) Successful in 28s
CI / CI image (pull_request) Successful in 34s
CI / CI image (Android) (pull_request) Successful in 1m6s
CI / Rustfmt (pull_request) Failing after 15s
CI / Clippy (pull_request) Successful in 3m49s
CI / Build (pull_request) Successful in 4m2s
CI / Test (pull_request) Successful in 4m19s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 43s
CI / Android companion (unit tests) (pull_request) Successful in 55s
CI / E2E (pull_request) Successful in 5m8s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m1s
CI / Firewall E2E (pull_request) Successful in 4m37s
d72a04e6cd
Raise PREFETCH_FIRST_N to 10, and extend the focused-item prefetch to warm the
focused grid tile plus the two around it (focused first, one at a time, sharing
the single resolve slot) so moving to an adjacent tile and playing is instant
too. The window items are marked attempted like the background warm-up so a
failing resolve doesn't loop.

Verified on device: opening a warmed library is instant and a warmed item plays
on a hardware-decode cache hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style(media-core): rustfmt wrapping in RetryBudget tests
All checks were successful
CI / Version harmony (pull_request) Successful in 23s
CI / ShellCheck (pull_request) Successful in 29s
CI / CI image (pull_request) Successful in 36s
CI / CI image (Android) (pull_request) Successful in 1m13s
CI / Rustfmt (pull_request) Successful in 16s
CI / Clippy (pull_request) Successful in 3m51s
CI / Build (pull_request) Successful in 4m7s
CI / Test (pull_request) Successful in 4m26s
CI / E2E (pull_request) Successful in 5m6s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 1m12s
CI / Android companion (unit tests) (pull_request) Successful in 1m23s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m8s
CI / Firewall E2E (pull_request) Successful in 4m45s
339a3a526c
Wrap the single-line assert! calls in the RetryBudget test module to satisfy
cargo fmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DRM-protected "full episode" videos that the Android app handles via the
android player client also failed on Linux ("This video is not available"): the
Linux binary resolves through mpv's ytdl_hook with the default player clients,
which don't serve those. Its ytdl-format already falls back to a muxed stream
(/best), so the only missing piece was the client.

Set ytdl-raw-options in LibmpvPlayer::new to route yt-dlp through
android_vr + android, matching the Android resolver — android_vr serves DASH
without a PO token, android exposes the legacy progressive itag 18 the muxed
fallback then selects for DRM uploads. The value is length-prefix quoted so mpv's
key/value list parser doesn't split it on the comma between the client names.
Shared core, but a no-op on Android (it pre-resolves and never runs ytdl_hook).

Verified through the real LibmpvPlayer headless: the "full episode" that failed
(CLOSED/unavailable) now Started; a normal video still resolves to 720p DASH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(media): share the YouTube player client and apply it to the cache download
Some checks failed
CI / Version harmony (pull_request) Successful in 34s
CI / ShellCheck (pull_request) Successful in 39s
CI / CI image (pull_request) Successful in 47s
CI / CI image (Android) (pull_request) Successful in 1m11s
CI / Rustfmt (pull_request) Successful in 18s
CI / Clippy (pull_request) Successful in 3m52s
CI / Build (pull_request) Successful in 4m15s
CI / Android portability (shepherd-media-core) (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 4m22s
CI / E2E (pull_request) Successful in 5m8s
CI / Android companion (unit tests) (pull_request) Successful in 56s
CI / Android media (cargo-ndk build) (pull_request) Successful in 58s
CI / Firewall E2E (pull_request) Successful in 4m43s
df52ca9add
The Linux video-file cache (download_youtube) invoked yt-dlp without
--extractor-args, so DRM-protected uploads failed to cache the same way they
failed to play. The android_vr,android client selection was also duplicated
across the Android resolver, the core mpv ytdl-raw-options, and (now) the cache.

Hoist it into one shared constant, shepherd_media_core::YOUTUBE_EXTRACTOR_ARGS
(new core::youtube module — core is the lowest common layer), and use it in all
three: the two CLI paths pass it as `--extractor-args <value>`, the mpv path
length-prefix quotes it for ytdl-raw-options. The two ytdl-format strings stay
separate by design (Android prefers H.264 for weak-TV hardware decode; Linux is
codec-agnostic) — both already carry the muxed fallback, so only the client
needed sharing.

Verified: the cache download command on the DRM "full episode" now produces an
itag-18 (h264 640x360 + aac) file instead of failing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

identified and fixed some performance (slow load) and UX (can't type URL) issues on Fire TV; refactored into Linux implementation as appropriate

behavior is now correct, just needs a more thorough code review

identified and fixed some performance (slow load) and UX (can't type URL) issues on Fire TV; refactored into Linux implementation as appropriate behavior is now correct, just needs a more thorough code review
@ -0,0 +83,4 @@
// yt-dlp bundled with a Python runtime, called over JNI from the Rust code
// (see src/youtube.rs). Brings its own native libs/assets, packaged into
// the APK automatically.
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
Author
Owner

Fine for now, but we’ll need to watch out for how maintained this is moving forward

Fine for now, but we’ll need to watch out for how maintained this is moving forward
@ -0,0 +84,4 @@
- The cdylib cross-compiles for `aarch64-linux-android` and exports
`android_main` / `ANativeActivity_onCreate`.
## Not yet wired (next steps)
Author
Owner

This entire section seems to be stale

This entire section seems to be stale
docs(media-android): refresh stale YouTube/prefetch notes in the README
All checks were successful
CI / Version harmony (pull_request) Successful in 24s
CI / ShellCheck (pull_request) Successful in 31s
CI / CI image (pull_request) Successful in 41s
CI / CI image (Android) (pull_request) Successful in 1m13s
CI / Rustfmt (pull_request) Successful in 16s
CI / Clippy (pull_request) Successful in 3m29s
CI / Build (pull_request) Successful in 3m56s
CI / Test (pull_request) Successful in 4m7s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 44s
CI / Android companion (unit tests) (pull_request) Successful in 53s
CI / E2E (pull_request) Successful in 4m53s
CI / Android media (cargo-ndk build) (pull_request) Successful in 58s
CI / Firewall E2E (pull_request) Successful in 4m33s
d6c8faba76
This session's changes had left three claims stale:
- the resolver now uses the android_vr + android clients (DRM itag-18 fallback,
  shared with Linux via YOUTUBE_EXTRACTOR_ARGS), not android_vr alone;
- prefetch is now a launch warm-up (all playlists, then their leading videos)
  plus the focused tile and its two neighbours while browsing, not just the
  focused item;
- per-library quality is applied to YouTube resolution, so drop the stale
  "not yet wired" item. Also note the transient-error retry (RetryBudget).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
albert merged commit cb3bb4795a into main 2026-07-09 02:49:14 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
albert/shepherd-launcher!72
No description provided.