Commit Graph
8 Commits
Author SHA1 Message Date
christopherandClaude Sonnet 5 4c116eb84f Phase 6: Playlists, local-first with server sync
Adds user-created playlists synced to/from Navidrome, using the same
Room-plus-WorkManager outbox pattern Phase 5 established for downloads:
every read/write goes through Room first (fully offline-capable), and
mutations mark a PENDING_CREATE/UPDATE/DELETE syncState that
PlaylistSyncWorker later pushes to the server, then pulls the server's
current playlists back down to reconcile.

New Subsonic endpoints: getPlaylists, getPlaylist, createPlaylist,
updatePlaylist, deletePlaylist. Track-list updates are computed as an
add/remove diff against a freshly-fetched remote track list
(computePlaylistSyncDelta, unit tested in isolation since a wrong
index here deletes the wrong track server-side, not just a local
glitch) rather than replaying individual UI actions - simpler, and
self-correcting if a previous push partially failed. Conflict
handling is last-write-wins at the whole-playlist level: pushPending()
always runs before pullRemote(), so a playlist with local changes
queued keeps them as the source of truth for that sync pass.

UI: the bottom-nav Library tab now switches between Playlists and
Artists (Spotify's own convention), a playlist detail screen
(play/download-all/remove-track/delete), and an "add to playlist"
entry point wired into AlbumDetailScreen's track rows - a bottom sheet
listing existing playlists plus inline playlist creation.

Verified on-device against the real Navidrome server: creating a
playlist assigns a real server id and reaches SYNCED; adding and
removing a track push the expected updatePlaylist add/remove diff
(confirmed via the actual HTTP requests) and both directions
reconcile correctly; deleting a playlist issues a real
server-side deletePlaylist; creating a playlist entirely offline
stays queued (WorkManager correctly blocks the sync job on the
CONNECTIVITY constraint, confirmed via dumpsys jobscheduler) and
syncs automatically the moment connectivity returns, with no crash
and no user action needed.

Bumped DeepwaveDatabase to version 2 for the new playlist tables,
using the same fallbackToDestructiveMigration already in place since
Phase 5 - this drops and recreates the whole database, including
Phase 5's downloaded_tracks table, so previously-downloaded tracks'
files remain on disk but drop out of the app's tracking until
re-downloaded. Expected given the documented "schema isn't stable
yet" tradeoff, not a regression, but worth knowing before installing
this build over an existing one with real downloads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:59:17 -04:00
christopherandClaude Sonnet 5 a89e81983e Phase 5: Offline downloads at selectable bitrates
Adds Room + WorkManager-backed offline downloads: a bitrate picker
(Low/Normal/High/Original) on track and album detail screens, a
Downloads library screen, and Settings additions for Wi-Fi-only
downloads and default quality. Bitrate tiers route through Subsonic's
`stream` endpoint with `maxBitRate`; Original uses `download`, which
always returns the untranscoded source file.

Three real bugs found and fixed during on-device verification:

- WorkManager was constructing DownloadWorker with its default
  reflection-based WorkerFactory instead of HiltWorkerFactory
  (NoSuchMethodException on the @AssistedInject constructor). The
  default androidx.startup auto-init ran before Hilt's field
  injection was guaranteed to have happened. Fixed by disabling the
  manifest's auto-init provider and calling WorkManager.initialize()
  manually in DeepwaveApplication.onCreate(), after super.onCreate().

- Crash on every download: WorkManager's own SystemForegroundService
  declares no foregroundServiceType in its manifest, but the worker
  requests dataSync at runtime via ForegroundInfo, which API 29+
  requires to be a subset of what's manifest-declared. Fixed by
  manifest-merging that service with foregroundServiceType="dataSync".

- Offline playback was completely broken: ResolvingDataSource only
  rewrites the DataSpec's URI (to file:// for a downloaded track) but
  always hands it to the same wrapped upstream DataSource to open.
  OkHttpDataSource can only open http(s) URLs, so the rewritten
  file:// URI failed with "Malformed URL" and playback silently fell
  through to the network. Wrapping the signed OkHttpDataSource.Factory
  in DefaultDataSource.Factory routes by scheme instead - this bug was
  latent since Phase 4, since LocalTrackFiles was always empty until
  now and the local-file path was never actually exercised.

- Re-downloading a track at a different quality could produce a
  different file extension (Content-Type-driven), orphaning the
  previous file on disk with no cleanup path. DownloadWorker now
  clears any existing files for the track id before writing the new
  one.

Verified on-device: downloads at all four tiers produce distinctly
different, correctly-ordered file sizes (Low < Normal < High <
Original); a fully downloaded track keeps playing with Wi-Fi and
mobile data both disabled; killing and relaunching the app mid-download
lets WorkManager resume the interrupted download to a correct,
byte-exact final file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:28:42 -04:00
christopherandClaude Sonnet 5 ad10f891b6 Fix double window-inset padding from the mini-player Scaffold hoist
Hoisting the mini-player into an outer Scaffold (previous commit)
introduced a real regression: extra space above the top header, and
a dead gap between the bottom tab bar and the mini-player.

Root cause: TopAppBar and Material3's NavigationBar (used by
BottomNavBar) are self-inset-aware - they only ever looked correct
because they sat directly at the true screen edges. Now they're
nested one level deeper inside the outer Scaffold's content area:
- The outer Scaffold has no topBar, so with its default
  contentWindowInsets it fell back to reserving the raw status-bar
  inset itself as top padding on the NavHost - on top of MainScreen's
  own TopAppBar doing the same thing a second time.
- BottomNavBar unconditionally pads itself for the navigation-bar
  inset regardless of what's below it, but MiniPlayerBar (which
  already self-pads for that inset) now always sits below it - so
  BottomNavBar was reserving space for an inset it's no longer
  adjacent to.

Fixed by zeroing contentWindowInsets on the outer Scaffold (it has
nothing of its own to protect beyond MiniPlayerBar's already-real
measured height) and zeroing BottomNavBar's own windowInsets (since
MiniPlayerBar is now what's genuinely bottom-adjacent).

Verified on-device across Home/Library/ArtistDetail/Settings: tight
header spacing restored, tab bar sits flush against the mini-player
with no gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 04:47:45 -04:00
christopherandClaude Sonnet 5 f79da367dc Fix mini-player disappearing on detail screens; polish Now Playing UI
The user noticed the mini-player ribbon vanished whenever navigating
into an artist/album/settings screen, and asked for the full-player
transport controls to stay anchored to a fixed spot and for long
titles to marquee like Spotify instead of wrapping.

- Mini-player was owned by MainScreen's own Scaffold, so it unmounted
  entirely on any sibling nav destination (ArtistDetail/AlbumDetail/
  Settings). Hoisted it to DeepwaveNavHost, wrapping the whole
  authenticated NavHost in an outer Scaffold whose bottomBar shows
  the mini-player on every route except Login and NowPlaying itself.
  MainScreen no longer needs PlayerViewModel at all as a result -
  reverted it to a single previewable composable.
- That hoist exposed a real inset bug: MiniPlayerBar is a plain Row,
  not a Material3 NavigationBar/BottomAppBar, so it doesn't handle
  system-bar insets on its own - it only ever looked right because it
  sat above the inset-aware BottomNavBar in the same Column. Standing
  alone, it rendered underneath the gesture nav bar. Fixed with an
  explicit navigationBarsPadding().
- NowPlayingScreen: the artwork/title/artist block now gets weight(1f)
  in the Column, so the seek bar and transport controls always land
  at a fixed distance from the bottom regardless of title length -
  previously a long title wrapped to 2-3 lines and pushed the controls
  down inconsistently.
- Long titles now use maxLines = 1 + Modifier.basicMarquee() instead
  of wrapping, on both NowPlayingScreen and MiniPlayerBar's title.

Fixed navController.currentBackStackEntryAsState()-based route
comparison to use route-string equality rather than the hasRoute<T>()
generic (that API isn't available in navigation-compose 2.9.8, this
project's pinned version - only the KClass-less String overload
exists here).

Verified on-device: mini-player now persists correctly through
Home -> Album Detail -> Now Playing and back, sits above the system
nav bar, and the transport controls stay in the same position across
tracks with short and long titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 04:40:36 -04:00
christopherandClaude Sonnet 5 cf52bb854e Phase 4: Media3 playback engine
Builds the single MediaSession-backed player that in-app playback,
offline downloads (Phase 5), and Android Auto (Phase 6) will all
share, per the Phase 2+ roadmap's Phase 4.

- DeepwavePlaybackService: ExoPlayer + MediaLibrarySession, built as
  a MediaLibraryService from day one (not the plainer
  MediaSessionService) so Android Auto only has to add browse-tree
  content later, never rebuild the service. Browse tree is a stub
  for now (MediaLibrarySession.Callback's defaults deny browsing).
- LocalOrRemoteDataSource: the entire "prefer a downloaded file over
  streaming" mechanism as a ResolvingDataSource.Resolver, backed by
  an in-memory LocalTrackFiles registry that stays empty until
  Phase 5 populates it from Room - reused unmodified by Android Auto
  later.
- ExoPlayer's OkHttpDataSource shares the same signed OkHttpClient as
  Retrofit/Coil, so SubsonicRequestInterceptor signs stream requests
  identically to every other Subsonic call - streamUrl() builds the
  request URL the same way coverArtUrl() does for Coil.
- PlaybackController: app-facing facade over a MediaController,
  exposing Flow<PlaybackUiState> - same facade convention as
  ServerRepository. NowPlayingScreen (new, full-screen: artwork,
  seek bar, play/pause/skip) and MiniPlayerBar (now real, replacing
  Phase 2/3's static placeholder) both consume it.
- Track clicks in AlbumDetailScreen (queues the whole album from the
  clicked index) and SearchScreen (single-song queue) now actually
  play, via PlaybackController injected into their ViewModels.
- Scrobbling: submission=false when a track starts, submission=true
  for the outgoing track on each transition - a simplified heuristic
  rather than a played-percentage threshold, feeding Navidrome's
  play-count data that Phase 3's getAlbumList2(frequent/recent) rows
  read from.
- Runtime POST_NOTIFICATIONS request added to MainActivity for API
  33+ (declared in the manifest but easy to forget the runtime half
  of - without it the service still plays, but its notification
  never shows).

Split MainScreen into a thin PlayerViewModel-resolving wrapper plus a
previewable MainContent, rather than having MainScreen call
hiltViewModel() directly - preserves the interactive Android Studio
preview from last session, which a direct Hilt dependency would have
broken.

media3 pinned to 1.11.1 (built against Kotlin 2.2.0, matching this
project's 2.2.10 - learned from Phase 3's Coil version conflict to
check this before picking a version this time).

Verified extensively on-device against the real Navidrome server:
real tracks play with correct metadata/artwork, pause/resume and
queue auto-advance work, the real Android MediaSession exposes
correct state (checked independently via `dumpsys media_session`,
not just the app's own UI), background playback survives
foregrounding/backgrounding, and no crashes across the whole session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 04:26:55 -04:00
christopherandClaude Sonnet 5 08b3a9a0df Phase 3: Subsonic browsing API + real library UI
Replaces Phase 2's sample-data placeholders with real Navidrome
browsing: artists, albums, songs, and search, per the Phase 2+
roadmap's Phase 3.

- SubsonicApi grows from just ping to getArtists/getArtist/
  getAlbumList2/getAlbum/search3, with DTOs split per-domain
  (ArtistModels/AlbumModels/SongModels/SearchModels) and mapped to
  plain domain models (data/model) by the new LibraryRepository
- Coil wired in for cover art, sharing the same signed OkHttpClient
  as Retrofit via a Hilt EntryPoint (SubsonicRequestInterceptor signs
  cover-art requests identically to every other Subsonic call) -
  Artwork() falls back to the Phase 2 placeholder icon when no cover
  art id is known
- ui/library replaces the Phase 2 placeholders: HomeScreen now shows
  real Recently Added/Played and a random "Made For You" row,
  ArtistsScreen is the real Library tab, ArtistDetailScreen and
  AlbumDetailScreen are new drill-down screens (typed nav routes,
  ViewModels resolve their id via SavedStateHandle.toRoute()), and
  SearchScreen does debounced search3 across artists/albums/songs
- TrackRow gained a `circular` option so it can double as an artist
  row in the Library list, not just a track row

First real test coverage in the repo: LibraryRepositoryTest (DTO to
domain-model mapping against MockWebServer) and
SubsonicRequestInterceptorTest (rewritten URL + signed query params).
CredentialsStore/ServerRepository tests are deliberately still out of
scope - CredentialsStore's Tink/Android Keystore usage isn't testable
in a plain JVM unit test without Robolectric, which felt like a
bigger side quest than this phase called for.

Coil pinned to 3.3.0 rather than the newer 3.6.x latest: newer Coil
requires Kotlin 2.4+, which conflicts with this project's Kotlin
2.2.10; 3.3.0 was the last release built against Kotlin 2.2.x.

Verified end-to-end against a real Navidrome server on-device: Home
rows, artist list/detail, album detail, and search all load real data
and real cover art.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 03:59:53 -04:00
christopherandClaude Sonnet 5 a07868b688 Phase 2: Spotify/SoundCloud-style theme, nav shell, and component library
Redirects Deepwave from a vanilla Subsonic client toward the Spotify/
SoundCloud-style product the user wants, per the Phase 2+ roadmap.

- Dark-first Material3 theme with a fixed aqua accent (no Material You
  dynamic color), full type scale, rounded-corner shape system
- Typed (@Serializable) navigation routes; new bottom-tab shell
  (Home/Search/Library) with a settings entry point and mini-player
  placeholder bar, replacing the single placeholder Home screen
- Logout moved out of Home into a dedicated Settings screen
- Reusable component library (TrackRow, AlbumCard, ArtistCard,
  ArtworkPlaceholder, BottomNavBar, MiniPlayerBar) for Phase 3 to wire
  real data into
- Strings externalized to strings.xml
- Security fix: exclude the Tink keyset and encrypted credentials
  DataStore from Android auto-backup (backup_rules.xml /
  data_extraction_rules.xml), previously unexcluded despite
  allowBackup=true
- Fix: Home screen's content column was missing verticalScroll,
  making it unscrollable once content exceeds the viewport; also
  added breathing room between each row's header and its cards
- Split Login/Settings into stateless content composables + thin
  ViewModel-wired wrappers, and added @Preview coverage (including
  interactive previews for MainScreen and BottomNavBar) across every
  screen and component so the UI can be reviewed in Android Studio's
  preview pane without running on a device

Added material-icons-extended as a pragmatic deviation from the
Phase 2 plan (no new deps) since later phases all need icons outside
Compose's small default set (play/pause/skip/download/playlist).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 03:37:58 -04:00
NetherwarlordandClaude Sonnet 5 1971d02e2b Initial commit: Deepwave Phase 1 - auth scaffolding
Spotify-like Navidrome client scaffold with Hilt DI, a Subsonic API
client (token auth, base-URL rewriting), encrypted single-server
credential storage (DataStore + Tink/Keystore), and a login/session
flow gating Compose Navigation between Login and a placeholder Home
screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:38:23 -04:00