feat(android): download notifications everywhere + swipe tabs (NowPlaying, Library)

- Downloads started from global search / Bibliothek now poll via the
  app-lifetime NotificationStore, so they show success/failure like the
  importer and artist page did (was a silent toast only).
- Swipe between tabs, synced with the tab bar, matching Erkunden:
  * Now Playing: Player <-> Lyrics <-> Queue (cover's skip-track swipe
    still wins as a child gesture).
  * Bibliothek: Playlists <-> Alben <-> Künstler <-> Offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-21 23:19:20 +02:00
1 parent d11de8a69e
commit b7c96451d8
4 files changed
+71 -13

No files matched your search

+8
View File
@@ -76,6 +76,10 @@ like Android), concert events become **reliable across web/Android**, and severa
- **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its
Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback
pauses; the row highlights while playing). Parity with the web importer.
- **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing**
(Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline),
two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's
own swipe (skip track) still wins, since it's a child gesture.
### Fixed
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
@@ -105,6 +109,10 @@ like Android), concert events become **reliable across web/Android**, and severa
the library and Deezer artist pages.
- **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill
in the artist hero's corner instead of a full-width labelled button.
- **Downloads from search/Bibliothek were silent (Android)** — a download started from the global
search / Bibliothek-Global tab fired only a transient toast and then registered no
success/failure notification (only the importer and artist page did). It now goes through the same
app-lifetime `NotificationStore` poll, so it notifies on completion even after you leave the screen.
---
@@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
@@ -79,16 +81,28 @@ fun LibraryScreen(
var showCreateDialog by remember { mutableStateOf(false) }
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
// pager's current page so it moves WITH the swipe.
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
LaunchedEffect(state.tab) {
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = LibraryTab.entries[pagerState.settledPage]
if (t != state.tab) vm.selectTab(t)
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = state.tab.ordinal,
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
LibraryTab.entries.forEach { tab ->
Tab(
selected = state.tab == tab,
selected = pagerState.currentPage == tab.ordinal,
onClick = { vm.selectTab(tab) },
text = {
Text(
@@ -108,16 +122,15 @@ fun LibraryScreen(
}
}
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val pageTab = LibraryTab.entries[page]
// 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) {
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
when (state.tab) {
} else when (pageTab) {
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
// Sort chips
androidx.compose.foundation.lazy.LazyRow(
@@ -388,9 +401,10 @@ fun LibraryScreen(
}
}
}
} // end HorizontalPager
} // end Column
if (state.tab == LibraryTab.PLAYLISTS) {
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -76,6 +78,18 @@ fun NowPlayingScreen(
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
// child pointerInput and consumes the drag before the pager sees it.
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = NpTab.entries[pagerState.settledPage]
if (t != tab) tab = t
}
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
val lyricsVm: LyricsViewModel = viewModel()
LaunchedEffect(song?.id) {
@@ -178,7 +192,7 @@ fun NowPlayingScreen(
.padding(3.dp),
) {
NpTab.entries.forEach { t ->
val sel = tab == t
val sel = pagerState.currentPage == t.ordinal
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@@ -210,8 +224,18 @@ fun NowPlayingScreen(
}
}
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f).fillMaxWidth(),
) { page ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
when (NpTab.entries[page]) {
// ── PLAYER TAB ─────────────────────────────────────────────────
if (tab == NpTab.PLAYER) {
NpTab.PLAYER -> {
var dragX by remember { mutableStateOf(0f) }
Spacer(Modifier.height(28.dp))
@@ -401,7 +425,7 @@ fun NowPlayingScreen(
}
// ── LYRICS TAB ─────────────────────────────────────────────────
if (tab == NpTab.LYRICS && song != null) {
NpTab.LYRICS -> if (song != null) {
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val lyricsListState = rememberLazyListState()
val density = LocalDensity.current
@@ -495,7 +519,7 @@ fun NowPlayingScreen(
}
// ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) {
NpTab.QUEUE -> {
// Radio mode toggle
if (onToggleRadio != null) {
Row(
@@ -593,6 +617,9 @@ fun NowPlayingScreen(
}
}
}
} // end when
} // end inner Column
} // end HorizontalPager
}
}
}
@@ -12,6 +12,7 @@ import com.novasonic.app.api.model.ItunesRssEntry
import com.novasonic.app.api.model.Song
import com.novasonic.app.api.model.artworkHd
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -315,7 +316,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") }
.onSuccess {
// App-lifetime poll → success/failure notification even after leaving the screen
// (previously only the artist page + importer notified; search/Bibliothek was silent).
NotificationStore.startPoll(
backendUrl, request.jobId, request.title, request.artist ?: "",
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
)
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}