How it works¶
Spooty's magic lives in the injection pipeline, the JS↔native bridge, and the native playback orchestration. This page walks the stack end to end.
1. Boot¶
SpootyApplication.onCreate starts a coroutine that, gated by settings, does two things:
- DNS preconnect — resolves
open.spotify.com,spclient.wg.spotify.com,i.scdn.coand the audio CDN host viaInetAddress.getAllByName, warming the resolver cache ahead of the first request. - Renderer prewarm — constructs an early
WebViewon the main thread so the renderer process (and its heavy init) is already running when the real one is needed.
2. Load the player¶
MainActivity creates the hidden headless WebView (alpha = 0): the Compose UI is the only
thing the user sees. The WebView is configured with the desktop user-agent, the security
defaults (JS enabled, mixed content NEVER_ALLOW, file access off) and loads
https://open.spotify.com. All navigation funnels through shouldOverrideUrlLoading:
accounts.spotify.com / open.spotify.com stay in-app, everything else opens externally.
3. Inject (once per page)¶
When a page finishes loading, the Injector — and only the Injector — pushes assets into
the page, guarded by window.__spootyInjected:
onPageFinished
├─ core-spooty.js bootstrapper: token sniffer, JS bridge wiring,
│ playback commands, onboarding dialog dismissal
├─ core-spooty.css base mobile/touch adaptation
├─ ad-engine.js ad detection + mute/skip (mode from __SPOOTY_AD_MODE__)
└─ themes/<active>/ color.ini palette → CSS variables + user.css
The bootstrapper re-runs on every page load (navigation happens inside the SPA), so the page keeps the injected UI and ad behavior as you move around the player.
TypeScript sources
All three .js assets above are generated. The source of truth is TypeScript in
web/src/ (core-spooty.ts, ad-engine.ts, spicetify-polyfill.ts), compiled to
app/src/main/assets/ with npm run build --prefix web. CI recompiles and fails if the
committed assets ever diverge from the sources.
4. The bridge (JsBridge)¶
addJavascriptInterface is the only JS→native channel. It's a WeakReference to the
activity and every exposed method carries @JavascriptInterface:
| JS call | Native side |
|---|---|
SpootyBridge.onPlaybackStarted() |
stamps the web-start signal, starts the service |
SpootyBridge.onTrackChanged(uri, title, artist, ...) |
updates NativeUiState |
SpootyBridge.onTrackMetadata(uri, metaJson) |
enriches artwork/metadata |
SpootyBridge.onToken(token) |
hands the harvested session token to Kotlin |
SpootyBridge.openSettings() |
launches SettingsActivity |
SpootyBridge.log(message) |
writes to Logcat |
Native→JS commands (playUriInWebView, pause, next) go the other way via
webView.evaluateJavascript().
5. Playback & the service¶
Spooty is Spotify-first with a native fallback (the default SPOTIFY_FIRST source):
- A track tap routes the URI to the hidden WebView instantly — Chromium plays the authentic, encrypted Spotify stream (CENC audio is decrypted in-browser, no native DRM needed).
- In parallel,
YouTubeClientresolves a YouTube stream (by ISRC, then title+artist) as insurance. - If the web player hasn't signalled audio within
WEB_AUDIO_START_TIMEOUT_MS(12 s), the app switches to ExoPlayer with the resolved native stream and startsSpotifyPlaybackService(foreground media service +MediaSessionlockscreen controls). - A safety re-arm: if nothing is actually playing shortly after the switch, the WebView path
is re-issued. The other audio sources (
SPOTIFY,YOUTUBE,AUTO) pin one side or the other. - Every Spotify track play is scrobbled back to the API, so Wrapped/Airbuds stay accurate even when audio was proxied through YouTube.
The service keeps the process alive for background audio. If background playback is disabled in settings, the service is stopped and the process dies with the activity like a normal app.
6. Ads¶
ad-engine.js runs on its own 1-second poll independent of the bootstrapper's UI poll:
- Reads
window.__SPOOTY_AD_MODE__(0 = off, 1 = mute, 2/3 = mute + skip). - Prefers the webpack
ListPlayerstate; falls back to DOM heuristics. - On an ad: mutes (saving the previous volume). With skip armed, it triggers
nextTrack()under the guards described in Features. - On a real track: restores volume and keeps going.
Safety
Mute is the default and skip must be explicitly enabled in settings. This default is a project invariant — never change it unilaterally.