Skip to content

Architecture

Spooty is a True Native Client: Jetpack Compose renders the UI, a hidden WebView drives login + the Spotify web player, and the native layer speaks to the official Spotify Web API.

┌───────────────────────────────────────────────────────────────┐
│ MainActivity (single activity, Jetpack Compose UI)             │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ NativeApp tabs: Home · Library · Player · Marketplace    │   │
│  │  └─ EntityPageMapper ──► Retrofit v1 API (API-first)      │   │
│  │  └─ UniversalAudioPlayer ──► ExoPlayer (native fallback)  │   │
│  │  └─ YouTubeClient (InnerTube) ──► YouTube audio stream    │   │
│  │  └─ SpotifyPlaybackService (foreground MediaSession)      │   │
│  └─────────────────────────────────────────────────────────┘   │
│  ┌─ Hidden WebView (alpha=0) ───────────────────────────────┐   │
│  │ open.spotify.com · JsBridge (addJavascriptInterface)     │   │
│  │  core-spooty.js bootstrapper + token sniffer             │   │
│  │  ad-engine.js (mute default) · themes/                   │   │
│  │  Chromium decrypts Spotify's encrypted audio in-browser  │   │
│  └──────────┬───────────────────────────────────────────────┘   │
│             │ evaluateJavascript (commands) / JsBridge (events) │
└─────────────┼───────────────────────────────────────────────────┘
              ▼
   SpotifyApiClient (Retrofit v1) + SpotifySsrClient (guest fallback)

Command routing

Native UI never renders Spotify's DOM. Instead:

  • Native→JS: a tap on Play/Next/Pause routes through webView.evaluateJavascript() into core-spooty.js (e.g. window.SpootyApi.playUri(uri)).
  • JS→Native: the injected bundle reports track state, playback events, and the harvested session token over JsBridge — every exposed method carries @JavascriptInterface.

Playback

  • Default SPOTIFY_FIRST: the URI is handed to the hidden WebView immediately (authentic Spotify audio). A YouTube stream is resolved in the background as insurance.
  • If the web player has not signalled audio within WEB_AUDIO_START_TIMEOUT_MS (12 s), the app switches to ExoPlayer with the resolved YouTube stream; if that also stalls, the WebView path is re-armed after a safety delay.
  • Audio source is user-selectable in settings: SPOTIFY, YOUTUBE, AUTO, SPOTIFY_FIRST.

Auth (honest sessions)

  • Login runs through accounts.spotify.com inside the WebView; core-spooty.js sniffs the session token.
  • A token is trusted only when the account's sp_dc cookie is present (and /v1/me returns 200 or a cookie-backed 429) — so anonymous/rate-limited sessions are never bound as the user, and Continue / Log in always routes through the real login.
  • An sp_dc cookie can also be pasted in as a fallback login (Settings → Import).

Component map

Component File Responsibility
MainActivity MainActivity.kt WebView host, command routing, playback orchestration, lifecycle
NativeApp + tabs nativeui/ 100% native Composable screens, player, profile
UniversalAudioPlayer nativeui/UniversalAudioPlayer.kt ExoPlayer with Range/UA-fixing datasource
YouTubeClient shared/.../youtube/YouTubeClient.kt InnerTube search + stream resolution
SpotifyPlaybackService SpotifyPlaybackService.kt foreground media service + MediaSession
SpotifyApiClient api/SpotifyApiClient.kt official v1 API (Retrofit) — playlists/albums/artists/search
EntityPageMapper api/EntityPageMapper.kt API-first entity/search mapping with isSearching state
SpotifySsrClient / SsEntityParser api/ guest fallback parsing the modern #initialState blob (mobile UA)
SettingsStore settings/SettingsStore.kt single settings source of truth (DataStore)
Injector inject/Injector.kt the only CSS/JS injection gateway
JsBridge js/JsBridge.kt @JavascriptInterface JS↔native channel
web/ sources web/src/*.ts TypeScript source of truth for injected JS
Assets assets/ generated from web/src/ — never hand-edit

Hard invariants (do not break)

  1. The WebView is headless. All UI is native Compose; the WebView only serves auth, the Spotify web player, and ad behaviour. External links open in the system browser.
  2. JsBridge is the only JS→native channel and every exposed method is annotated @JavascriptInterface.
  3. Injection only through the Injector in onPageFinished, guarded by window.__spootyInjected. Never add ad-engine/theme/polyfill logic elsewhere.
  4. Playback is resilient. Spotify-first with a native ExoPlayer/YouTube fallback and a safety re-arm — never leave the user with silence.
  5. Ad-skip is opt-in. Mute is the default. Changing this default requires an explicit user decision — it's a project invariant.

WebView security posture

  • javaScriptEnabled only because the player needs it.
  • allowFileAccessFromFileURLs / allowUniversalAccessFromFileURLs — false.
  • mixedContentMode = MIXED_CONTENT_NEVER_ALLOW, usesCleartextTraffic = false.
  • onReceivedSslError always calls handler.cancel().
  • All bridge methods gated with @JavascriptInterface.

Testing strategy

  • JVM unit tests (app/src/test, shared/src/test) cover the pure logic: audio-source routing, settings mapping, theme parsing, ad-mode mapping, SSR parsing, YouTube parsing.
  • CI runs lint + ktlint + detekt + unit tests + assembleDebug on every push/PR and on-demand, and produces a debug APK artifact for sideloading. The desktop companion is compiled (:desktop:build) and packaged as a Windows EXE by a dedicated workflow.