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