feat(android): playbar everywhere, song-search context menu, offline tab loads offline

- MiniPlayer now shows on every screen (album/artist/playlist detail), not just
  top-level tabs; the tab bar stays top-level-only.
- Local library song search results get the 3-dot context menu (add to playlist,
  favorite, queue, download, …) via onShowContextMenu.
- Offline tab is fed by the local Room DB and no longer waits on the online library:
  it stays usable while the library is loading or the server is unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-17 09:21:02 +02:00
1 parent 03046cd631
commit 419f80568c
3 files changed
+44 -34

No files matched your search

@@ -155,41 +155,43 @@ fun AppNavigation() {
containerColor = NsBackground, containerColor = NsBackground,
modifier = Modifier.background(NsBackground).background(bgGlow), modifier = Modifier.background(NsBackground).background(bgGlow),
bottomBar = { bottomBar = {
if (isTopLevel) { // MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
Column { // is always reachable; the tab bar stays top-level-only.
// ABS audio routes through MusicService — one notification, one mini player. Column {
// When an ABS book is loaded, tapping the mini player opens the ABS overlay // ABS audio routes through MusicService — one notification, one mini player.
// instead of the music NowPlaying screen. // When an ABS book is loaded, tapping the mini player opens the ABS overlay
val hasAbsItem = absState.item != null // instead of the music NowPlaying screen.
val showMiniPlayer = playerState.isPlaying || val hasAbsItem = absState.item != null
playerState.currentSong != null || val showMiniPlayer = playerState.isPlaying ||
(lastPlayedSong != null && !showNowPlaying) playerState.currentSong != null ||
AnimatedVisibility( (lastPlayedSong != null && !showNowPlaying)
visible = showMiniPlayer, AnimatedVisibility(
enter = slideInVertically { it } + fadeIn(tween(220)), visible = showMiniPlayer,
exit = slideOutVertically { it } + fadeOut(tween(180)), enter = slideInVertically { it } + fadeIn(tween(220)),
) { exit = slideOutVertically { it } + fadeOut(tween(180)),
MiniPlayer( ) {
state = playerState, MiniPlayer(
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri, state = playerState,
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true }, coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() }, onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() }, onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() }, onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
lastSong = if (playerState.currentSong == null) lastPlayedSong else null, onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) }, lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
onPlayLast = { lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
lastPlayedSong?.let { ls -> onPlayLast = {
scope.launch { lastPlayedSong?.let { ls ->
runCatching { scope.launch {
val song = repo.getSong(ls.id) runCatching {
player.playSongs(listOf(song), 0) val song = repo.getSong(ls.id)
} player.playSongs(listOf(song), 0)
} }
} }
}, }
) },
} )
}
if (isTopLevel) {
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) { NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
navItems.forEach { dest -> navItems.forEach { dest ->
NavigationBarItem( NavigationBarItem(
@@ -272,6 +274,7 @@ fun AppNavigation() {
onSongPlay = { songs, idx -> player.playSongs(songs, idx) }, onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") }, onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onShowContextMenu = ::showContextMenu,
onOpenImporter = { navController.navigate("importer") }, onOpenImporter = { navController.navigate("importer") },
) )
} }
@@ -49,6 +49,7 @@ fun ExploreScreen(
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit, onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit, onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
onOpenImporter: () -> Unit = {}, onOpenImporter: () -> Unit = {},
) { ) {
val vm: ExploreViewModel = viewModel() val vm: ExploreViewModel = viewModel()
@@ -214,6 +215,7 @@ fun ExploreScreen(
onSongPlay = onSongPlay, onSongPlay = onSongPlay,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onShowContextMenu = onShowContextMenu,
) )
ExploreTab.GLOBAL -> GlobalExploreContent( ExploreTab.GLOBAL -> GlobalExploreContent(
state = state, state = state,
@@ -238,6 +240,7 @@ private fun LibrarySearchContent(
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit, onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit, onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
) { ) {
// Reuse SearchScreen's ViewModel // Reuse SearchScreen's ViewModel
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel() val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
@@ -315,7 +318,9 @@ private fun LibrarySearchContent(
song = song, song = song,
coverUrl = coverUrl(song.coverArt), coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id, isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onSongPlay(songs, idx) }, onPlay = { onSongPlay(songs, idx) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
) )
} }
} }
@@ -108,7 +108,9 @@ fun LibraryScreen(
} }
} }
if (state.isLoading) { // The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
// the online library is still loading or unreachable — only gate the network-backed tabs.
if (state.isLoading && state.tab != LibraryTab.OFFLINE) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent) CircularProgressIndicator(color = NsAccent)
} }