Skip to content

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.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 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):

  1. 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).
  2. In parallel, YouTubeClient resolves a YouTube stream (by ISRC, then title+artist) as insurance.
  3. 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 starts SpotifyPlaybackService (foreground media service + MediaSession lockscreen controls).
  4. 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.
  5. 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:

  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.