address review: cancellation-safe star, one star handler, focus logic tested

- setStar no longer treats cancellation as a rejection: rethrow CancellationException
  instead of toasting/rolling back, and roll back under NonCancellable on a real failure
- serialise setStar so overlapping toggles cannot clobber each other's rollback
- the menu star item reverts its label when the request fails (setStar now returns the
  result), so the open menu cannot drift from the row
- ContextMenuHost.dismiss clears the actions so a popped screen's ViewModel is released
- one shared setStar from the app shell instead of five per-ViewModel copies
- extract the nav-focus decision into focusPops and unit-test it
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent 38679c4959
commit ccdeca563f
13 changed files with 118 additions and 73 deletions
@@ -17,8 +17,13 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class LibraryRepository(
private val db: SubtracksDatabase,
@@ -242,22 +247,32 @@ class LibraryRepository(
playlistId: String,
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
private val starLock = Mutex()
suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
): Result<Unit> {
val sourceId = sourceRepository.activeSourceIdOnce()
val source = sourceRepository.activeMusicSource()
if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server"))
val previous = starredValue(sourceId, type, id)
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
return runCatching { source.setStar(type, id, starred) }
.onFailure {
updateStarred(sourceId, type, id, previous)
showMessage("Could not update star")
): Result<Unit> =
starLock.withLock {
val sourceId = sourceRepository.activeSourceIdOnce()
val source = sourceRepository.activeMusicSource()
if (sourceId == null || source == null) {
return@withLock Result.failure(IllegalStateException("No active server"))
}
}
val previous = starredValue(sourceId, type, id)
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
try {
source.setStar(type, id, starred)
Result.success(Unit)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
showMessage("Could not update star")
Result.failure(failure)
}
}
private suspend fun starredValue(
sourceId: Long,
+29 -12
View File
@@ -52,13 +52,13 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
@@ -123,6 +123,20 @@ private object Routes {
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
}
internal data class BackStackKey(
val route: String?,
val argument: String?,
)
internal fun focusPops(
stack: List<BackStackKey>,
pattern: String,
value: String,
): Int? {
val index = stack.indexOfLast { it.route == pattern && it.argument == value }
return if (index < 0) null else stack.size - 1 - index
}
@Composable
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
@@ -144,6 +158,7 @@ private fun MainNavigation() {
?.route
val tabBarVisible = currentRoute == Routes.LIBRARY
val playbackController = koinInject<PlaybackController>()
val libraryRepository = koinInject<LibraryRepository>()
val playback by playbackController.state.collectAsStateWithLifecycle()
var showingQueue by rememberSaveable { mutableStateOf(false) }
var nowPlayingOpen by rememberSaveable { mutableStateOf(false) }
@@ -188,17 +203,14 @@ private fun MainNavigation() {
value: String,
route: String,
) {
val matches: (NavBackStackEntry?) -> Boolean = { entry ->
entry?.destination?.route == pattern && entry.arguments?.getString(argument) == value
}
if (matches(navController.currentBackStackEntry)) return
if (navController.currentBackStack.value.none(matches)) {
navController.navigate(route)
return
}
var guard = navController.currentBackStack.value.size
while (guard-- > 0 && !matches(navController.currentBackStackEntry) && navController.popBackStack()) {
// pop until the matching entry is back on top
val stack =
navController.currentBackStack.value.map {
BackStackKey(it.destination.route, it.arguments?.getString(argument))
}
when (val pops = focusPops(stack, pattern, value)) {
null -> navController.navigate(route)
0 -> Unit
else -> repeat(pops) { navController.popBackStack() }
}
}
LaunchedEffect(Unit) { playbackController.connect() }
@@ -273,6 +285,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
bottomInset = bottomInset,
)
}
@@ -305,6 +318,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
)
}
composable(
@@ -331,6 +345,7 @@ private fun MainNavigation() {
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
)
}
composable(
@@ -345,6 +360,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
)
}
composable(Routes.ADD_SERVER) {
@@ -416,6 +432,7 @@ private fun MainNavigation() {
fadeNowPlaying()
},
contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
modifier = Modifier.fillMaxSize(),
)
}
@@ -95,7 +95,7 @@ class ItemActions(
val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (PlaylistModel) -> Unit = {},
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null,
)
@@ -116,6 +116,7 @@ class ContextMenuHost {
fun dismiss() {
target = null
actions = ItemActions()
}
}
@@ -227,13 +228,17 @@ private fun MenuHeader(target: MenuTarget) {
@Composable
private fun StarItem(
current: Long?,
onToggle: (Boolean) -> Unit,
onSet: suspend (Boolean) -> Result<Unit>,
) {
var starred by remember(current) { mutableStateOf(current) }
val scope = rememberCoroutineScope()
MenuItem(starIcon(starred), starLabel(starred)) {
val starring = starred == null
val previous = starred
val starring = previous == null
starred = if (starring) System.currentTimeMillis() else null
onToggle(starring)
scope.launch {
if (onSet(starring).isFailure) starred = previous
}
}
}
@@ -25,6 +25,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold
@@ -47,6 +48,7 @@ fun AlbumDetailRoute(
onBack: () -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(),
) {
@@ -61,7 +63,7 @@ fun AlbumDetailRoute(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() },
setStar = viewModel::setStar,
setStar = setStar,
viewArtist = onViewArtist,
)
AlbumDetailScreen(
@@ -8,7 +8,6 @@ import com.subtracks.data.model.Disc
import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -54,12 +53,4 @@ class AlbumDetailViewModel(
playbackController.shuffleAlbum(sourceId.first(), albumId)
}
}
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
}
@@ -41,9 +41,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -61,6 +59,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.source.StarType
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
@@ -92,6 +91,7 @@ fun ArtistDetailRoute(
onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
@@ -103,7 +103,7 @@ fun ArtistDetailRoute(
ItemActions(
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
setStar = viewModel::setStar,
setStar = setStar,
viewAlbum = onViewAlbum,
)
ArtistDetailScreen(
@@ -7,7 +7,6 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@@ -68,14 +67,6 @@ class ArtistDetailViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
fun playAlbum(albumId: String) {
viewModelScope.launch {
playbackController.playAlbum(sourceId.first(), albumId, 0)
@@ -113,6 +113,7 @@ import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemActions
@@ -195,6 +196,7 @@ fun LibraryRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
@@ -227,7 +229,7 @@ fun LibraryRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar,
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
@@ -20,7 +20,6 @@ import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueSongQuery
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
@@ -163,14 +162,6 @@ class LibraryViewModel(
}
}
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
fun sync() = syncManager.requestSync()
}
@@ -15,6 +15,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold
@@ -37,6 +38,7 @@ fun PlaylistDetailRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(),
) {
@@ -47,7 +49,7 @@ fun PlaylistDetailRoute(
ItemActions(
playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() },
setStar = viewModel::setStar,
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
@@ -9,7 +9,6 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -56,12 +55,4 @@ class PlaylistDetailViewModel(
playbackController.shufflePlaylist(sourceId.first(), playlistId)
}
}
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
}
@@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -66,6 +65,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.RepeatMode
@@ -78,7 +78,6 @@ import com.subtracks.ui.theme.ArtworkSeedCache
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@Composable
@@ -88,6 +87,7 @@ fun NowPlayingRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
@@ -103,10 +103,9 @@ fun NowPlayingRoute(
}
}
val context = LocalPlatformContext.current
val scope = rememberCoroutineScope()
val actions =
ItemActions(
setStar = { type, id, starred -> scope.launch { libraryRepository.setStar(type, id, starred) } },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
@@ -0,0 +1,39 @@
package com.subtracks.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FocusPopsTest {
private val library = BackStackKey("library", null)
private val artistA = BackStackKey("artist/{artistId}?coverArt={coverArt}", "ar1")
private val albumA = BackStackKey("album/{albumId}?coverArt={coverArt}", "al1")
private val albumB = BackStackKey("album/{albumId}?coverArt={coverArt}", "al2")
@Test
fun anAbsentTargetNavigates() {
assertNull(focusPops(listOf(library, albumA), "artist/{artistId}?coverArt={coverArt}", "ar1"))
}
@Test
fun anEmptyStackNavigates() {
assertNull(focusPops(emptyList(), "album/{albumId}?coverArt={coverArt}", "al1"))
}
@Test
fun aTargetAlreadyOnTopNeedsNoPops() {
assertEquals(0, focusPops(listOf(library, artistA), "artist/{artistId}?coverArt={coverArt}", "ar1"))
}
@Test
fun aTargetUnderTheTopPopsDownToIt() {
assertEquals(1, focusPops(listOf(library, artistA, albumA), "artist/{artistId}?coverArt={coverArt}", "ar1"))
}
@Test
fun theMatchingEntryIsChosenNotJustTheDestination() {
val stack = listOf(library, albumA, artistA, albumB)
assertEquals(2, focusPops(stack, "album/{albumId}?coverArt={coverArt}", "al1"))
assertEquals(0, focusPops(stack, "album/{albumId}?coverArt={coverArt}", "al2"))
}
}