Commit Graph
9 Commits
Author SHA1 Message Date
christopherandClaude Sonnet 5 49f941b8b9 Move Downloads out of Settings into a dynamic Library collection
Downloads is no longer a Settings sub-screen - it's now a pinned entry
at the top of Library > Playlists (Settings keeps only the Wi-Fi-only
and default-quality preference toggles, which govern how downloads
behave rather than being downloaded content itself).

The Downloads screen it opens is no longer a flat track list. It now
splits into two sections, both fully reactive to what's actually on
disk:

- Playlists: any playlist with at least one downloaded track. Tapping
  one opens the real PlaylistDetailScreen, not a special-cased view -
  this is a live filter over existing playlists, not a copy of their
  data.
- Tracks: downloaded tracks that aren't a member of any playlist. A
  track belonging to a playlist is never listed twice - that playlist
  already qualifies for the Playlists section by virtue of this same
  track being downloaded, so once it's shown there this list skips it.

New PlaylistDao.observeAllMemberships() (and
PlaylistRepository.observeTrackIdsByPlaylist()) exposes the full
playlist-to-track-id membership map in one reactive query, so working
out which playlists have a downloaded track doesn't require loading
every playlist's full track list. DownloadsViewModel combines that
with the existing downloaded-tracks and playlists flows to derive both
sections - no new database tables, this is a computed view over the
same Room state Phase 5 and Phase 6 already maintain.

Verified on-device: Settings no longer shows a Downloads row; the
Library tab shows the new pinned "Downloads" entry; added a
downloaded track to a test playlist and confirmed it moved from the
flat Tracks list into a Playlists entry with no duplication; confirmed
tapping that entry opens the actual playlist detail screen; deleted
the playlist and confirmed the track reactively reverted to the flat
Tracks list with no manual refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:23:03 -04:00
christopherandClaude Sonnet 5 53d4a73309 Phase 6.5: Android Auto (phone-projection)
Adds a real browse tree to DeepwavePlaybackService, replacing the
Phase 4 stub callback: root -> Playlists/Artists/Albums/Downloaded ->
drill-down -> tracks. Built entirely on Phase 3/5/6's existing
repositories (LibraryRepository, PlaylistRepository,
DownloadRepository) - no new Subsonic calls. A logged-out root shows
a single "log in on your phone first" placeholder instead of the
four categories, since Auto shares the same process/session as the
phone app rather than having its own login flow.

Also wires voice/typed search (onSearch/onGetSearchResult) through
the existing search3-backed LibraryRepository.search(), and
onAddMediaItems to rebuild a streamable URI when the car hands a
browsed item back for playback - a MediaItem's URI doesn't survive
the trip across into Android Auto's process, only its metadata does,
so this reconstructs it from the media id rather than re-fetching
anything.

No androidx.car.app dependency: that library targets navigation/POI
apps, not media - Auto's media category is driven entirely by
MediaLibraryService + media3-session + the automotive_app_desc.xml
manifest declaration added here.

Verified: full app rebuild, Hilt's DI graph resolves with BrowseTree
injected into DeepwavePlaybackService, all unit tests pass, and
in-app playback on the phone itself still works correctly through
the new session callback (confirmed via dumpsys media_session and
logcat, no crashes) - the regression risk of swapping the stub
callback for a real one. Every onGetLibraryRoot/onGetChildren/
onAddMediaItems/onSearch/onGetSearchResult override was verified by
the Kotlin compiler to correctly match Media3 1.11.1's actual
MediaLibrarySession.Callback signatures.

Not verified: the actual browse tree over a live Android Auto
connection. This device has no car or head unit to connect to, and
the Desktop Head Unit tool isn't installed on this machine (Google
no longer ships it through the standard SDK Manager - it's a
separate download). Recommend testing with the actual DHU tool or a
real car/head unit before relying on this in the car.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:02:48 -04:00
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