address feedback: optimistic star and menu polish

- star/unstar updates the local row immediately and rolls back with a toast if the server
  rejects it, instead of waiting on the response
- the menu collapses with an animation on dismiss
- star/unstar keeps the menu open and toggles its item in place
- the album/artist rows show the target's name instead of "View album"/"View artist"
- the menu header shows the cover art thumbnail
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent d9e9a6b865
commit c28271590e
11 changed files with 130 additions and 34 deletions
@@ -18,6 +18,7 @@ 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.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
class LibraryRepository( class LibraryRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
@@ -249,9 +250,24 @@ class LibraryRepository(
val sourceId = sourceRepository.activeSourceIdOnce() val sourceId = sourceRepository.activeSourceIdOnce()
val source = sourceRepository.activeMusicSource() val source = sourceRepository.activeMusicSource()
if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server")) 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) } return runCatching { source.setStar(type, id, starred) }
.onSuccess { updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) } .onFailure {
.onFailure { showMessage("Could not update star") } updateStarred(sourceId, type, id, previous)
showMessage("Could not update star")
}
}
private suspend fun starredValue(
sourceId: Long,
type: StarType,
id: String,
): Long? =
when (type) {
StarType.Song -> song(sourceId, id).first()?.starred
StarType.Album -> album(sourceId, id).first()?.starred
StarType.Artist -> artist(sourceId, id).first()?.starred
} }
private suspend fun updateStarred( private suspend fun updateStarred(
@@ -1,6 +1,9 @@
package com.subtracks.ui.components package com.subtracks.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
@@ -15,12 +18,22 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.launch
import com.subtracks.data.model.Album as AlbumModel import com.subtracks.data.model.Album as AlbumModel
import com.subtracks.data.model.Artist as ArtistModel import com.subtracks.data.model.Artist as ArtistModel
import com.subtracks.data.model.Playlist as PlaylistModel import com.subtracks.data.model.Playlist as PlaylistModel
@@ -29,9 +42,11 @@ import com.subtracks.data.model.Song as SongModel
sealed interface MenuTarget { sealed interface MenuTarget {
val title: String val title: String
val subtitle: String? val subtitle: String?
val coverArt: CoverArtRef?
data class Song( data class Song(
val song: SongModel, val song: SongModel,
override val coverArt: CoverArtRef? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = song.title override val title: String get() = song.title
@@ -40,6 +55,7 @@ sealed interface MenuTarget {
data class Album( data class Album(
val album: AlbumModel, val album: AlbumModel,
override val coverArt: CoverArtRef? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = album.name override val title: String get() = album.name
@@ -48,6 +64,7 @@ sealed interface MenuTarget {
data class Artist( data class Artist(
val artist: ArtistModel, val artist: ArtistModel,
override val coverArt: CoverArtRef? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = artist.name override val title: String get() = artist.name
@@ -56,6 +73,7 @@ sealed interface MenuTarget {
data class Playlist( data class Playlist(
val playlist: PlaylistModel, val playlist: PlaylistModel,
override val coverArt: CoverArtRef? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = playlist.name override val title: String get() = playlist.name
@@ -81,54 +99,64 @@ fun ItemContextMenu(
actions: ItemActions, actions: ItemActions,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val dismissThen: (() -> Unit) -> Unit = { action -> val scope = rememberCoroutineScope()
fun dismiss(after: () -> Unit = {}) {
scope.launch {
sheetState.hide()
after()
onDismiss() onDismiss()
action()
} }
}
ModalBottomSheet(
onDismissRequest = { dismiss() },
sheetState = sheetState,
) {
MenuHeader(target) MenuHeader(target)
when (target) { when (target) {
is MenuTarget.Song -> { is MenuTarget.Song -> {
actions.playSong?.let { play -> actions.playSong?.let { play ->
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { play(target.song) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
}
MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) {
dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) }
} }
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
target.song.albumId?.let { albumId -> target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view -> actions.viewAlbum?.let { view ->
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { view(albumId) } } MenuItem(Icons.Rounded.Album, target.song.album?.takeIf { it.isNotBlank() } ?: "Album") {
dismiss { view(albumId) }
}
} }
} }
target.song.artistId?.let { artistId -> target.song.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { view(artistId) } } MenuItem(Icons.Rounded.Person, target.song.artist?.takeIf { it.isNotBlank() } ?: "Artist") {
dismiss { view(artistId) }
}
} }
} }
} }
is MenuTarget.Album -> { is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playAlbum(target.album) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shuffleAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
MenuItem(starIcon(target.album.starred), starLabel(target.album.starred)) { StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) }
dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) }
}
target.album.artistId?.let { artistId -> target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { view(artistId) } } MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") {
dismiss { view(artistId) }
}
} }
} }
} }
is MenuTarget.Artist -> { is MenuTarget.Artist -> {
MenuItem(starIcon(target.artist.starred), starLabel(target.artist.starred)) { StarItem(target.artist.starred) { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }
dismissThen { actions.setStar(StarType.Artist, target.artist.id, target.artist.starred == null) }
}
} }
is MenuTarget.Playlist -> { is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playPlaylist(target.playlist) } } MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shufflePlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
} }
} }
} }
@@ -155,10 +183,30 @@ private fun MenuHeader(target: MenuTarget) {
) )
} }
}, },
leadingContent = {
CoverArt(
ref = target.coverArt,
name = target.title,
modifier = Modifier.size(48.dp).clip(if (target is MenuTarget.Artist) CircleShape else RoundedCornerShape(8.dp)),
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
@Composable
private fun StarItem(
current: Long?,
onToggle: (Boolean) -> Unit,
) {
var starred by remember(current) { mutableStateOf(current) }
MenuItem(starIcon(starred), starLabel(starred)) {
val starring = starred == null
starred = if (starring) System.currentTimeMillis() else null
onToggle(starring)
}
}
@Composable @Composable
private fun MenuItem( private fun MenuItem(
icon: ImageVector, icon: ImageVector,
@@ -77,7 +77,7 @@ fun AlbumDetailRoute(
onSongLongClick = { menuTarget = it }, onSongLongClick = { menuTarget = it },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { album?.let { menuTarget = MenuTarget.Album(it) } }, onMore = { album?.let { menuTarget = MenuTarget.Album(it, viewModel.coverArt(it.coverArt, true)) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
) )
menuTarget?.let { target -> menuTarget?.let { target ->
@@ -161,7 +161,7 @@ fun AlbumDetailScreen(
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
onClick = { onSongClick(index) }, onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(song)) }, onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) },
), ),
) )
} }
@@ -94,7 +94,7 @@ fun AlbumsContent(
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.combinedClickable( .combinedClickable(
onClick = { onAlbumClick(album) }, onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album)) }, onLongClick = { onLongClick(MenuTarget.Album(album, art)) },
).testTag(ALBUM_COVER_TAG), ).testTag(ALBUM_COVER_TAG),
) )
} }
@@ -116,7 +116,7 @@ fun ArtistDetailRoute(
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onAlbumLongClick = { menuTarget = it }, onAlbumLongClick = { menuTarget = it },
onMore = { artist?.let { menuTarget = MenuTarget.Artist(it) } }, onMore = { artist?.let { menuTarget = MenuTarget.Artist(it, artThumbnail ?: art) } },
) )
menuTarget?.let { target -> menuTarget?.let { target ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null }) ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
@@ -238,7 +238,7 @@ fun ArtistDetailScreen(
end = if (index % 2 == 1) 16.dp else 0.dp, end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable( ).combinedClickable(
onClick = { onAlbumClick(album) }, onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album)) }, onLongClick = { onAlbumLongClick(MenuTarget.Album(album, coverArt(album.coverArt, true))) },
), ),
) { ) {
CoverArt( CoverArt(
@@ -98,7 +98,7 @@ fun ArtistsContent(
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onArtistClick(artist) }, onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist)) }, onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
), ),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
@@ -61,7 +61,7 @@ fun PlaylistDetailRoute(
onSongLongClick = { menuTarget = it }, onSongLongClick = { menuTarget = it },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { playlist?.let { menuTarget = MenuTarget.Playlist(it) } }, onMore = { playlist?.let { menuTarget = MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
) )
menuTarget?.let { target -> menuTarget?.let { target ->
@@ -116,7 +116,7 @@ fun PlaylistDetailScreen(
modifier = modifier =
rowModifier.combinedClickable( rowModifier.combinedClickable(
onClick = { onSongClick(index) }, onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(item.song)) }, onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
), ),
) )
} }
@@ -96,7 +96,7 @@ fun PlaylistsContent(
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onPlaylistClick(playlist) }, onClick = { onPlaylistClick(playlist) },
onLongClick = { onLongClick(MenuTarget.Playlist(playlist)) }, onLongClick = { onLongClick(MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true))) },
), ),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
@@ -91,7 +91,7 @@ fun SongsContent(
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onSongClick(item.song.id) }, onClick = { onSongClick(item.song.id) },
onLongClick = { onLongClick(MenuTarget.Song(item.song)) }, onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
), ),
) )
} }
@@ -138,7 +138,7 @@ fun NowPlayingRoute(
onPrevious = controller::previous, onPrevious = controller::previous,
onShuffle = controller::toggleShuffle, onShuffle = controller::toggleShuffle,
onRepeat = controller::cycleRepeat, onRepeat = controller::cycleRepeat,
onMore = { song?.let { menuTarget = MenuTarget.Song(it) } }, onMore = { song?.let { menuTarget = MenuTarget.Song(it, thumbnail) } },
onSeek = controller::seekTo, onSeek = controller::seekTo,
modifier = modifier, modifier = modifier,
) )
@@ -9,8 +9,11 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
@@ -22,6 +25,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LibraryRepositoryTest { class LibraryRepositoryTest {
@@ -80,7 +84,19 @@ class LibraryRepositoryTest {
} }
@Test @Test
fun aRejectedStarLeavesTheRowUntouchedAndReports() = fun starringUpdatesTheRowBeforeTheServerResponds() =
runBlocking {
withSource { server ->
server.enqueue(ok().setBodyDelay(10, TimeUnit.SECONDS))
val call = launch { repository.setStar(StarType.Song, "s1", true) }
withTimeout(3_000) { while (starred() == null) delay(10) }
call.cancel()
}
}
@Test
fun aRejectedStarRollsBackAndReports() =
runBlocking { runBlocking {
withSource { server -> withSource { server ->
server.enqueue(failed(40)) server.enqueue(failed(40))
@@ -92,6 +108,22 @@ class LibraryRepositoryTest {
} }
} }
@Test
fun aRejectedUnstarRestoresTheStarredRow() =
runBlocking {
withSource { server ->
server.enqueue(ok())
repository.setStar(StarType.Song, "s1", true)
val starredAt = starred()
server.enqueue(failed(40))
assertTrue(repository.setStar(StarType.Song, "s1", false).isFailure)
assertEquals(starredAt, starred())
assertEquals(listOf("Could not update star"), messages)
}
}
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) { private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer() val server = MockWebServer()
server.start() server.start()