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
|
- **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
|
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.
|
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
|
### Fixed
|
||||||
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
|
- **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.
|
the library and Deezer artist pages.
|
||||||
- **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill
|
- **"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.
|
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.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -79,16 +81,28 @@ fun LibraryScreen(
|
|||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = state.tab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = NsAccent,
|
contentColor = NsAccent,
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEach { tab ->
|
LibraryTab.entries.forEach { tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == tab,
|
selected = pagerState.currentPage == tab.ordinal,
|
||||||
onClick = { vm.selectTab(tab) },
|
onClick = { vm.selectTab(tab) },
|
||||||
text = {
|
text = {
|
||||||
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 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.
|
// 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) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
}
|
}
|
||||||
return@Column
|
} else when (pageTab) {
|
||||||
}
|
|
||||||
|
|
||||||
when (state.tab) {
|
|
||||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||||
// Sort chips
|
// Sort chips
|
||||||
androidx.compose.foundation.lazy.LazyRow(
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
@@ -388,9 +401,10 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end HorizontalPager
|
||||||
} // end Column
|
} // end Column
|
||||||
|
|
||||||
if (state.tab == LibraryTab.PLAYLISTS) {
|
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showCreateDialog = true },
|
onClick = { showCreateDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.verticalScroll
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -76,6 +78,18 @@ fun NowPlayingScreen(
|
|||||||
val song = state.currentSong
|
val song = state.currentSong
|
||||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
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
|
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
||||||
val lyricsVm: LyricsViewModel = viewModel()
|
val lyricsVm: LyricsViewModel = viewModel()
|
||||||
LaunchedEffect(song?.id) {
|
LaunchedEffect(song?.id) {
|
||||||
@@ -178,7 +192,7 @@ fun NowPlayingScreen(
|
|||||||
.padding(3.dp),
|
.padding(3.dp),
|
||||||
) {
|
) {
|
||||||
NpTab.entries.forEach { t ->
|
NpTab.entries.forEach { t ->
|
||||||
val sel = tab == t
|
val sel = pagerState.currentPage == t.ordinal
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
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 ─────────────────────────────────────────────────
|
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.PLAYER) {
|
NpTab.PLAYER -> {
|
||||||
var dragX by remember { mutableStateOf(0f) }
|
var dragX by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
@@ -401,7 +425,7 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.LYRICS && song != null) {
|
NpTab.LYRICS -> if (song != null) {
|
||||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||||
val lyricsListState = rememberLazyListState()
|
val lyricsListState = rememberLazyListState()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -495,7 +519,7 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||||
if (tab == NpTab.QUEUE) {
|
NpTab.QUEUE -> {
|
||||||
// Radio mode toggle
|
// Radio mode toggle
|
||||||
if (onToggleRadio != null) {
|
if (onToggleRadio != null) {
|
||||||
Row(
|
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.Song
|
||||||
import com.novasonic.app.api.model.artworkHd
|
import com.novasonic.app.api.model.artworkHd
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -315,7 +316,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
val client = ImporterClient(backendUrl)
|
val client = ImporterClient(backendUrl)
|
||||||
runCatching { client.download(request) }
|
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}") }
|
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user