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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-02 21:30:17 +02:00
1 parent 8741c2fb88
commit ec4d5d0572
4 files changed
+46 -6

No files matched your search

@@ -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<Map<String, Boolean>>(emptyMap())
val overrides: StateFlow<Map<String, Boolean>> = _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
}
}
}
@@ -127,7 +127,8 @@ fun AppNavigation() {
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
fun starToggle(songId: String, currentlyStarred: Boolean) { 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 } fun showContextMenu(song: Song) { contextMenuSong = song }
@@ -432,12 +433,12 @@ fun AppNavigation() {
contextMenuSong?.let { song -> contextMenuSong?.let { song ->
SongContextSheet( SongContextSheet(
song = song, song = song,
isStarred = song.starred != null, isStarred = com.novasonic.app.data.StarState.isStarred(song),
onDismiss = { contextMenuSong = null }, onDismiss = { contextMenuSong = null },
onPlay = { player.playSongs(listOf(song), 0) }, onPlay = { player.playSongs(listOf(song), 0) },
onInsertNext = { player.insertNext(song) }, onInsertNext = { player.insertNext(song) },
onAddToQueue = { player.addToQueueEnd(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 }, onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } }, onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
onShowInfo = { infoSheetSong = song; contextMenuSong = null }, onShowInfo = { infoSheetSong = song; contextMenuSong = null },
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -34,6 +36,9 @@ fun SongRow(
onStarToggle: (() -> Unit)? = null, onStarToggle: (() -> Unit)? = null,
onMore: (() -> 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -113,9 +118,9 @@ fun SongRow(
if (onStarToggle != null) { if (onStarToggle != null) {
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) { IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
Icon( Icon(
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder, if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null, 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), modifier = Modifier.size(17.dp),
) )
} }
@@ -313,7 +313,8 @@ fun NowPlayingScreen(
} }
} }
if (onStarToggle != null && song != null) { 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( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier