From 419f80568c9f043f8c56864e615f09a0fc3607ac Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 17 Jul 2026 09:21:02 +0200 Subject: [PATCH] feat(android): playbar everywhere, song-search context menu, offline tab loads offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../com/novasonic/app/ui/AppNavigation.kt | 69 ++++++++++--------- .../novasonic/app/ui/screen/ExploreScreen.kt | 5 ++ .../novasonic/app/ui/screen/LibraryScreen.kt | 4 +- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index e504d40..d09855c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -155,41 +155,43 @@ fun AppNavigation() { containerColor = NsBackground, modifier = Modifier.background(NsBackground).background(bgGlow), bottomBar = { - if (isTopLevel) { - Column { - // ABS audio routes through MusicService — one notification, one mini player. - // When an ABS book is loaded, tapping the mini player opens the ABS overlay - // instead of the music NowPlaying screen. - val hasAbsItem = absState.item != null - val showMiniPlayer = playerState.isPlaying || - playerState.currentSong != null || - (lastPlayedSong != null && !showNowPlaying) - AnimatedVisibility( - visible = showMiniPlayer, - enter = slideInVertically { it } + fadeIn(tween(220)), - exit = slideOutVertically { it } + fadeOut(tween(180)), - ) { - MiniPlayer( - state = playerState, - coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri, - onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true }, - onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() }, - onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() }, - onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() }, - lastSong = if (playerState.currentSong == null) lastPlayedSong else null, - lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) }, - onPlayLast = { - lastPlayedSong?.let { ls -> - scope.launch { - runCatching { - val song = repo.getSong(ls.id) - player.playSongs(listOf(song), 0) - } + // MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback + // is always reachable; the tab bar stays top-level-only. + Column { + // ABS audio routes through MusicService — one notification, one mini player. + // When an ABS book is loaded, tapping the mini player opens the ABS overlay + // instead of the music NowPlaying screen. + val hasAbsItem = absState.item != null + val showMiniPlayer = playerState.isPlaying || + playerState.currentSong != null || + (lastPlayedSong != null && !showNowPlaying) + AnimatedVisibility( + visible = showMiniPlayer, + enter = slideInVertically { it } + fadeIn(tween(220)), + exit = slideOutVertically { it } + fadeOut(tween(180)), + ) { + MiniPlayer( + state = playerState, + coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri, + onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true }, + onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() }, + onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() }, + onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() }, + lastSong = if (playerState.currentSong == null) lastPlayedSong else null, + lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) }, + onPlayLast = { + lastPlayedSong?.let { ls -> + scope.launch { + runCatching { + val song = repo.getSong(ls.id) + player.playSongs(listOf(song), 0) } } - }, - ) - } + } + }, + ) + } + if (isTopLevel) { NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) { navItems.forEach { dest -> NavigationBarItem( @@ -272,6 +274,7 @@ fun AppNavigation() { onSongPlay = { songs, idx -> player.playSongs(songs, idx) }, onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onArtistClick = { artist -> navController.navigate("artist/${artist.id}") }, + onShowContextMenu = ::showContextMenu, onOpenImporter = { navController.navigate("importer") }, ) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index 2c299c1..313b238 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -49,6 +49,7 @@ fun ExploreScreen( onSongPlay: (List, Int) -> Unit, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit, onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit, + onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null, onOpenImporter: () -> Unit = {}, ) { val vm: ExploreViewModel = viewModel() @@ -214,6 +215,7 @@ fun ExploreScreen( onSongPlay = onSongPlay, onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, + onShowContextMenu = onShowContextMenu, ) ExploreTab.GLOBAL -> GlobalExploreContent( state = state, @@ -238,6 +240,7 @@ private fun LibrarySearchContent( onSongPlay: (List, Int) -> Unit, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit, onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit, + onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null, ) { // Reuse SearchScreen's ViewModel val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel() @@ -315,7 +318,9 @@ private fun LibrarySearchContent( song = song, coverUrl = coverUrl(song.coverArt), isPlaying = playerState.currentSong?.id == song.id, + starred = song.starred != null, onPlay = { onSongPlay(songs, idx) }, + onMore = onShowContextMenu?.let { cb -> { cb(song) } }, ) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index da084e8..36c013e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -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) { CircularProgressIndicator(color = NsAccent) }