Skip to content

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.co and the audio CDN host via InetAddress.getAllByName, warming the resolver cache ahead of the first request.
  • Renderer prewarm — constructs an early WebView on 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 onPlaybackStarted the activity starts SpotifyPlaybackService (a plain Service, foregrounded with FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), which publishes a MediaItem with the track metadata to a MediaSession — 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:

  1. Reads window.__SPOOTY_AD_MODE__ (0 = off, 1 = mute, 2/3 = mute + skip).
  2. Prefers the webpack ListPlayer state; falls back to DOM heuristics.
  3. On an ad: mutes (saving the previous volume). With skip armed, it triggers nextTrack() under the guards described in Features.
  4. 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.