How it works¶
Spooty's magic lives in the injection pipeline and the JS↔native bridge. 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 WebView, sets the user-agent (desktop by default), applies the
security defaults (JS enabled, mixed content NEVER_ALLOW, file access off) and loads
https://open.spotify.com. All navigation funnels through
WebViewClient.shouldOverrideUrlLoading: Spotify URLs 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: sets __spootyInjected, wires JS bridge,
│ injects the settings button, polls playback state
├─ 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.
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.openSettings() |
launches SettingsActivity |
SpootyBridge.onPlaybackStarted(title, artist) |
mirrors metadata to MediaSession |
SpootyBridge.onPlaybackPaused() |
clears the lockscreen state |
SpootyBridge.log(message) |
writes to Logcat |
The activity is the single owner of everything the bridge touches, so cross-thread calls are routed to the main thread where needed.
5. Playback & the service¶
The WebView's Spotlight <audio> element is the real player. core-spooty.js polls the
DOM/player state every ~2 seconds and reports track + play/pause changes over the bridge:
- On
onPlaybackStartedthe activity startsSpotifyPlaybackService(a plainService, foregrounded withFOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), which publishes a MediaItem with the track metadata to aMediaSession— so the OS lockscreen has controls. - Native audio never flows through the service: ExoPlayer is only a placeholder so the Media3 session framework has a player to wrap.
- 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.