Overview · Download · First run · Features · Casting · Build · Architecture · FAQ · License
WatchBox is a native Android client for anime, movies and series, written in Kotlin and Jetpack Compose.
Content comes entirely from user-installed Aniyomi-compatible extensions. The app ships no sources, no extension repository, and hosts no media of its own — it is the player and the library, and you decide what it reads from.
| 📺 Two real UIs | 🔌 Extension-driven | 📡 Cast anywhere | 💬 Subtitles |
|---|---|---|---|
| Separate phone and big-screen builds | Aniyomi lib 12–15 | Chromecast + DLNA | Search, download, style |
Note
The interface is a deliberate port of NuvioMobile's design system — the same tokens, typography, spacing, poster metrics, floating pill navigation and player chrome.
Grab the latest build from the Releases page. Two APKs per release — pick by screen size, not by whether the device is "mobile":
| APK | Recommended for |
|---|---|
📱 watchbox-X.Y.Z.apk |
Phones — portrait-first, thumb-reachable, touch affordances |
📺 watchbox-X.Y.Z-tv.apk |
Tablets, Android TV, Google TV, TV boxes |
Tip
On a tablet, install the TV APK. A tablet has the screen area the big-screen UI is drawn for: landscape 16:9 cards, a focus-following backdrop, larger posters and a left navigation rail instead of a bottom bar. The phone APK on a tablet is just the phone layout given more room.
The two use different package names, so both can be installed side by side — worth doing on a tablet to compare before settling on one.
Two things to know before installing the TV build on a tablet
Neither of these is a bug:
- It is landscape-only. Locked to
sensorLandscape, so it will not rotate to portrait. If you hold your tablet in portrait, use the phone APK. - Affordance is focus-based, not touch-based. Taps work — every control is genuinely clickable — but the highlight and scale follow focus, and ripples are switched off because they are invisible at three metres. A tap therefore acts without lighting up. With a keyboard, remote or D-pad attached, it behaves exactly as it does on a television.
Switching between the two builds
The in-app updater picks its APK from the build's own form factor, decided at compile time, and matches release assets by filename. A tablet running the TV build therefore keeps getting TV builds, which is what you want. It also means:
- Switching between them is a manual uninstall and reinstall — the differing package names make them separate apps, not an upgrade path. Library and settings do not carry across.
- Renaming release assets by hand sends devices the wrong build. The
-tvsuffix is load-bearing.
Requirements: Android 7.0 (API 24) or newer. targetSdk is 36.
Tip
WatchBox checks GitHub Releases for new versions and can update itself from
Settings → Check for updates.
The app ships with no extension repository. Bundling one would decide on your behalf which third-party index the app fetches from, so you add your own:
- Open an
aniyomi://add-repo?url=...link — repositories advertise themselves this way and the app adds them automatically, or - paste the URL under Settings → Extension repositories.
Both the repository root and a direct link to its index.min.json are accepted; they
normalise to the same entry. Multiple repositories can be configured and switched on
or off independently.
| Area | What it does |
|---|---|
| 🏠 Home | Spotlight carousel drawn at random from every installed source, Continue Watching, My List, and one rail per source |
| 🔍 Search | Debounced search across every source at once, grouped per source, or narrowed to one |
| 🧩 Extensions | Multiple repositories, each switchable; filter by language, adult content and repository; per-extension settings; load failures surfaced rather than hidden |
| 📄 Detail | Parallax hero with a multi-stop scrim, collapsing floating header, expanding action row, episode list |
| Media3/ExoPlayer with HLS + MP4, quality/subtitle/speed pickers, episode switcher, aspect cycling, gesture seek, brightness and volume swipes, lock mode | |
| 💬 Subtitles | Online search and download, plus size, background style, outline width, colour and opacity — adjustable from Settings or inside the player |
| 📡 Casting | Chromecast and DLNA in one list, with a header-injecting local proxy and a Web Video Caster hand-off |
| 📚 Library | My List, in-progress, and full watch history |
| ⚙️ Settings | Seven accent themes, display scaling, auto-play-next, repository management, subtitle appearance and provider, 18+ toggle |
📺 Android TV and tablets — a separate build, not a stretched layout
- Leanback launcher entry with a banner, so it appears on the TV home screen
- Left navigation rail that expands on focus, replacing the bottom pill — which sat inside the overscan region a television can physically crop
- Backdrop that follows focus, using TMDB backdrops and title logos; landscape 16:9 cards rather than portrait posters
- Full D-pad navigation, with focus visible at three-metre viewing distance
- Remote playback control — directional seek on the timeline, media transport keys, and Back that hides the controls before leaving
- Voice search, because typing a title with a remote is nobody's preference
📱 Phones — and what happens on a tablet
The phone build carries the touch layout: bottom pill navigation, portrait posters, single-pane detail. It also adapts upward if you run it on a tablet anyway — a navigation rail and two-pane detail above 1000dp, with column counts and padding scaling from one shared definition. Nothing is broken there; it is simply the smaller-screen design given more room.
Two protocols, listed together in one picker:
- Chromecast — via the Cast SDK and
MediaRouter, using Google's Default Media Receiver (CC1AD845) - DLNA/UPnP — via SSDP discovery and SOAP AVTransport
Casting is pull-based: you hand the receiver a URL and it opens its own connection.
Neither Cast's LOAD nor DLNA's SetAVTransportURI carries request headers, so a
receiver cannot send the Referer that extension CDNs require. A local proxy therefore
relays those streams — the TV fetches from your phone, and your phone fetches upstream
with the headers. Streams needing no headers skip the proxy entirely.
Details that are easy to get wrong
Each of these silently returns zero devices, or plays nothing at all:
NEARBY_WIFI_DEVICESis required on Android 13+. Without it the router reports no routes. It is requested when the cast panel opens.- A multicast lock is required for SSDP. Android's Wi-Fi driver filters multicast in hardware without one, and SSDP is entirely multicast.
- The SSDP socket must join the multicast group. Several Samsung and LG models
reply to the group rather than the requester, so a plain
DatagramSocketnever sees them. - HLS manifests must be rewritten at every level. A receiver fetches segments, variant playlists and encryption keys itself.
- The segment format must be declared. A Cast receiver assumes MPEG2-TS; handed fragmented MP4 without being told, it reports the duration, downloads segments and never renders a frame.
- Subtitle format differs per protocol. Chromecast accepts only WebVTT; DLNA renderers are built around SubRip and commonly ignore WebVTT. The proxy converts to whichever the receiver wants.
Warning
Most DLNA TVs cannot play HLS. That is a receiver limitation — those sources generally need Chromecast, or the Web Video Caster hand-off. Seeking is also limited on proxied HLS, because the proxy deliberately does not advertise byte-range support for rewritten manifests.
Chromecast needs genuine Google Play Services. Devices with a sideloaded or spoofed GMS register no cast route providers, so no Chromecast will ever be found. DLNA works there, since it needs no Google services.
This is the part worth understanding before changing anything.
Aniyomi-family extension APKs are compiled compileOnly against the Aniyomi source
API and bundle none of it. Disassembling one shows a single class extending
eu.kanade.tachiyomi.animesource.online.AnimeHttpSource — which is not in the APK.
It is resolved at runtime from the host, so this app is the extension runtime
library.
Three consequences:
- The
eu.kanade.tachiyomi.*tree is a fixed ABI. Class names, member names, signatures and even Kotlin file-facade names (RequestsKt,OkHttpExtensionsKt) are load-bearing. Renaming any of them still compiles, then fails at runtime withNoSuchMethodError. - Dependency versions are constraints, not preferences. rxjava 1.3.8, okhttp
5.3.2, jsoup 1.22.1 and
androidx.preferenceare what extensions were compiled against. - R8 must be told to keep all of it. Nothing references the tree statically, so
R8 deletes it by default — the first release build shipped 4,861 classes and zero
eu.kanade.tachiyomiones while the debug build worked fine.
Two checks guard this, both wired into CI:
python3 tools/verify-extension-abi.py # compiled classes
python3 tools/verify-release-abi.py <release.apk> # after minificationSupported API: library versions 12–15. Lib 16 is deliberately rejected — it
made getSeasonList abstract and replaced the video contract with Hoster, so a 16
extension would call members this app does not implement.
Known limitations
- Cloudflare-protected sources will not work. Solving those needs a WebView to run
the JS challenge.
cloudflareClientexists for ABI compatibility but is not a solver, so affected sources fail rather than hang. - Extensions are private to this app. Stored in internal storage rather than
installed system-wide, which avoids needing
REQUEST_INSTALL_PACKAGESandQUERY_ALL_PACKAGES— but means they are not shared with other Aniyomi clients. - No downloads and no tracker sync.
Requires JDK 17 and the Android SDK (platform 36, build-tools 36.0.0).
git clone https://github.com/Nicartjay/Watchbox-Android.git
cd Watchbox-Android
# Point Gradle at your SDK
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
# Phone build, or :app:assembleTvDebug for TV and tablets
./gradlew :app:assembleMobileDebugAPKs land in app/build/outputs/apk/mobile/debug/ and app/build/outputs/apk/tv/debug/.
Task names are flavor-aware: assembleRelease does not exist — use
assembleMobileRelease / assembleTvRelease.
Configuration
Everything has a working default, so no configuration is needed to build.
| Key | Default | Purpose |
|---|---|---|
WATCHBOX_REPO_URL |
yuzono/anime-repo | Seed for BuildConfig.DEFAULT_REPO_URL |
WATCHBOX_VERSION_NAME |
current version in app/build.gradle.kts |
Version name |
WATCHBOX_VERSION_CODE |
1 locally; CI run number in releases |
Version code |
TMDB_API_KEY |
a working shared key | Artwork and metadata enrichment |
Nothing reads WATCHBOX_REPO_URL at runtime any more, since repositories are added by
the user. It survives as a build constant for forks that want to hardcode one.
Releasing
.github/workflows/release.yml builds a minified release APK and publishes it. Run it
from the Actions tab with one of three modes:
| Mode | Effect |
|---|---|
dry-run |
Build only; APK uploaded as a workflow artifact |
draft |
Build and create a draft GitHub Release |
publish |
Build and publish the Release |
Pushing a v* tag (e.g. v3.5.4) publishes automatically and takes the version from
the tag name. versionCode comes from the workflow run number so it always increases,
which Android requires for in-place upgrades. A local build defaults to 1, so a
locally built APK will not install over a released one.
Signing. The release keystore (release.jks) is committed, with store/key password
and alias both watchbox. That is deliberate, and the reason is upgrade compatibility
rather than secrecy: Android refuses an update whose signature differs from the
installed copy, and a debug-key fallback cannot work in CI because every runner
generates its own debug key.
The trade-off is that anyone can build an APK Android treats as an update to this one.
Fine for a personal build. To move the key into CI secrets, set
WATCHBOX_KEYSTORE_BASE64, WATCHBOX_KEYSTORE_PASSWORD, WATCHBOX_KEY_ALIAS and
WATCHBOX_KEY_PASSWORD — when all four are present they override the committed
keystore. Changing keys breaks in-place upgrades for existing installs.
The workflow fails the build if an APK ends up debug-signed, so a dead-end release cannot be published by accident.
Single-module Android app, no Kotlin Multiplatform. Plain layering with a hand-rolled
service locator (AppContainer) instead of Hilt or Koin.
app/src/
├── main/kotlin/ Shared by both builds
├── mobile/kotlin/ Phone entry point
└── tv/kotlin/ TV screens + leanback manifest and banner
app/src/main/kotlin/
├── eu/kanade/tachiyomi/ THE EXTENSION ABI — do not rename
│ ├── animesource/ AnimeSource, AnimeHttpSource, models
│ └── network/ NetworkHelper, Requests, interceptors
└── space/nicart/watchbox/
├── cast/ Chromecast + DLNA transports, discovery, proxy
├── core/ui/ Design tokens, theme, type scale
├── data/local/ DataStore: history, watchlist, settings, repos
├── domain/ UI models + AnimeRepository
├── extension/ Loader, classloader, repo index, installer
└── ui/ home, browse, detail, player, search, library,
settings, extensions, components, navigation
Notable choices
- Parent-last classloading. Extensions bundle their own copies of common
libraries, so their dex is searched before the host's — with a parent-first fallback
on
LinkageError, since a few only link that way. - Every extension call is guarded. Third-party code runs in-process and is linked
at runtime, so failures arrive as
NoSuchMethodErrorrather thanException. One bad source degrades to an empty rail instead of taking down the feed. - Per-source search results. Relevance is not comparable across sources, so merging would bury good matches.
- Identity is
sourceId+ source-relativeurl. There is no global id in this ecosystem, and titles change between fetches. - Subtitles are drawn in Compose, not by Media3's
SubtitleView.SubtitlePainterhardcodes the outline to 2dp andCaptionStyleCompatexposes no width, so an outline-width setting is impossible without rendering the cues. - Two flavors rather than one combined APK. A single build carrying both
LAUNCHERandLEANBACK_LAUNCHERworks, but ships the TV UI to every phone and makes the two impossible to install side by side. - Form factor is tracked separately from width. A 1080p television and a 1080p tablet report near-identical dp widths yet need opposite treatments: the TV is read at three metres with a D-pad and needs fewer, larger targets.
- Cast SDK calls are marshalled to the main thread.
RemoteMediaClientguards 56 methods withcheckMainThread, and so doCastContext.getCastStateandSessionManager.getCurrentCastSession. Never return an SDK object from that hop — only a resolved value, or the guard fires on the next dereference.
Testing
./gradlew :app:testMobileDebugUnitTest :app:testTvDebugUnitTest776 unit tests, run in CI. They deliberately cover only pure logic whose failures are silent on a device — HLS URI rewriting, DLNA SOAP envelopes, subtitle conversion, cast stream selection, gesture maths, filter application, deep-link parsing — because those break in ways that look like missing data rather than errors. Anything better checked by looking at the screen is not unit-tested.
Kotlin 2.1 · Compose BOM 2025.05 · Material 3 · Navigation-Compose (typed routes) · Media3 1.6 · Ktor 3.1 · Coil 2.7 · DataStore · kotlinx.serialization · play-services-cast 22 · androidx.mediarouter 1.7
Extension runtime: okhttp 5.3.2 · rxjava 1.3.8 · jsoup 1.22.1 · Injekt · androidx.preference
Does WatchBox host or stream any content? No. It ships no sources and hosts nothing. All content comes from extensions you choose to install, from repositories you choose to add.
Which APK should I download?
Phones: watchbox-X.Y.Z.apk. Tablets, Android TV and TV boxes:
watchbox-X.Y.Z-tv.apk. On a tablet the TV build is the better experience.
Why are there no extensions after installing? The app ships with no repository on purpose — see First run.
Why does a source show nothing? Most often the source is Cloudflare-protected, or its host is unreachable. Failures are surfaced per source rather than hidden, so the rail reports rather than silently emptying.
Chromecast finds no devices — why? Chromecast discovery needs genuine Google Play Services. Devices with a sideloaded or spoofed GMS register no cast routes at all. DLNA still works.
Can I install both builds at once? Yes — different package names, so they coexist. They do not share library or settings.
Important
WatchBox is a client interface. It ships no sources and does not host, store or distribute any content.
- Content — everything is provided by extensions the user chooses to install. The app is not affiliated with those extensions or the sites they read from.
- Third-party code — installing an extension runs third-party code inside this app's process, with its network access. Only install extensions from repositories you trust.
- Responsibility — users are responsible for how they use the app and any third-party services they interact with, and for complying with applicable law and copyright. Concerns about an extension belong with its author, not this project.
Released under the GNU GPL-3.0, which is required rather than chosen: the design system is ported from GPL-3.0 code, and the GPL is copyleft.
In short — you may use, study, modify and redistribute this, including commercially,
provided derivative works stay under the GPL-3.0 and ship their source. See
LICENSE for the terms that actually govern.
- NuvioMobile (GPL-3.0) — the design system: colour tokens, spacing scale, typography, poster metrics, navigation pill and player chrome.
- Aniyomi (Apache-2.0) — the extension ABI reproduces its interface so existing extensions can link against it. The implementation is this project's own, written against signatures observed in published extension APKs; no Aniyomi source was copied.
- Typeface — JetBrains Sans (Apache-2.0).
- Metadata and artwork — TMDB. This product uses the TMDB API but is not endorsed or certified by TMDB.
WatchBox