A keyboard-first, minimalist SoundCloud client for desktop. Built with Flutter for macOS, Windows, and Linux.
Closer to canonical SoundCloud than any other unofficial client — but darker, quieter, and built so you almost never have to touch the mouse.
v0.2.0 — stability pass. A week of daily-driving shook out the audio-engine, shuffle and likes bugs that made the core loop feel broken; v0.2.0 fixes those and adds clipboard deep-links, top-right toasts, a perceptual volume curve, and consistent real waveforms everywhere. Daily-driver loop works end-to-end on macOS (Windows/Linux build too, less exercised). APIs are unofficial; rough edges remain. Full change log in
CHANGELOG.md.
The web SoundCloud is fine. Tabs are not. Native clients for it either don't exist on desktop, or they're Electron wrappers around the web. Waveform is a real native desktop app:
- Keyboard-first. Space plays. ⌘K finds anything. Arrows scrub the queue. Enter on a track page plays it. You can run the app for hours without touching the trackpad.
- Gapless + crossfade (0–6s). Two-engine architecture — preloads the next track on an inactive
AudioPlayerand instant-swaps on completion. No silence between tracks. Optional crossfade for DJ-style transitions. - OS-native integration. Real media keys via
audio_service— Control Center on macOS, SystemMediaTransportControls on Windows, MPRIS on Linux. Pause from your bluetooth headphones, see now-playing on your lock screen. - Your account, your data. Real SoundCloud login via WebView (your password is never seen by the app — only the session token, stored in the app's sandboxed support directory). Your stream, your likes, your playlists, your play history.
- Last.fm scrobbling. Optional, off until you configure it. Now-playing pings after 3s, scrobbles at ≥50% played or ≥4 min, persisted across launches.
- Local listening stats. Aggregated from
/me/play-history(up to 400 plays): top artists, top genres, total hours, unique tracks. All client-side — nothing leaves the app.
The whole app is one giant Shortcuts + Actions graph. Here's the canonical flow:
⌘K → open command palette (centered, blurred backdrop)
type → live SoundCloud results (debounced 250 ms)
↑ / ↓ → navigate results
↵ → open the selected track / playlist / artist
↵ → (on the /track page) play it, queue = related tracks
Space → pause / resume
→ / ← → next / previous in queue
↑ / ↓ → volume up / down (±5 %)
Esc → close the palette
That's the full loop. Find anything, open it, play it, transport-control it — without your hand leaving the home row.
| Key | Action |
|---|---|
| Space | Play / pause |
| ← / → | Previous / next track |
| ↑ / ↓ | Volume ± 5 % |
| ⌘K or ⌘F | Open command palette |
| ⌘L | Jump to library / likes |
| ⌘, | Open settings |
| ⌘ + Shift + L | Open logs |
↵ (on /track/:id) |
Play / resume this page's track |
macOS: ⌘ — Windows/Linux: Ctrl.
| Key | Action |
|---|---|
| type anything | Live search (tracks / playlists / artists) |
| ↑ / ↓ | Navigate results |
| ↵ | Activate the selected row |
| Esc | Close |
The palette also recognises a few action words — type settings, logs, likes, shuffle, sign out, clear cache and they appear as actionable rows you can fire with Enter.
Every track row has a context menu: play / add to queue / like / repost / copy link / open on SoundCloud / open artist / open track page.
A few common flows once you've launched the app:
Find and play a track. ⌘K → type → ↓ to pick → ↵ to open → ↵ again to play.
Build a queue from a playlist. Open the playlist (from the home shelves or from search). Hit the orange play for sequential or shuffle all to drain the whole playlist (paginates the full set first, then shuffles — true full-collection shuffle, not just the loaded page).
Reorder what's coming up. Click the queue icon in the top bar → floating queue panel slides in from the right with current track at top and upcoming below. Drag the rows to reorder. Click × to remove.
Discover what's adjacent. Open any /track/:id page → scroll to related tracks at the bottom → Enter on any of them to start playing with that track as the new queue head. The current /track page auto-follows playback — when a track auto-advances, the page navigates with it.
Free up screen space. Click the ▽ at the right edge of the bottom player → the player collapses to a 44px mini-bar with cover, ticker, play / next, chevron. Click △ to expand again.
Check your listening. ⌘K → stats → Enter. Or visit /stats directly. Top 10 artists with progress bars, top genres as pills, totals (plays / unique tracks / hours) as big mono numbers.
Scrobble to Last.fm. Settings → last.fm → Connect → browser auth → continue. From then on, every play ≥50 % (or ≥4 min) gets scrobbled; now-playing pings after 3 s.
Pre-built binaries are attached to every GitHub release.
- Download
waveform-macos-vX.Y.Z.dmgfrom the latest release. - Open the DMG and drag
Waveformto yourApplicationsfolder. - Double-click. The app is signed with Apple Developer ID and notarized, so it opens without Gatekeeper prompts.
- Download
waveform-windows-vX.Y.Z.zipand unzip. - Run
Waveform.exe. SmartScreen will say "Unknown publisher" the first time (no EV signing cert yet) — click More info → Run anyway.
- Download
waveform-linux-vX.Y.Z.tar.gz. tar xzfit and run./waveform_app.
After first launch: sign in via Settings → account, then go back to Home — your personal stream, likes, and playlists populate from your real account.
Daily-driver loop:
- Live SoundCloud data via the internal
api-v2. No mock data in the shipping app. - Per-user login through SoundCloud's real sign-in page in an embedded WebView. The session token is captured and persisted locally in
getApplicationSupportDirectory()(sandboxed per-user app data). - Real HLS playback via
just_audio(native AVPlayer on macOS). Gapless + crossfade, with serialized engine ops so the volume slider, progress bar and play/pause never desync mid-transition. Progressive-stream fallback when HLS variants fail. Encrypted HLS (cbc-/ctr-encrypted-hls) is detected and skipped with a UI notice (GO+ tracks). - True-shuffle with real history — next / previous retrace the exact path you played, no repeats within a cycle, stable upcoming queue.
- Perceptual volume (cubic taper) — usable range spread across the whole slider instead of the bottom few percent.
- Real waveforms everywhere — lists, player, feed cards and the track page all show the same SoundCloud waveform (fetched lazily with a shared cache; procedural shape as an instant fallback).
- OS media keys + now-playing card via
audio_service. - Screens: Home (your stream + curated shelves), Feed, Library (likes / playlists / albums / stations / following / history), Search (tracks / people / playlists), Track page (waveform + comments with timecodes + related), Artist page, Playlist page, Settings, Stats. Content is centered and width-capped on wide displays; the right rail stays pinned to the window edge.
- Liked / reposted state synced across track rows, player, and track page. The full likes set loads progressively so highlighting is complete, not capped. Optimistic +1/−1 with revert on API failure.
- Open SoundCloud links in-app — copy a
soundcloud.com/…link and a toast offers to open it here (toggle in Settings → links); or paste it into ⌘K.waveform://scheme registered on macOS. - Top-right toasts that dismiss on click.
- Persistent queue panel with drag-reorder + remove; likes list is virtualized (smooth with hundreds of tracks / "shuffle all").
- Tiles ↔ list view toggle (persisted), available in Library + Search.
- Hero transitions on cover art when navigating cards → detail.
- Ambient album-art backdrop behind hero blocks (full window width, blur 80, soft top→bg gradient).
- Skeleton loaders on first paint.
- Dynamic window title —
Waveform · {artist} — {title}; drag the window by the top bar (all platforms). - In-app log screen at
/logspowered by Talker (Riverpod + Dio integration).
Dark theme by default (light theme planned). Brutal details: 3px radii, 0.5px borders.
| Token | Value | Use |
|---|---|---|
bg / surface / surface2 |
#0A0A0A / #111111 / #1A1A1A |
backgrounds |
textHi / textMid / textLow |
#F5F5F5 / #888888 / #555555 |
text |
acid |
#FF5500 |
active elements only (SoundCloud orange) |
lime |
#C6FF00 |
web3 markers (minted / owned — visual only for now) |
Typography: Inter for text and headings, JetBrains Mono for all numbers, timecodes, durations, and technical labels.
Flutter + Dart 3.11. Key packages: flutter_riverpod (state), go_router (navigation), just_audio (playback), audio_service (OS media controls), dio (HTTP), path_provider (token + cache paths), cached_network_image, google_fonts, talker_flutter (logging), window_manager (dynamic title + traffic-light overlay).
Feature-first layout:
lib/
├── app/ # theme, router, shell, global keyboard shortcuts
├── core/
│ ├── api/ # SoundCloud api-v2 client, DTOs, mappers, auth, liked-tracks
│ ├── audio/ # two-engine just_audio wrapper behind an interface
│ ├── cache/ # image cache (JSON-backed; no sqflite on desktop)
│ ├── lastfm/ # auth flow + scrobbler
│ ├── log/ # Talker
│ └── storage/# file-based token + prefs persistence
├── features/ # auth, home, feed, library, search, track, artist, playlist, player, queue, stats, settings, omnibox, debug
└── shared/ # models, reusable widgets, intents, formatting helpers
State via Riverpod, navigation via go_router. All numerics through an AppTheme.mono() helper; acid orange used sparingly (active elements only).
Prerequisites: a recent Flutter SDK with desktop support enabled.
flutter pub get
flutter run -d macos # or -d windows / -d linuxRun against built-in mock data (offline, no network or login):
flutter run -d macos --dart-define=MOCK=trueTests + static analysis:
flutter test
flutter analyze- Waveform talks to SoundCloud's undocumented
api-v2. The app key (client_id) is scraped from soundcloud.com at runtime — nothing is shipped or hardcoded. - Personal data requires logging in with your own SoundCloud account via the WebView flow. The OAuth token is written to a file inside the app's sandboxed support directory (
getApplicationSupportDirectory()); anonymous endpoints never carry it. - Personal collections use
/users/{id}/…(the/me/…equivalents 404 in api-v2).
- DRM tracks won't play. SoundCloud increasingly serves encrypted HLS (
cbc-/ctr-encrypted-hls), which the desktop audio engine can't decode. Such tracks are detected, marked🔒 GO+in UI, and skipped — playing them would require Widevine/EME support thatjust_audiodoesn't provide on desktop. - macOS-first. Windows/Linux build but are less exercised.
- Like / repost writes are best-effort. The api-v2 write endpoints aren't a stable contract; blocked writes (e.g. behind a VPN / captcha challenge) are surfaced in the UI but may not always recover.
- No play-history writing or comment likes. Verified against api-v2 with a token client — the write endpoints return 404 and tracks/comments expose no per-user like flag — so these aren't implemented. The home "listening history" rail reflects plays recorded by official SoundCloud clients.
- Deep links are clipboard / paste based, not OS-level. Becoming the system handler for
https://soundcloud.comwould need Universal Links hosted on SoundCloud's own servers (impossible) or hijacking allhttps(unacceptable), so Waveform watches the clipboard and accepts pasted links instead. Awaveform://scheme is registered on macOS; Windows/Linux scheme registration is installer-level and pending. - Windows sign-in lags. The embedded WebView2 sign-in page is heavy; the reliable path on Windows is pasting your
oauth_token(the login dialog has a step-by-step guide). - Official OAuth 2.1 + PKCE is not used — SoundCloud's developer-app registration has been effectively closed for years, so the WebView token flow stands in for it.
- No light theme yet — design is dark-by-default.
- No real WalletConnect yet — web3 markers are visual accents only.
- Light theme + system-appearance follow
- Drag tracks between playlists
- Lyrics view on the track page
- Reduced-motion + full VoiceOver pass (accessibility audit)
- Stats: hour-of-week heatmap from
played_at - Always-on-top compact "just the player" mode (300×120)
- Official OAuth 2.1 + PKCE (if SoundCloud reopens registration)
- Real WalletConnect v2 (currently visual accents only)
- Windows EV code-signing (currently
.zipwithout signature → SmartScreen prompt)
The CI/release pipeline is wired up; tagging is what you do by hand.
- Apple Developer Program ($99/yr) — you need the
Developer ID Applicationcert in your local Keychain. Export it as.p12(right-click cert + private key → Export → set a password). - GitHub Actions secrets at
Settings → Secrets and variables → Actions:APPLE_TEAM_ID— 10-char Team ID from Apple Developer → Membership.APPLE_CERT_P12_BASE64—base64 -i cert.p12 | pbcopy.APPLE_CERT_PASSWORD— the password you set on.p12.APPLE_ID— your Apple ID email.APPLE_APP_PASSWORD— an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords; name itWaveform notarytool).KEYCHAIN_PASSWORD— any random string; used only inside the CI runner.
- Last.fm credentials (optional, only if you want scrobbling in the shipped build): register Waveform on last.fm/api/account/create, then add two more GitHub Actions secrets:
LASTFM_API_KEYandLASTFM_SHARED_SECRET. The release workflow passes them through--dart-defineat build time — they end up in the binary but never in the public source. Last.fm explicitly asks to keepshared_secretprivate, so don't paste it intolib/core/lastfm/lastfm_constants.dartdirectly. For local testing:flutter run --dart-define=LASTFM_API_KEY=… --dart-define=LASTFM_SHARED_SECRET=…. - App icon: put a square
1024×1024PNG atassets/icon/icon.pngand run:dart run flutter_launcher_icons flutter clean
# 1. Bump version in pubspec.yaml and lib/features/settings/settings_screen.dart::_kAppVersion
# 2. Update CHANGELOG.md
# 3. Commit + push
git tag v0.2.0
git push --tagsGitHub Actions runs release.yml: parallel macOS / Windows / Linux builds, signs and notarizes the macOS .app, packs everything into a release, and publishes at https://github.com/alina0x/waveform/releases/tag/v0.2.0. Notes come from .github/release_template.md.
If the macOS job fails at codesign or notarytool, check gh run view --log-failed <id> and validate the secrets are present + the .p12 is exportable on a fresh machine.
This is an unofficial client and is not affiliated with, endorsed by, or connected to SoundCloud. It uses an undocumented internal API; use of that API may be against SoundCloud's Terms of Service. This project is provided for educational and personal use — use it with your own account and at your own risk. SoundCloud, the SoundCloud logo, and related marks are trademarks of their respective owners.
Not licensed yet. Until a LICENSE file is added, all rights are reserved. A license will be chosen and added later.





