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.SongSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class LibraryRepository( class LibraryRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
@@ -242,22 +247,32 @@ class LibraryRepository(
playlistId: String, playlistId: String,
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) } ): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
private val starLock = Mutex()
suspend fun setStar( suspend fun setStar(
type: StarType, type: StarType,
id: String, id: String,
starred: Boolean, starred: Boolean,
): Result<Unit> { ): Result<Unit> =
val sourceId = sourceRepository.activeSourceIdOnce() starLock.withLock {
val source = sourceRepository.activeMusicSource() val sourceId = sourceRepository.activeSourceIdOnce()
if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server")) val source = sourceRepository.activeMusicSource()
val previous = starredValue(sourceId, type, id) if (sourceId == null || source == null) {
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) return@withLock Result.failure(IllegalStateException("No active server"))
return runCatching { source.setStar(type, id, starred) }
.onFailure {
updateStarred(sourceId, type, id, previous)
showMessage("Could not update star")
} }
} 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( private suspend fun starredValue(
sourceId: Long, sourceId: Long,
+29 -12
View File
@@ -52,13 +52,13 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
@@ -123,6 +123,20 @@ private object Routes {
fun playlist(id: String) = "playlist/${Uri.encode(id)}" 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 @Composable
fun SubtracksRoot(root: RootViewModel = koinViewModel()) { fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) { when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
@@ -144,6 +158,7 @@ private fun MainNavigation() {
?.route ?.route
val tabBarVisible = currentRoute == Routes.LIBRARY val tabBarVisible = currentRoute == Routes.LIBRARY
val playbackController = koinInject<PlaybackController>() val playbackController = koinInject<PlaybackController>()
val libraryRepository = koinInject<LibraryRepository>()
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
var showingQueue by rememberSaveable { mutableStateOf(false) } var showingQueue by rememberSaveable { mutableStateOf(false) }
var nowPlayingOpen by rememberSaveable { mutableStateOf(false) } var nowPlayingOpen by rememberSaveable { mutableStateOf(false) }
@@ -188,17 +203,14 @@ private fun MainNavigation() {
value: String, value: String,
route: String, route: String,
) { ) {
val matches: (NavBackStackEntry?) -> Boolean = { entry -> val stack =
entry?.destination?.route == pattern && entry.arguments?.getString(argument) == value navController.currentBackStack.value.map {
} BackStackKey(it.destination.route, it.arguments?.getString(argument))
if (matches(navController.currentBackStackEntry)) return }
if (navController.currentBackStack.value.none(matches)) { when (val pops = focusPops(stack, pattern, value)) {
navController.navigate(route) null -> navController.navigate(route)
return 0 -> Unit
} else -> repeat(pops) { navController.popBackStack() }
var guard = navController.currentBackStack.value.size
while (guard-- > 0 && !matches(navController.currentBackStackEntry) && navController.popBackStack()) {
// pop until the matching entry is back on top
} }
} }
LaunchedEffect(Unit) { playbackController.connect() } LaunchedEffect(Unit) { playbackController.connect() }
@@ -273,6 +285,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
bottomInset = bottomInset, bottomInset = bottomInset,
) )
} }
@@ -305,6 +318,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
) )
} }
composable( composable(
@@ -331,6 +345,7 @@ private fun MainNavigation() {
}, },
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
) )
} }
composable( composable(
@@ -345,6 +360,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
) )
} }
composable(Routes.ADD_SERVER) { composable(Routes.ADD_SERVER) {
@@ -416,6 +432,7 @@ private fun MainNavigation() {
fadeNowPlaying() fadeNowPlaying()
}, },
contextMenuHost = contextMenuHost, contextMenuHost = contextMenuHost,
setStar = libraryRepository::setStar,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -95,7 +95,7 @@ class ItemActions(
val shuffleAlbum: (AlbumModel) -> Unit = {}, val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {}, val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (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 viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null,
) )
@@ -116,6 +116,7 @@ class ContextMenuHost {
fun dismiss() { fun dismiss() {
target = null target = null
actions = ItemActions()
} }
} }
@@ -227,13 +228,17 @@ private fun MenuHeader(target: MenuTarget) {
@Composable @Composable
private fun StarItem( private fun StarItem(
current: Long?, current: Long?,
onToggle: (Boolean) -> Unit, onSet: suspend (Boolean) -> Result<Unit>,
) { ) {
var starred by remember(current) { mutableStateOf(current) } var starred by remember(current) { mutableStateOf(current) }
val scope = rememberCoroutineScope()
MenuItem(starIcon(starred), starLabel(starred)) { MenuItem(starIcon(starred), starLabel(starred)) {
val starring = starred == null val previous = starred
val starring = previous == null
starred = if (starring) System.currentTimeMillis() else 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.Disc
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
@@ -47,6 +48,7 @@ fun AlbumDetailRoute(
onBack: () -> Unit, onBack: () -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -61,7 +63,7 @@ fun AlbumDetailRoute(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) }, playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() }, playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() }, shuffleAlbum = { viewModel.shuffle() },
setStar = viewModel::setStar, setStar = setStar,
viewArtist = onViewArtist, viewArtist = onViewArtist,
) )
AlbumDetailScreen( AlbumDetailScreen(
@@ -8,7 +8,6 @@ import com.subtracks.data.model.Disc
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -54,12 +53,4 @@ class AlbumDetailViewModel(
playbackController.shuffleAlbum(sourceId.first(), albumId) 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.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.alpha 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.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.source.StarType
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -92,6 +91,7 @@ fun ArtistDetailRoute(
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
) { ) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
@@ -103,7 +103,7 @@ fun ArtistDetailRoute(
ItemActions( ItemActions(
playAlbum = { viewModel.playAlbum(it.id) }, playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
setStar = viewModel::setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
) )
ArtistDetailScreen( ArtistDetailScreen(
@@ -7,7 +7,6 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -68,14 +67,6 @@ class ArtistDetailViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
fun playAlbum(albumId: String) { fun playAlbum(albumId: String) {
viewModelScope.launch { viewModelScope.launch {
playbackController.playAlbum(sourceId.first(), albumId, 0) 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.PlaylistSort
import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -195,6 +196,7 @@ fun LibraryRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
bottomInset: Dp, bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(), viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
@@ -227,7 +229,7 @@ fun LibraryRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) }, playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
) )
@@ -20,7 +20,6 @@ import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueSongQuery import com.subtracks.data.repo.QueueSongQuery
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController 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() 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.Playlist
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
@@ -37,6 +38,7 @@ fun PlaylistDetailRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -47,7 +49,7 @@ fun PlaylistDetailRoute(
ItemActions( ItemActions(
playPlaylist = { viewModel.playAll() }, playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() }, shufflePlaylist = { viewModel.shuffle() },
setStar = viewModel::setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
) )
@@ -9,7 +9,6 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -56,12 +55,4 @@ class PlaylistDetailViewModel(
playbackController.shufflePlaylist(sourceId.first(), playlistId) 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.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.RepeatMode 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.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
@@ -88,6 +87,7 @@ fun NowPlayingRoute(
onViewAlbum: (String) -> Unit, onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit, onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null, contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(),
@@ -103,10 +103,9 @@ fun NowPlayingRoute(
} }
} }
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val scope = rememberCoroutineScope()
val actions = val actions =
ItemActions( ItemActions(
setStar = { type, id, starred -> scope.launch { libraryRepository.setStar(type, id, starred) } }, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, 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"))
}
}