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()intocore-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.cominside the WebView;core-spooty.jssniffs the session token. - A token is trusted only when the account's
sp_dccookie is present (and/v1/mereturns 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_dccookie 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)¶
- 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.
JsBridgeis the only JS→native channel and every exposed method is annotated@JavascriptInterface.- Injection only through the Injector in
onPageFinished, guarded bywindow.__spootyInjected. Never add ad-engine/theme/polyfill logic elsewhere. - Playback is resilient. Spotify-first with a native ExoPlayer/YouTube fallback and a safety re-arm — never leave the user with silence.
- 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¶
javaScriptEnabledonly because the player needs it.allowFileAccessFromFileURLs/allowUniversalAccessFromFileURLs— false.mixedContentMode = MIXED_CONTENT_NEVER_ALLOW,usesCleartextTraffic = false.onReceivedSslErroralways callshandler.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 +
assembleDebugon 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.