18 KiB
SyncWave
Personal Android music player for the owner's self-hosted Navidrome server, built in phases. Feel: inspired by Apple Music on iPhone, with SyncWave's own look (no Apple logos or icons). Later phases add Android Auto and a separate server-side "brain" service for AI features, so keep layers clean and the shared models free of API details.
Working with the owner
- The owner does not write code. Explain every change in plain language (what it does for them, not how the Kotlin works), and say plainly what was and wasn't verified.
- Keep the build working after every step: build, run unit tests, and check on the emulator before reporting back.
- Checkpoint = commit after a feature is built and tested, then push to GitHub
(
origin= https://github.com/TySmittie52/SyncWave, private, branchmain). Never force-push. - Don't install on or screenshot the owner's phone (Pixel 10 Pro XL, adb serial
57241FDCQ008C7) unless asked. Test on the emulator (emulator-5554).
Tech
- Kotlin 2.4.10 (pinned via
kotlin-gradle-pluginin the root build; AGP's built-in Kotlin is older), AGP 9.4.1, Gradle 9.6, JDK 25 toolchain. minSdk 28, target/compile 37. - UI: Jetpack Compose (BOM 2026.08.00, Material 3), Navigation Compose 2.10 with type-safe routes, Compose shared-element transitions.
- Network: Retrofit 3 + OkHttp 5 + kotlinx.serialization. Images: Coil 3 (disk cache).
- Playback: Media3 1.11.1: ExoPlayer in a
MediaLibraryService,MediaControllerin the UI. Android has no ALAC (Apple Lossless .m4a) decoder:org.jellyfin.media3:media3-ffmpeg-decoder(1.9.0+1, GPL-3.0, ~1.5 MB/ABI) is the fallback viaEXTENSION_RENDERER_MODE_ON(Android's decoders still first). Without it ALAC "plays" silently (position moves, no AudioTrack). Check withadb shell dumpsys media.audio_flinger(an active track = sound). Bump it when Jellyfin publishes a build for a newer Media3. - Storage: DataStore + Android Keystore (AES-GCM) for the encrypted login.
- Colors from cover art: AndroidX Palette.
- No DI framework:
AppContainerwires everything by hand.
Architecture (app/src/main/java/com/tysmi/syncwave)
AppContainer / SyncWaveApplication long-lived objects; Coil image loader setup
model/ Album, Track, Artist, Playlist, Genre... (no wire-format types)
data/auth/ ServerCredentials, CredentialStore (encrypted), SessionManager (login state)
data/subsonic/ OpenSubsonic API: Retrofit interface, DTOs, token auth interceptor, URLs
data/navidrome/ Navidrome's own REST API: only for the A–Z song list (JWT login)
data/library/ LibraryRepository (single entry point for screens), AlbumSort, AlbumList
(server album lists), SongLetterIndex
data/search/ MusicSearch (library + Deezer, drops owned results), MusicMatch (same-song rules),
TopResult, RecentSearches
data/deezer/ Deezer public API (no key): search + album pages, 30 s previews
data/catalog/ OutsideCatalog interface; OutsideMusicActions ("Play full song" / "Download" hook)
playback/ PlaybackService (MediaLibraryService), PlaybackController (UI's remote),
Scrobbler + ListenTracker (listening reports, offline queue)
ui/main/ MainScreen (tabs, NavHost, mini player, Now Playing sheet), Tabs.kt (routes)
ui/home/ Home rows (HomeSections: add a row there), HomeViewModel, Favorites page
ui/library/ Library home, Albums, Artists/Artist, Songs, Playlists/Playlist, Genres
ui/search/ Search tab, See all, outside album page, outside rows + disabled buttons
ui/album/ Album page (full-width art, parallax, artwork colors, tag details: AlbumFacts)
ui/player/ MiniPlayer, NowPlayingScreen, QueueScreen, GlassPlayButton, FitTitle, PreviewBadge
ui/common/ shared pieces: LargeTitleScaffold, SongRow, AlbumCard, Loadable, ArtworkColors,
SharedElements, FastScrollBar, AppLocals (cover URLs, playback hooks)
ui/theme/ SyncWaveTheme, ArtworkTheme, colors, type
res/mipmap-*/ic_launcher_* app icon from "SyncWave Sv2.png" (project root, glossy purple S): whole
image at 94% as the foreground (S inside the 33dp safe zone), edge blended into
background #0E0B14; monochrome = solid S silhouette.
res/drawable-*/splash_icon.png launch screen (Theme.SyncWave.Starting): only the S on #0E0B14 (no
textured square, so the round crop shows no edge).
Regenerate all these PNGs with tools/make_icons.py (needs Pillow + numpy):
py tools/make_icons.py "SyncWave Sv2.png" app/src/main/res <preview dir>
syncwave-icon-1024.png is the previous logo (no longer used).
Key rules:
- Credentials never go in URLs.
SubsonicAuthInterceptoradds salted-token auth only to requests for the logged-in server's/rest/. Cover-art and stream URLs are credential-free, so image cache keys stay stable. - Brand color:
BrandPurple#6A4DF4 (BrandPurpleLight#A693FF on dark) for all menus, tabs and lists. Never Android dynamic/wallpaper color. Only album pages and Now Playing take background colors from cover art (ArtworkTheme+rememberArtworkColors, darkened to WCAG 4.5:1 for white text). The color isArtworkColorMath.themeColor: palette colors covering under 6% of the cover are ignored (no small red logo taking over a grey photo); the rest are ranked by area with a bonus for colorfulness. Top and bottom of the gradient share that hue. - New screens: add a route in
ui/main/Tabs.ktand ascreen<>/screenWithArgs<>entry inTabsNavHost(MainScreen.kt). Detail pages live outside the tab graphs so any tab can open them; each tab keeps its own back stack. - Lists scroll under the floating mini player + tab bar: use
LocalBottomChromePadding. - The Songs list depends on Navidrome's native API (OpenSubsonic can't sort songs by title). A non-Navidrome server would need a fallback there.
Build, test, run
./gradlew :app:assembleDebug :app:testDebugUnitTest
adb -s emulator-5554 install -r app/build/outputs/apk/debug/app-debug.apk
- 84 JVM unit tests (MockWebServer for API calls incl. Deezer and scrobble, color math, A–Z index, album facts, formatting, search matching / top result / recent searches, listen rules, unplayable-song rules).
- Songs that fail to play:
adb logcat -s SyncWavePlayback. - Scrobbling:
adb logcat -s SyncWaveScrobbleshows now playing / sent / kept for later. Saved listens:adb shell run-as com.tysmi.syncwave cat shared_prefs/scrobbles.xml. - For testing without the owner's server: Navidrome demo,
demo.navidrome.org, user/passworddemo/demo. Its content changes and isn't consistent: the emulator and curl from this PC see different libraries, and the emulator's copy has no playlists and mostly one-song albums. - Playback timing:
adb logcat -s SWTimingshows each step from tap to first sound (playback/PlaybackTrace.kt). Before/after numbers from 2026-10-12 are in the commit message. - Git Bash quirks:
export MSYS_NO_PATHCONV=1before adb commands with/sdcard/...paths, and then pass Windows-style local paths toadb pull. Pass-s <serial>when the phone is also connected. Slow animations for inspection withsettings put global animator_duration_scale 5, and restore it afterwards.
Done
-
Login (server URL, username, password), verified with
ping; encrypted on-device storage, excluded from backups; sign out (Home tab). -
Playback: album page → tap a track plays it with the rest of the album queued. Media3 service keeps playing in the background and with the screen off; notification controls. 3a. Tab bar (Home, Library, Search), floating mini player, Now Playing sheet (spring open, swipe down / Back to close, seek bar, previous/play/next). 3b. Large titles that shrink smoothly into the top bar; album cover flies from grid to album page (shared element); album page with full-width fading cover and parallax; brand purple and artwork-colored backgrounds; landscape layouts for Now Playing and album pages. 3c. Menus (2026-10-12): long-press (with a haptic tick) on songs, albums and artists, or "…" for the current song in the queue view, opens
ItemMenuSheet: Play next, Add to queue, Shuffle, Go to album, Go to artist, with a brief confirmation pill. Provided once by MainScreen (LocalItemMenu,Modifier.menuClickable). -
Library categories: Playlists, Artists (photos, artist page), Albums (sort: title, artist, year, recently added), Songs (A–Z, paged, fast-scroll bar), Downloaded (empty state until offline downloads exist). Library home has larger rows and a two-column Recently Added grid. Genres screens still exist but nothing links to them since 2026-10-12.
-
Now Playing redesign (2026-10-12): the cover fills the screen edge to edge behind everything (full width upright, full height sideways, never cropped), with cover-color tint beyond it and a dark gradient under the white controls. Since 2026-10-13 the cover no longer fades into the tint: it ends in a hard edge with a 1 dp light line (
ArtEdge), and the darkening starts below (or right of) the cover so the art itself stays untouched. The pause shrink was dropped because it broke the edge-to-edge look. -
Album page details (2026-10-12), only from file tags via
getAlbum: detail line (genre · year · LOSSLESS badge, or format · bitrate for lossy), Shuffle / Play / Download ("coming soon" toast) buttons, disc headings for multi-disc albums, and an info block (release date, songs + running time, ℗ label, format with bit depth / sample rate). Logic lives inui/album/AlbumFacts.ktand is unit-tested. Anything the tags lack is hidden. ALAC vs AAC in .m4a is guessed by bitrate (≥ 600 kbps = ALAC), since Navidrome doesn't report the codec. -
Now Playing polish (2026-10-13): skipping while paused starts playback everywhere (
PlayOnSkipPlayerwraps the player in PlaybackService); big auto-fitting title (FitTitle: 32→22sp in up to 2 lines, then a slow one-line scroll); purple frosted-glass play pill (GlassPlayButton: blurred cover + translucent BrandPurple + light edge, squishes on press). -
Queue view (2026-10-13): swipe up on the art (or the queue button) slides up
QueueScreenover Now Playing; swipe down / queue button returns. Current song with star (OpenSubsonic star/unstar) and more menu; Shuffle / Repeat / Autoplay pills; History (queue before the current song) with Clear; Up Next (drag the handle to reorder, swipe the row left to remove); seek bar, controls, Lyrics / Cast ("coming soon") / queue toggle. Autoplay lives in PlaybackService (works with the app closed): when the last song starts it adds getSimilarSongs, else getRandomSongs; setting saved in PlaybackSettings. Queue entries are built by the sharedMediaItemFactory. -
Server robustness (2026-10-13):
ServerRetryretries GETs twice on 502/503/504 or a dropped connection; Songs view limits itself to 2 requests at a time; plain error messages;FailedResponseLoggerlogs failures (adb logcat -s SyncWaveHttp). -
Search tab (2026-10-13): searches as you type (350 ms pause), library via OpenSubsonic
search3and Deezer in parallel. Empty field: recent searches (tap, swipe/X, Clear all; saved when you press Search or tap a result). Library: Top result card, Songs / Albums / Artists with See all (SearchAllRoute, paged). "Not in your library": Deezer songs and albums minus what you own (MusicMatch: artist + title, ignoring case, accents, punctuation, apostrophes, "feat." parts; Deezer's short title too). Tapping a song plays its 30 s preview (MediaItemFactory.createPreview, flaggedisPreview: label on mini player / Now Playing / queue, no star or menu, no Autoplay, hidden from queue History; never scrobbled). Outside album page (OutsideAlbumRoute) lists previews. "Play full song" / "Download" are disabled viaComingSoonOutsideActions; swap in a realOutsideMusicActionsin AppContainer to enable them (Deezer ids, ISRC kept inCatalogRef). If Deezer is unreachable, library results show with a note. -
Scrobbling (2026-10-13):
Scrobbler(AppContainer) listens to the player in PlaybackService, so plays from the app, notification, lock screen or with the screen off all count. "Now playing" when a song actually starts; a full scrobble once it has played half its length or 4 min (ListenTracker; pauses don't count, once per play, repeat = new play), with the time it started. Saved to SharedPreferences first (scrobbles), sent in batches of 50, resent when the network comes back (needs ACCESS_NETWORK_STATE) or at next app start. Previews never report. Verified on the emulator against the owner's server: now playing, offline save with original time, sent 7 s after reconnecting, preview sent nothing. -
ALAC fix (2026-10-13): ALAC songs (e.g. Eminem, Recovery) played silently: Android has no ALAC decoder. Added the FFmpeg decoder fallback (see Tech). Verified on the emulator: "25 to Life" now outputs 44.1 kHz audio via FFmpeg; FLAC still uses
c2.android.flac.decoder. -
Now Playing additions (2026-10-13): star + ⋮ beside the title (star shared with the queue view via
nowPlaying.starred; ⋮ =NowPlayingMenuSheet, also the queue view's ⋮: Add to playlist (own playlists + New playlist; updatePlaylist / createPlaylist), Go to album / artist, Share (text share sheet), Sleep timer (SleepTimerin AppContainer, attached to the ExoPlayer in PlaybackService: 15/30/45/60 min with an 8 s fade, or end of song via pauseAtEndOfMediaItems)). Live lyrics under the artist (LyricsSource→ getLyricsBySongId, then getLyrics; .lrc text parsed byLyricsParser): current line white, next faded, slides on change; tap = full-screenLyricsScreen(follows the song, tap a line to seek, pauses following 4 s after a manual scroll). Plain lyrics = "Lyrics" button; none = nothing. Up Next card (UpNextPeek) opens the queue. Layout:AdaptiveColumntries levels until it fits (0 all, 1 lyrics one line, 2 no Up Next card, 3 no live lyrics, 4 no Lyrics/Cast/Queue row); on short screens the cover also shrinks (keepsMinControlsHeight380 dp free) and centres. Verified on the emulator at 1080x2400, 1080x1800 and sideways. -
Home tab (2026-10-13), now the start tab: rows Recently Played (
recent), Recently Added (newest), Most Played (frequent), Favorites (getStarred2 albums + songs), Rediscover (random), each with See all (AlbumListRoutegrid,FavoritesRoute). Rows load in parallel (3 at a time) with grey placeholder cards, hide when empty or failed, pull-to-refresh keeps old cards until new ones arrive. Rediscover and Recently Played reload each time Home is shown again, except when coming back from a page opened from Home. -
Unplayable songs (2026-10-14): owner reported "skipping a few songs froze the app". Cause: a song whose file is missing on the server (Navidrome answers
streamwith a JSON error marked HTTP 200); it was cached as audio, the player failed and stopped, and Next/Previous then only changed the title. Now: song downloads with a text/JSON/XML Content-Type are refused (UnplayableRules.mayBeAudio, never cached);SkipUnplayable(PlaybackService) removes the failed song's cached copy, shows a toast and skips to the next (waits if the rest of the queue is still arriving; at most 3 in a row; not on network errors);PlayOnSkipPlayerre-prepares a stopped player on skip. Verified on the emulator against the owner's server.
In progress / unverified
The owner has build 8919596 (2026-10-14, new S logo) on their phone: everything in Done above. Collect
their feedback on what only the phone can show:
- Now Playing additions: not tried end to end on the emulator: actually adding to a playlist (the owner has none; not created on their server), the Share sheet, a sleep timer running out (fade + pause), and the full view for plain (unsynced) lyrics.
- Scrobbling end to end: ListenBrainz receiving listens (Navidrome forwards them, if set up there), and plays started from the notification / lock screen (same player listener as the tested paths, not tried separately). One real test listen went to the owner's server from the emulator on 2026-10-13 ~18:25 ("Afterglow (Hazel's Song)", Dayseeker).
- Home: the Favorites row and page (nothing starred on the server yet); the grey loading cards (rows load too fast to see on the emulator).
- Search: the "couldn't reach Deezer" note (emulator was always online; unit tests only cover the failure being caught); See all pages and long-press on results; whether any owned songs slip into "Not in your library".
- Menus and queue (3c): long-press / "…" menus (Play next, Add to queue, Go to album, Go to artist) and the queue view: built and emulator-tested, not yet tried by the owner.
- Only unit-tested: Playlists screens (the demo library the emulator sees has none), lossy labels and disc headings on album pages (demo library is single-disc FLAC).
- Never tested: lock-screen controls (emulator has no screen lock), themed (monochrome) app icon.
- Owner's server: "Ain’t No Fun (Waiting Round to Be a Millionaire)" (AC/DC, Dirty Deeds…) is listed by Navidrome but its file is missing ("no such file or directory"; maybe the curly apostrophe). A full library rescan in Navidrome should fix or drop it. Now skipped with a note.
- Known limits: album sort resets on restart; artist pages load songs one album at a time;
Navidrome login sends the password in a POST body (plain over
http://); Download buttons and the Downloaded page are placeholders; Lyrics and Cast buttons say "coming soon".
Next steps
- Owner feedback on the list above, then fix what they find.
- Android Auto.
PlaybackServicealready exports the MediaLibraryService / MediaBrowserService intent filters; fillonGetChildrenwith a browse tree (e.g. Home rows, Playlists, Albums, Artists), add artwork content URIs Auto can load, and addautomotive_app_descmetadata to the manifest. - Later: offline downloads (Download buttons, Downloaded page), Lyrics, Cast, Home AI mixes
(add a
HomeSection), a server feature to fetch outside music (turns on Play full song / Download viaOutsideMusicActions), and the server-side "brain" service for AI features.