A minimal Android TV app for Autodarts — launches play.autodarts.com fullscreen straight from your TV's home screen, with D-pad focus navigation like a native TV app.
No more opening a browser on your TV, typing URLs and fighting a clunky cursor with the remote. Install once, log in once, done.
Grab the latest autodarts-tv-vX.Y.Z.apk from the Releases page — no build setup needed. Then jump to Installing on your TV.
Autodarts runs great on a Raspberry Pi at the board, and the web app is the natural way to display matches on a TV. But TV browsers are painful: you have to navigate to the site every time, and the web app isn't built for remote controls. This app wraps the web app in a WebView and injects a custom spatial navigation layer so the D-pad behaves the way you'd expect on a TV.
- Leanback launcher entry — appears directly on the Android TV home screen, one click to start
- Persistent login — cookies and localStorage (Keycloak tokens) survive restarts; log in once and never again
- Focus navigation — the D-pad jumps between clickable elements, the selected one is highlighted with a white glow, OK clicks it
- Smart directional search — pressing up really goes up: same-column/row elements are preferred, with a 45° cone as fallback
- Auto-scroll — if there's nothing further in the pressed direction, the page scrolls and the search continues, so the very top and bottom of long pages stay reachable (works with virtualized lists too)
- Cursor fallback — press MENU to toggle a free-moving virtual mouse cursor for anything the focus navigation can't reach
- Fullscreen, landscape, no browser chrome
| Button | Action |
|---|---|
| D-pad | Move the highlight to the nearest element in that direction |
| OK / Enter | Click the highlighted element (focuses text fields → on-screen keyboard) |
| BACK | Navigate back in the web app (doesn't close the app) |
| MENU | Toggle between focus navigation and free cursor mode |
In text fields: left/right move the caret, up/down leave the field and resume navigation.
Only needed if you want to build from source — otherwise just grab the APK from the Releases page.
Requirements: Android Studio (ships with JDK 17 and the Android SDK). The Gradle wrapper is included.
git clone https://github.com/TheJim03/Autodarts-TV.git
cd Autodarts-TV
./gradlew assembleDebug # Windows: .\gradlew.bat assembleDebugThe APK ends up in app/build/outputs/apk/debug/app-debug.apk. Alternatively open the project in Android Studio and use Build → Build APK(s).
The debug APK is fine for personal use. For a signed release build, set up a keystore and run ./gradlew assembleRelease.
- On the TV: Settings → Device Preferences → About → click Build 7 times to enable developer options
- Developer options → enable USB debugging (and Network debugging / ADB over network if your TV has it as a separate switch)
- From your computer (same network):
adb connect <tv-ip>:5555
adb install app/build/outputs/apk/debug/app-debug.apk # later updates: adb install -rAccept the debugging prompt on the TV the first time. If adb connect fails, reboot the TV after enabling developer options.
No ADB? Copy the APK over with an app like Send Files to TV, then install it with a file manager (allow installs from unknown sources).
You'll land on the Autodarts (Keycloak) login. Select the fields with the D-pad — OK opens the on-screen keyboard. Tip: the Google TV Remote app on your phone has a proper keyboard, which makes this one-time login much more pleasant. After that the session persists indefinitely.
- Start URL:
START_URLinMainActivity.kt— point it directly at your board or match view, e.g.https://play.autodarts.com/boards/<board-id>/follow - Highlight style: the CSS block at the top of
spatialnav.js - Clickable element detection: the
SELECTORlist inspatialnav.js, in case some element isn't picked up - Cursor speed (fallback mode): constants in
CursorLayout.kt
Quick iteration tip: paste the contents of spatialnav.js into the DevTools console on play.autodarts.com in a desktop browser — the arrow keys behave exactly like the D-pad on the TV, no rebuild needed.
The app is a single Activity hosting a WebView. After each page load it injects spatialnav.js, which:
- collects all clickable elements (
button,a[href],[role="button"], inputs, …) - on every arrow key press, runs a three-pass geometric search (same column/row → 45° cone → half-plane) to find the nearest element in that direction
- highlights it, scrolls it into view, and clicks it on Enter via
el.click()(which triggers React handlers)
A MutationObserver re-picks a highlight when the current element disappears (route changes, dialogs), and the script survives SPA navigation since it's injected once per document.
- Logged out after restart — make sure you didn't clear the app's data; the session lives in the WebView's cookies/localStorage
- An element can't be reached — toggle cursor mode with MENU as a workaround, then please open an issue with a screenshot so the selector/search can be improved
- Gradle sync fails — usually a proxy/VPN blocking
services.gradle.orgordl.google.com, or an outdated Android Studio (AGP 8.5 needs a recent version)
The community extension Tools for Autodarts (caller sounds, takeout detection, animations, board/lobby tweaks) can run inside this WebView. Android System WebView has no extension subsystem, so the app rebuilds the small part of the MV3 runtime the extension actually uses.
The upstream extension is never forked, patched or committed here. It is
cloned at a pinned tag, built, and the output is copied into assets/tfa/.
Requires Node 20+ and Yarn (the extension is a WXT/Vue project).
node tools/tfa-build.mjsThat clones creazy231/tools-for-autodarts at the pinned tag into third_party/
(gitignored), runs yarn build, and installs .output/chrome-mv3/ into
app/src/main/assets/tfa/ — minus images/ (~30 MB of settings screenshots)
and background.js (replaced by TfaBridge.kt). Then rebuild the APK as usual.
The app runs fine without this step; the injection just finds no assets and logs a skip for each.
To move to a newer extension release, bump DEFAULT_TAG in
tools/tfa-build.mjs (or pass --tag) and re-run.
| Piece | Role |
|---|---|
tfa-shim.js |
Fake browser/chrome namespace: runtime.id, getURL, sendMessage, storage.local |
TfaBridge.kt |
Replaces the MV3 service worker — OkHttp fetch proxy + asset reads |
WebViewAssetLoader |
Serves assets/tfa/** at https://appassets.androidplatform.net/assets/tfa/ |
A shim is sufficient because both bootstraps inside the built bundle only test
that runtime.id is truthy and then adopt the object as-is, promise-based, with
no callback wrapping. A promise-returning stand-in is accepted unchanged.
Four things had to be bridged:
runtime.sendMessage— all 8 call sites send{ type: "fetch", url, options }. The service worker only ever existed to dodge CORS; OkHttp has no CORS. Answers use the upstream contract{ ok, status, statusText, data, error }, wheredatais a data: URL, not bare base64.@JavascriptInterfacemethods are synchronous, so calls carry a callback id and Kotlin answers viawindow.__tfaResolve(id, json).runtime.getURL— 27 static paths. Strategy is switchable at one place (STRATEGYintfa-shim.js):assetloader(default) orblob.play.autodarts.comsends no CSP header and carries no CSP meta tag (verified 2026-08-29), so the asset loader is safe;blobis the escape hatch if that ever changes.storage.local— mapped ontolocalStorageunder a__tfa_storage__prefix, including the per-areastorage.local.onChangedthe WXT driver subscribes to. Sounds (IndexedDB) and animations (OPFS) are not shimmed — WebView does both natively.- Main-world injection — upstream appends
auth-cookie.jsandwebsocket-capture.jsas<script src>to escape the isolated world. We are already in the main world, so both are run inline byMainActivityand theirgetURL()is redirected to an empty__noop.js, otherwisewindow.WebSocketwould get patched twice.
Worth knowing before debugging:
- Unstyled settings panel. The extension loads its CSS with
fetch(getURL(...)). If that response has noAccess-Control-Allow-Origin, the fetch rejects, the extension swallows it, and the panel renders fully functional but with zero styling — nothing logged.shouldInterceptRequestadds the header, andtfa-shim.jsadditionally interceptsfetch()for asset URLs and serves them straight from the bridge, so the CSS does not depend on CORS at all. - A new upstream API. The shim covers four API points. If a future release
reaches for a fifth, MV3 APIs return
undefinedrather than throwing, and the feature just quietly does nothing.tools/tfa-build.mjstherefore scans the built content scripts forbrowser.*/chrome.*usage after every build and fails the build on anything not in itsSHIMMED_APISlist.
node tools/shim-test.mjsRuns tfa-shim.js under Node against the WXT storage driver copied verbatim out
of the built content.js, plus getURL, the fetch interception and the
sendMessage bridge with a stubbed Kotlin side. Needs node tools/tfa-build.mjs
to have run first, since it reads the real CSS out of assets/tfa/.
Fixed in MainActivity.kt and load-bearing:
tfa-shim.js— document_startwebsocket-capture.jsinline — document_startcontent-scripts/websocket-monitor.js— document_startauth-cookie.jsinlineboards.js,content.js,lobby.js,lobbynew.js,match.jsspatialnav.jslast
Steps 1–3 hang off onPageStarted. onPageFinished is too late: by then the app
has already opened its WebSocket and takeout detection would never see a frame.
onPageFinished re-runs them as a guarded safety net in case onPageStarted
lost the race with the document swap.
addJavascriptInterface attaches to every page the WebView loads, and
fetchProxy is a CORS-free proxy that will attach the user's cookies. So
TfaBridge tracks the current document's host and refuses both of its methods
anywhere other than play.autodarts.com — the Keycloak login page and anything
reached through an outbound link get nothing.
The settings UI mounts into a shadow root (createShadowRootUi, host element
<autodarts-tools-wxt>). A plain querySelectorAll sees nothing inside it, so
spatialnav.js now descends into open shadow roots when collecting focusable
elements — and stamps its highlight CSS into each one, since styles do not cross
shadow boundaries.
Enable WebView.setWebContentsDebuggingEnabled(true) and attach chrome://inspect.
Each step depends on the previous one:
browser.runtime.idis truthy in the console → shim loaded- Settings panel appears and is styled → the CSS fetch got through
- Change a setting, restart the app → it survives (storage shim)
- Import a caller sound → bridge fetch + IndexedDB
- Start a match → takeout detection fires (WebSocket patched in time)
- Navigate the settings panel with the remote → shadow-root traversal
On (4)/(5): the caller needs a user interaction before audio plays.
mediaPlaybackRequiresUserGesture = false should cover this, and any D-pad press
counts — but if the app boots straight into a match and the first call is
missing, that is the cause, and a "press OK to start" splash is the fix.
Upstream's licensing is contradictory: its LICENSE file and README state
CC BY-NC 4.0, while its package.json states MIT. Because of that, the extension
is not bundled into the APKs built by the release workflow — the build script
only ever fetches it onto your own machine. Clear redistribution with
creazy231 before publishing any APK with the
extension bundled in.
This is an unofficial community project and is not affiliated with or endorsed by autodarts.io. It simply displays the official web app.
MIT