SyncWave/CLAUDE.md
TySmi 8919596ac2 New app icon and launch screen from "SyncWave Sv2.png"
- Adaptive icon: the glossy purple S logo, whole image at 94% so the S sits
  inside the 33dp safe zone (it reached 34.6dp untouched), edge blended into
  the new #0E0B14 background that matches the image's border.
- Themed (monochrome) icon: the S as a solid silhouette.
- Launch screen: only the S on #0E0B14, so the round splash crop shows no
  edge of the textured square.
- tools/make_icons.py regenerates all of these from the source PNG.

Verified on the emulator: icon in the app drawer and the launch screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:58:03 -04:00

18 KiB
Raw Blame History

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, branch main). 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-plugin in 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, MediaController in 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 via EXTENSION_RENDERER_MODE_ON (Android's decoders still first). Without it ALAC "plays" silently (position moves, no AudioTrack). Check with adb 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: AppContainer wires 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. SubsonicAuthInterceptor adds 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 is ArtworkColorMath.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.kt and a screen<>/screenWithArgs<> entry in TabsNavHost (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 SyncWaveScrobble shows 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/password demo/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 SWTiming shows 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=1 before adb commands with /sdcard/... paths, and then pass Windows-style local paths to adb pull. Pass -s <serial> when the phone is also connected. Slow animations for inspection with settings put global animator_duration_scale 5, and restore it afterwards.

Done

  1. Login (server URL, username, password), verified with ping; encrypted on-device storage, excluded from backups; sign out (Home tab).

  2. 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).

  3. 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.

  4. 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.

  5. 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 in ui/album/AlbumFacts.kt and 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.

  6. Now Playing polish (2026-10-13): skipping while paused starts playback everywhere (PlayOnSkipPlayer wraps 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).

  7. Queue view (2026-10-13): swipe up on the art (or the queue button) slides up QueueScreen over 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 shared MediaItemFactory.

  8. Server robustness (2026-10-13): ServerRetry retries GETs twice on 502/503/504 or a dropped connection; Songs view limits itself to 2 requests at a time; plain error messages; FailedResponseLogger logs failures (adb logcat -s SyncWaveHttp).

  9. Search tab (2026-10-13): searches as you type (350 ms pause), library via OpenSubsonic search3 and 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, flagged isPreview: 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 via ComingSoonOutsideActions; swap in a real OutsideMusicActions in AppContainer to enable them (Deezer ids, ISRC kept in CatalogRef). If Deezer is unreachable, library results show with a note.

  10. 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.

  11. 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.

  12. 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 (SleepTimer in 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 by LyricsParser): current line white, next faded, slides on change; tap = full-screen LyricsScreen (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: AdaptiveColumn tries 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 (keeps MinControlsHeight 380 dp free) and centres. Verified on the emulator at 1080x2400, 1080x1800 and sideways.

  13. 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 (AlbumListRoute grid, 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.

  14. 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 stream with 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); PlayOnSkipPlayer re-prepares a stopped player on skip. Verified on the emulator against the owner's server.

In progress / unverified

The owner has build 5564ef2 (2026-10-14) 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

  1. Owner feedback on the list above, then fix what they find.
  2. Android Auto. PlaybackService already exports the MediaLibraryService / MediaBrowserService intent filters; fill onGetChildren with a browse tree (e.g. Home rows, Playlists, Albums, Artists), add artwork content URIs Auto can load, and add automotive_app_desc metadata to the manifest.
  3. 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 via OutsideMusicActions), and the server-side "brain" service for AI features.