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:
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 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 },
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user