add star/unstar and long-press item menus to the library lists

Star is a new write path: MusicSource.setStar posts star.view/unstar.view and
LibraryRepository updates the local row on success (toast on failure). Long-press an
album, artist, song or playlist in the library to open a bottom sheet with play/shuffle,
star/unstar and view-album/view-artist.
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent ed7a2e7390
commit fb9e985680
15 changed files with 490 additions and 20 deletions
@@ -62,6 +62,27 @@ interface LibraryDao {
@Upsert
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
@Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setArtistStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("UPDATE albums SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setAlbumStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("UPDATE songs SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setSongStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
suspend fun artistIds(sourceId: Long): List<String>
@@ -16,11 +16,13 @@ import com.subtracks.data.prefs.ArtistSort
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.flow.Flow
class LibraryRepository(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val showMessage: (String) -> Unit = {},
) {
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
@@ -234,6 +236,33 @@ class LibraryRepository(
playlistId: String,
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
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"))
return runCatching { source.setStar(type, id, starred) }
.onSuccess { updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) }
.onFailure { showMessage("Could not update star") }
}
private suspend fun updateStarred(
sourceId: Long,
type: StarType,
id: String,
starred: Long?,
) {
val dao = db.libraryDao()
when (type) {
StarType.Song -> dao.setSongStar(sourceId, id, starred)
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
}
}
private fun <T : Any> pager(
pageSize: Int,
source: () -> PagingSource<Int, T>,
@@ -12,6 +12,12 @@ interface MusicSource {
suspend fun ping()
suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
)
fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>>
@@ -22,3 +28,9 @@ interface MusicSource {
fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>>
}
enum class StarType {
Song,
Album,
Artist,
}
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -40,6 +41,22 @@ class SubsonicSource(
withContext(Dispatchers.IO) { client.check("ping") }
}
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
val param =
when (type) {
StarType.Song -> "id"
StarType.Album -> "albumId"
StarType.Artist -> "artistId"
}
withContext(Dispatchers.IO) {
client.check(if (starred) "star" else "unstar", mapOf(param to id))
}
}
override fun artists(): Flow<List<Artist>> =
entityBatches(
method = "getArtists",
@@ -32,20 +32,14 @@ fun appModule(
http: OkHttpClient,
) = module {
val mainHandler = Handler(Looper.getMainLooper())
val toast: (String) -> Unit = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
}
single { createAndroidDatabase(context) }
single { http }
single { createUserPreferences(context) }
single {
SourceRepository(
get(),
get(),
get(),
showMessage = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
},
)
}
single { LibraryRepository(get(), get()) }
single { SourceRepository(get(), get(), get(), showMessage = toast) }
single { LibraryRepository(get(), get(), toast) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) }
single { QueueRepository(get()) }
@@ -220,6 +220,8 @@ private fun MainNavigation() {
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) },
bottomInset = bottomInset,
)
}
@@ -0,0 +1,170 @@
package com.subtracks.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import com.subtracks.data.source.StarType
import com.subtracks.data.model.Album as AlbumModel
import com.subtracks.data.model.Artist as ArtistModel
import com.subtracks.data.model.Playlist as PlaylistModel
import com.subtracks.data.model.Song as SongModel
sealed interface MenuTarget {
val title: String
val subtitle: String?
data class Song(
val song: SongModel,
) : MenuTarget {
override val title: String get() = song.title
override val subtitle: String? get() = song.artist
}
data class Album(
val album: AlbumModel,
) : MenuTarget {
override val title: String get() = album.name
override val subtitle: String? get() = album.albumArtist
}
data class Artist(
val artist: ArtistModel,
) : MenuTarget {
override val title: String get() = artist.name
override val subtitle: String? get() = null
}
data class Playlist(
val playlist: PlaylistModel,
) : MenuTarget {
override val title: String get() = playlist.name
override val subtitle: String? get() = null
}
}
class ItemActions(
val playSong: (SongModel) -> Unit = {},
val playAlbum: (AlbumModel) -> Unit = {},
val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (PlaylistModel) -> Unit = {},
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: (String) -> Unit = {},
val viewArtist: (String) -> Unit = {},
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemContextMenu(
target: MenuTarget,
actions: ItemActions,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
val dismissThen: (() -> Unit) -> Unit = { action ->
onDismiss()
action()
}
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playSong(target.song) } }
MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) {
dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) }
}
target.song.albumId?.let { albumId ->
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { actions.viewAlbum(albumId) } }
}
target.song.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
}
}
is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shuffleAlbum(target.album) } }
MenuItem(starIcon(target.album.starred), starLabel(target.album.starred)) {
dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) }
}
target.album.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
}
}
is MenuTarget.Artist -> {
MenuItem(starIcon(target.artist.starred), starLabel(target.artist.starred)) {
dismissThen { actions.setStar(StarType.Artist, target.artist.id, target.artist.starred == null) }
}
}
is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shufflePlaylist(target.playlist) } }
}
}
}
}
@Composable
private fun MenuHeader(target: MenuTarget) {
ListItem(
headlineContent = {
Text(
text = target.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent =
target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
{
Text(
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@Composable
private fun MenuItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(label) },
leadingContent = { Icon(icon, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick),
)
}
private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star
private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar"
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -40,6 +41,7 @@ fun AlbumsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onAlbumClick: (Album) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -90,8 +92,10 @@ fun AlbumsContent(
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.clickable { onAlbumClick(album) }
.testTag(ALBUM_COVER_TAG),
.combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album)) },
).testTag(ALBUM_COVER_TAG),
)
}
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -38,6 +39,7 @@ fun ArtistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -93,7 +95,11 @@ fun ArtistsContent(
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
modifier = Modifier.clickable { onArtistClick(artist) },
modifier =
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist)) },
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -114,6 +114,9 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor
@@ -189,12 +192,15 @@ fun LibraryRoute(
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onOpenSettings: () -> Unit,
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab()
@@ -231,6 +237,7 @@ fun LibraryRoute(
onSongClick = viewModel::playSong,
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
onItemLongClick = { menuTarget = it },
playingSongId = playingSongId,
listQuery = listQuery,
resetKeys = resetKeys,
@@ -254,6 +261,23 @@ fun LibraryRoute(
search = search,
onSearchChange = { viewModel.setSearch(listTab, it) },
)
menuTarget?.let { target ->
ItemContextMenu(
target = target,
actions =
ItemActions(
playSong = { viewModel.playSong(it.id) },
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
),
onDismiss = { menuTarget = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -272,6 +296,7 @@ fun LibraryScreen(
onSongClick: (String) -> Unit,
onSync: () -> Unit,
onOpenSettings: () -> Unit,
onItemLongClick: (MenuTarget) -> Unit = {},
syncing: Boolean = false,
playingSongId: String? = null,
bottomInset: Dp = 0.dp,
@@ -364,6 +389,7 @@ fun LibraryScreen(
dismissSearch()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -381,6 +407,7 @@ fun LibraryScreen(
dismissSearch()
onArtistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -398,6 +425,7 @@ fun LibraryScreen(
dismissSearch()
onSongClick(it)
},
onLongClick = onItemLongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
@@ -416,6 +444,7 @@ fun LibraryScreen(
dismissSearch()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -20,6 +20,7 @@ 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
@@ -138,6 +139,38 @@ class LibraryViewModel(
}
}
fun playAlbum(albumId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) }
}
}
fun shuffleAlbum(albumId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.shuffleAlbum(it, albumId) }
}
}
fun playPlaylist(playlistId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.playPlaylist(it, playlistId, 0) }
}
}
fun shufflePlaylist(playlistId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.shufflePlaylist(it, playlistId) }
}
}
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
fun sync() = syncManager.requestSync()
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@@ -37,6 +38,7 @@ fun PlaylistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onPlaylistClick: (Playlist) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -91,7 +93,11 @@ fun PlaylistsContent(
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
)
},
modifier = Modifier.clickable { onPlaylistClick(playlist) },
modifier =
Modifier.combinedClickable(
onClick = { onPlaylistClick(playlist) },
onLongClick = { onLongClick(MenuTarget.Playlist(playlist)) },
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -39,6 +39,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@@ -48,6 +49,7 @@ fun SongsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onSongClick: (String) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
playingSongId: String? = null,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
@@ -86,7 +88,11 @@ fun SongsContent(
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(item.song.id) },
modifier =
Modifier.combinedClickable(
onClick = { onSongClick(item.song.id) },
onLongClick = { onLongClick(MenuTarget.Song(item.song)) },
),
)
}
}