From ec4d5d05721cdcca4270a54b52f1a6c5c1ed8482 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 2 Jul 2026 21:30:17 +0200 Subject: [PATCH] fix(android): instant star/favorite feedback everywhere Tapping the star only fired the API and never updated any UI state, so the icon didn't change (no feedback). Add a global reactive StarState with optimistic overrides: the star flips instantly across every screen showing that song (SongRow lists, Now Playing, context sheet), the API runs in the background, and the override reverts if the call fails. Co-Authored-By: Claude Opus 4.8 --- .../com/novasonic/app/data/StarState.kt | 33 +++++++++++++++++++ .../com/novasonic/app/ui/AppNavigation.kt | 7 ++-- .../com/novasonic/app/ui/component/SongRow.kt | 9 +++-- .../app/ui/screen/NowPlayingScreen.kt | 3 +- 4 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/data/StarState.kt diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/StarState.kt b/android/app/src/main/kotlin/com/novasonic/app/data/StarState.kt new file mode 100644 index 0000000..aba1ebd --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/StarState.kt @@ -0,0 +1,33 @@ +package com.novasonic.app.data + +import com.novasonic.app.api.model.Song +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately* + * (optimistic), so every screen showing that song updates at once — regardless of when each + * screen last fetched `song.starred`. The API call runs in the background and reverts on failure. + */ +object StarState { + private val _overrides = MutableStateFlow>(emptyMap()) + val overrides: StateFlow> = _overrides.asStateFlow() + + /** Current starred state for a song: the live override if present, else the song's own flag. */ + fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null) + fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback + + /** Optimistically toggle, then hit the API; revert the override if the call fails. */ + fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) { + val current = _overrides.value[songId] ?: fallbackStarred + val next = !current + _overrides.value = _overrides.value + (songId to next) + scope.launch { + val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess + if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure + } + } +} 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 b5b07eb..4684604 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 @@ -127,7 +127,8 @@ fun AppNavigation() { fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) fun starToggle(songId: String, currentlyStarred: Boolean) { - scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } } + // Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API. + com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred) } fun showContextMenu(song: Song) { contextMenuSong = song } @@ -432,12 +433,12 @@ fun AppNavigation() { contextMenuSong?.let { song -> SongContextSheet( song = song, - isStarred = song.starred != null, + isStarred = com.novasonic.app.data.StarState.isStarred(song), onDismiss = { contextMenuSong = null }, onPlay = { player.playSongs(listOf(song), 0) }, onInsertNext = { player.insertNext(song) }, onAddToQueue = { player.addToQueueEnd(song) }, - onStarToggle = { starToggle(song.id, song.starred != null) }, + onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) }, onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null }, onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } }, onShowInfo = { infoSheetSong = song; contextMenuSong = null }, diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt index 4daa4ba..9b1288a 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt @@ -10,6 +10,8 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -34,6 +36,9 @@ fun SongRow( onStarToggle: (() -> Unit)? = null, onMore: (() -> Unit)? = null, ) { + // Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere. + val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState() + val effectiveStarred = starOverrides[song.id] ?: starred Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -113,9 +118,9 @@ fun SongRow( if (onStarToggle != null) { IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) { Icon( - if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder, contentDescription = null, - tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f), + tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(17.dp), ) } 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 2f8e086..6fd4d41 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 @@ -313,7 +313,8 @@ fun NowPlayingScreen( } } if (onStarToggle != null && song != null) { - val starred = song.starred != null + val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState() + val starred = starOverrides[song.id] ?: (song.starred != null) Box( contentAlignment = Alignment.Center, modifier = Modifier