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:
1 parent
d11de8a69e
commit
b7c96451d8
4 files changed
+71
-13
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user