From b7c96451d849c43cd63b2598ec6307bc05235db4 Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 21 Aug 2026 23:19:20 +0200 Subject: [PATCH] feat(android): download notifications everywhere + swipe tabs (NowPlaying, Library) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 8 +++++ .../novasonic/app/ui/screen/LibraryScreen.kt | 30 +++++++++++----- .../app/ui/screen/NowPlayingScreen.kt | 35 ++++++++++++++++--- .../app/viewmodel/ExploreViewModel.kt | 11 +++++- 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5766eb..e842d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. --- 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 36c013e..2c9a53c 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 @@ -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(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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 71bd088..a00655f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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 } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index 31fa722..2c26ef0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -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() }