6579f008663f10bd406e5607735c186de381474d
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |