A native desktop music player built with Kotlin and Compose Desktop.
Searches and streams audio from YouTube Music, YouTube, and SoundCloud — no ads, login optional.
An Android app shares the same API, auth and provider code.
Wren is for private, personal use, and it is not affiliated with, endorsed by, or permitted by YouTube, Google, or SoundCloud. It talks to undocumented internal endpoints, scrapes the public client id those web players use, and reads the SoundCloud session out of its own WebView after you sign in. All of that is against those services' terms of use.
It is published so it can be read and run by whoever chooses to, each person on their own account and at their own risk. Using it can get an account suspended or a client id blocked, and nobody but you is responsible for that.
In particular, do not treat it as a downloader. Saving SoundCloud tracks is off unless you turn it on, because fetching whole files is the behaviour those services actually police — it is what gets a client id rotated, and it degrades the app for everyone using it.
No credentials ship with this repo. Bring your own API keys and your own accounts. If you fork this, that warning is yours to keep and to pass on.
- Search songs across YouTube Music, YouTube, and SoundCloud simultaneously, results interleaved and sortable by popularity, duration, or source
- SoundCloud Station: start a radio of related tracks from any SoundCloud result
- Discover per platform: SoundCloud "Made for you" mixes, curated and trending selections plus a weekly list generated from your listening history; YouTube Home built from the artists you follow (new releases) and the tracks you play here (quick picks)
- Artist pages with top songs, albums, singles, and EPs
- Queue playback with automatic prefetching of upcoming tracks
- Download SoundCloud tracks as local audio files (mp3/m4a) straight from the search results
- Google OAuth login to access your YouTube Music playlists and library
- Popularity-based artist sorting using monthly listener counts parsed directly from the YTMusic API
- Now Playing screen with synced/plain lyrics (from lrclib.net) and resizable queue panel
- Dark/light theme toggle in the sidebar
- Persistent player bar with seek, volume, and queue controls
- Android home-screen widget: now playing with play/pause and skip, driven by the same playback service
- Stream URL resolution via
yt-dlp(bundled in AppImage, or available in PATH) - 4-hour stream URL cache for YouTube, 20-minute cache for SoundCloud progressive streams
- UI: Compose Desktop (Jetpack Compose for JVM)
- Language: Kotlin
- Player: JavaCV + FFmpeg (in-process audio decoding and playback)
- Stream resolution:
yt-dlp(for reliable YouTube stream URL extraction) - Concurrency: Kotlin coroutines (
async/coroutineScopefor parallel API calls) - Serialization:
kotlinx.serialization - Auth: OAuth 2.0 with PKCE (S256) local redirect, implemented from scratch without third-party auth libraries
Wren is split into Gradle modules: shared holds everything platform-agnostic (models, API
clients, auth, providers, HTTP), desktop is the shipping Linux app, and android is the
Android app (search, streaming and queue; sessions come later).
shared/src/main/kotlin/
├── models/Models.kt # Domain models (SearchResult, QueueItem, Playlist, ...)
├── api/
│ ├── YoutubeMusic.kt # Public facade — search, artist lookup
│ ├── YtMusicSearch.kt # InnerTube search parsing (songs + artists)
│ ├── YtMusicArtist.kt # Artist page + album track parsing
│ ├── YtMusicPlaylists.kt # YouTube Data API v3 (playlists, view counts)
│ ├── YtMusicRadio.kt # Per-track YouTube Music radio
│ ├── YtSearch.kt # YouTube (non-Music) video search
│ ├── SoundCloud.kt # SoundCloud search/stations/library (client_id scrape)
│ ├── SoundCloudDiscovery.kt# SoundCloud weekly discovery
│ ├── Lyrics.kt # Synced/plain lyrics from lrclib.net
│ ├── ListeningHistory.kt # Local play history (feeds discovery)
│ ├── ApiKeyManager.kt # API key management with config file fallback
│ ├── StreamResolver.kt # Stream URL cache + pluggable resolver
│ ├── YtDlpResolver.kt # Desktop resolver (yt-dlp)
│ └── HttpStreamResolver.kt # Android resolver (InnerTube player + SoundCloud progressive)
├── auth/
│ ├── LocalProfile.kt # Local profile + Google/SoundCloud session store
│ ├── GoogleAuth.kt # Google session lifecycle
│ ├── OAuthFlow.kt # Auth URL, loopback redirect, token exchange
│ ├── SoundCloudAuth.kt # SoundCloud session lifecycle
│ ├── SoundCloudOAuth.kt # SoundCloud PKCE flow
│ └── AuthEvents.kt # Change notifications for Compose
├── provider/
│ ├── MusicProvider.kt # Platform abstraction: search, discover, station, library
│ ├── YouTubeProvider.kt # YouTube + YouTube Music behind one provider (radio, library)
│ └── SoundCloudProvider.kt # SoundCloud provider (stations, selections, likes, playlists)
├── player/PlayerEngine.kt # Transport + observable state contract
└── util/
├── AppDirs.kt # Per-platform config/state directories
├── Http.kt # OkHttp helper (java.net.http needs Android 13+)
└── Log.kt # File logger for diagnostics
desktop/src/main/kotlin/
├── player/FFmpegPlayer.kt # in-process FFmpeg decoder + Java Sound API playback
├── ui/ # Compose Desktop UI (window, screens, dialogs)
├── util/Browser.kt # Opens URLs in the system browser
└── main.kt # Entry point, UI scale detection
android/app/src/main/kotlin/com/wren/app/
├── MainActivity.kt # Compose host
├── WrenApplication.kt # AppDirs + stream resolver wiring
├── player/ExoPlayerEngine.kt # Media3/ExoPlayer PlayerEngine implementation
├── playback/ # Foreground service + transport notification
└── ui/ # Compose UI (search, discover, now playing, player bar)
- JDK 21
yt-dlpavailable in PATH (bundled in AppImage, or install viasudo apt install yt-dlp)- No external media player required — FFmpeg is bundled as a JAR dependency via JavaCV
# Copy and configure the Gradle properties
cp gradle.properties.example gradle.properties
# Edit gradle.properties if you need to point to a specific JDK
./gradlew :desktop:run./build-appimage.sh
./Wren.AppImage./gradlew :desktop:packageDeb
./gradlew :desktop:packageRpmThe Android app (API 26+) covers the desktop experience without sessions yet: search across YouTube/YouTube Music and SoundCloud, streaming playback with a foreground notification, queue, discover and lyrics.
# Build the debug APK
./gradlew :android:app:assembleDebug
# Install on a connected device or emulator
adb install -r android/app/build/outputs/apk/debug/app-debug.apkRelease builds are signed locally — no CI involved. Create the keystore once and keep it safe: it is the only thing that can update an installed app in place.
keytool -genkeypair -v -keystore ~/.android/keys/wren-release.jks -storetype PKCS12 \
-alias wren -keyalg RSA -keysize 4096 -validity 10000 \
-storepass "$WREN_RELEASE_KEYSTORE_PASSWORD" -keypass "$WREN_RELEASE_KEYSTORE_PASSWORD" -dname "CN=wren"Then build it. Credentials come from the environment, never from the repo (AGENTS.md has the
secrets convention), and the signing config is skipped — producing an unsigned APK — when they
are unset:
mise run android:apk # or: ./gradlew :android:app:assembleRelease
adb install -r android/app/build/outputs/apk/release/wren-*.apkVariables: WREN_RELEASE_KEYSTORE_PATH, WREN_RELEASE_KEYSTORE_PASSWORD,
WREN_RELEASE_KEY_ALIAS, WREN_RELEASE_KEY_PASSWORD. PKCS12 uses one password for the store
and the key, so the two password variables hold the same value. Signing with a different key
than an installed build already uses needs an uninstall first.
versionCode counts commits and versionName is the git describe string, so a build
already installed on a phone can be replaced without uninstalling. Both accept an override in
WREN_VERSION_CODE / WREN_VERSION_NAME (what a tagged release would use). The APK is named
from the same string — wren-0.4.0.apk, or wren-<version>-unsigned.apk when the signing
variables above are missing.
Publishing is manual by design — the APK reaches a release page when you decide it does, never as a side effect of pushing:
git tag v0.5.0 && git push origin v0.5.0
mise run android:release # builds, checks the signature, uploads to release v0.5.0The task refuses to run on a dirty tree or an untagged commit (the APK name comes from git, so
there would be nothing to trace it to), verifies the APK is signed by CN=wren before
uploading, and adds the asset to the release the tag already has — the desktop artifacts and
the APK end up on the same page.
Requirements: JDK 21, an Android SDK (ANDROID_HOME, or sdk.dir in local.properties),
and android.useAndroidX=true in gradle.properties (see gradle.properties.example).
Playback differs from desktop:
- Streams are resolved over HTTP — InnerTube's
playerendpoint with theANDROID_VRclient, and SoundCloud progressive transcodings — because Android cannot spawnyt-dlp. - YouTube answers
Sign in to confirm you're not a botfor some tracks (typically gated / label-restricted music videos, more often from datacenter IPs). Those tracks fall back to skipping in the queue. SoundCloud playback is unaffected. - Google and SoundCloud sign-in are a later phase; connecting sessions currently lives in the desktop app.
SoundCloud search, station, and weekly discovery work out of the box. The client ID is scraped automatically from the SoundCloud web app.
To use your own client ID, create ~/.config/wren/soundcloud.json:
{
"client_id": "YOUR_SOUNDCLOUD_CLIENT_ID"
}Listening history is stored locally at ~/.config/wren/history.json and used to generate the weekly discovery playlist.
Wren renders through XWayland (AWT has no stable native Wayland toolkit yet). Wren detects your
monitor scale at startup and renders natively, but the compositor must be told not to rescale
XWayland buffers — otherwise the UI looks pixelated under fractional scaling. In hyprland.conf:
xwayland {
force_zero_scaling = true
}Wren works without any login — search, playback, station, and weekly discovery are fully available.
Logging in unlocks provider-specific features:
| Provider | What it unlocks |
|---|---|
| YouTube Music playlists, library, view count data | |
| SoundCloud | Liked tracks, your playlists, "Made for you" mixes in Discover |
A local profile is created automatically at ~/.config/wren/profile.json. You can rename it from the sidebar.
Official builds bundle a Google OAuth client, so just click GOO → connect in the sidebar and authorize in the browser. Until the app passes Google's OAuth verification you may see an "unverified app" warning on the consent screen.
If you build from source, or want to use your own Cloud project, either:
- export
WREN_GOOGLE_CLIENT_ID/WREN_GOOGLE_CLIENT_SECRETbefore running Gradle (baked in at build time), or - create an OAuth 2.0 client ID in the Google Cloud Console
(Desktop app type, YouTube Data API v3 scope) and save the downloaded
client_secret_*.jsonto~/.config/wren/oauth.json. The file always takes precedence over the bundled client.
Tokens are stored at ~/.config/wren/sessions/google.json and refreshed automatically.
Two ways to connect:
- Browser sign-in (recommended): click SC → connect, then sign_in_with_browser — a window opens with the SoundCloud login page. Sign in normally; Wren captures the session cookie automatically.
- Manual token: get your OAuth token from the browser (cookie
oauth_tokenon soundcloud.com) and paste it.
Session stored at ~/.config/wren/sessions/soundcloud.json.
Wren ships with default API keys for YouTube Search and InnerTube. To use your own keys, create ~/.config/wren/api.json:
{
"youtubeApiKey": "YOUR_YOUTUBE_DATA_API_KEY",
"innerTubeApiKey": "YOUR_INNERTUBE_KEY"
}Keys in this file override the built-in defaults.
Wren ships with default API keys for YouTube Search and InnerTube. To use your own keys, create ~/.config/wren/api.json:
{
"youtubeApiKey": "YOUR_YOUTUBE_DATA_API_KEY",
"innerTubeApiKey": "YOUR_INNERTUBE_KEY"
}Keys in this file override the built-in defaults.
Downloads are saved to ~/Music/Wren on desktop (falling back to the app state dir when
~/Music is not writable) and to Android/data/com.wren.app/files/Music on Android. No
extra binary is required — the audio is copied from the same stream URL the player uses.
This project uses YouTube's internal InnerTube API, which is not publicly documented or officially supported for third-party use. It may break without notice if YouTube changes their API structure. No content is redistributed — the app streams directly from YouTube's CDN.
