add item menus to the detail screens and now playing

Album, artist and playlist detail screens get a more button plus long-press on their song
and album rows; now playing gets a more button for the current song. Song menus can reach
the album and artist and star; the now-playing song is read from Room so its star state is
current.
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent fb9e985680
commit d9e9a6b865
12 changed files with 208 additions and 16 deletions
@@ -611,6 +611,12 @@ interface LibraryDao {
artistId: String, artistId: String,
): Flow<Artist?> ): Flow<Artist?>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND id = :songId")
fun song(
sourceId: Long,
songId: String,
): Flow<Song?>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id") @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id")
fun albumsForArtist( fun albumsForArtist(
sourceId: Long, sourceId: Long,
@@ -211,6 +211,11 @@ class LibraryRepository(
artistId: String, artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId) ): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId)
fun song(
sourceId: Long,
songId: String,
): Flow<Song?> = db.libraryDao().song(sourceId, songId)
fun artistAlbums( fun artistAlbums(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
@@ -50,7 +50,7 @@ fun appModule(
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) } viewModel { QueueViewModel(get(), get()) }
} }
@@ -250,6 +250,7 @@ private fun MainNavigation() {
.orEmpty() .orEmpty()
.ifEmpty { null }, .ifEmpty { null },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) },
) )
} }
composable( composable(
@@ -272,6 +273,7 @@ private fun MainNavigation() {
.ifEmpty { null }, .ifEmpty { null },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) },
) )
} }
composable( composable(
@@ -281,6 +283,8 @@ private fun MainNavigation() {
PlaylistDetailRoute( PlaylistDetailRoute(
playlistId = entry.arguments?.getString("playlistId").orEmpty(), playlistId = entry.arguments?.getString("playlistId").orEmpty(),
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) },
) )
} }
composable(Routes.ADD_SERVER) { composable(Routes.ADD_SERVER) {
@@ -343,6 +347,14 @@ private fun MainNavigation() {
NowPlayingRoute( NowPlayingRoute(
onBack = { settleNowPlaying(false) }, onBack = { settleNowPlaying(false) },
onQueue = { showingQueue = true }, onQueue = { showingQueue = true },
onViewAlbum = { albumId ->
settleNowPlaying(false)
navController.navigate(Routes.album(albumId))
},
onViewArtist = { artistId ->
settleNowPlaying(false)
navController.navigate(Routes.artist(artistId))
},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -64,14 +64,14 @@ sealed interface MenuTarget {
} }
class ItemActions( class ItemActions(
val playSong: (SongModel) -> Unit = {}, val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {}, val playAlbum: (AlbumModel) -> Unit = {},
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: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: (String) -> Unit = {}, val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: (String) -> Unit = {}, val viewArtist: ((String) -> Unit)? = null,
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -89,15 +89,21 @@ fun ItemContextMenu(
MenuHeader(target) MenuHeader(target)
when (target) { when (target) {
is MenuTarget.Song -> { is MenuTarget.Song -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playSong(target.song) } } actions.playSong?.let { play ->
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { play(target.song) } }
}
MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) { MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) {
dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) } dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) }
} }
target.song.albumId?.let { albumId -> target.song.albumId?.let { albumId ->
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { actions.viewAlbum(albumId) } } actions.viewAlbum?.let { view ->
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { view(albumId) } }
}
} }
target.song.artistId?.let { artistId -> target.song.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } } actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { view(artistId) } }
}
} }
} }
@@ -108,7 +114,9 @@ fun ItemContextMenu(
dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) } dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) }
} }
target.album.artistId?.let { artistId -> target.album.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } } actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { view(artistId) } }
}
} }
} }
@@ -1,6 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -13,7 +13,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -28,6 +30,9 @@ import com.subtracks.data.model.Song
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -42,6 +47,7 @@ fun AlbumDetailRoute(
albumId: String, albumId: String,
coverArtId: String?, coverArtId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onViewArtist: (String) -> Unit,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
@@ -51,6 +57,15 @@ fun AlbumDetailRoute(
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions =
ItemActions(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() },
setStar = viewModel::setStar,
viewArtist = onViewArtist,
)
AlbumDetailScreen( AlbumDetailScreen(
album = album, album = album,
songs = songs, songs = songs,
@@ -59,10 +74,15 @@ fun AlbumDetailRoute(
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { menuTarget = it },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { album?.let { menuTarget = MenuTarget.Album(it) } },
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 ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@Composable @Composable
@@ -74,6 +94,7 @@ fun AlbumDetailScreen(
artwork: ArtworkColors?, artwork: ArtworkColors?,
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit, onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) }, onPlay: () -> Unit = { onSongClick(0) },
onDownload: () -> Unit = {}, onDownload: () -> Unit = {},
@@ -137,7 +158,11 @@ fun AlbumDetailScreen(
song = song, song = song,
isPlaying = song.id == playingSongId, isPlaying = song.id == playingSongId,
trackNumber = song.track, trackNumber = song.track,
modifier = rowModifier.clickable { onSongClick(index) }, modifier =
rowModifier.combinedClickable(
onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(song)) },
),
) )
} }
} }
@@ -8,6 +8,7 @@ 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
@@ -53,4 +54,12 @@ 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) }
}
} }
@@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -27,6 +28,7 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -39,7 +41,9 @@ 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
@@ -58,6 +62,9 @@ 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.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
@@ -83,6 +90,7 @@ fun ArtistDetailRoute(
coverArtId: String?, coverArtId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> 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)
@@ -90,6 +98,14 @@ fun ArtistDetailRoute(
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions =
ItemActions(
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
)
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
albums = albums, albums = albums,
@@ -99,7 +115,12 @@ fun ArtistDetailRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onAlbumLongClick = { menuTarget = it },
onMore = { artist?.let { menuTarget = MenuTarget.Artist(it) } },
) )
menuTarget?.let { target ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -113,6 +134,8 @@ fun ArtistDetailScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onAlbumLongClick: (MenuTarget) -> Unit = {},
onMore: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyGridState() val listState = rememberLazyGridState()
@@ -213,7 +236,10 @@ fun ArtistDetailScreen(
.padding( .padding(
start = if (index % 2 == 0) 16.dp else 0.dp, start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp, end = if (index % 2 == 1) 16.dp else 0.dp,
).clickable { onAlbumClick(album) }, ).combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album)) },
),
) { ) {
CoverArt( CoverArt(
ref = coverArt(album.coverArt, false), ref = coverArt(album.coverArt, false),
@@ -267,6 +293,11 @@ fun ArtistDetailScreen(
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
} }
}, },
actions = {
IconButton(onClick = onMore, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = modifier =
Modifier Modifier
@@ -7,6 +7,8 @@ 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -14,13 +16,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ArtistDetailViewModel( class ArtistDetailViewModel(
libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val playbackController: PlaybackController,
private val artistId: String, private val artistId: String,
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -63,4 +67,24 @@ class ArtistDetailViewModel(
coverArt: String?, coverArt: String?,
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) {
viewModelScope.launch {
playbackController.playAlbum(sourceId.first(), albumId, 0)
}
}
fun shuffleAlbum(albumId: String) {
viewModelScope.launch {
playbackController.shuffleAlbum(sourceId.first(), albumId)
}
}
} }
@@ -1,8 +1,11 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
@@ -15,6 +18,9 @@ import com.subtracks.data.model.SongListItem
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -28,12 +34,23 @@ private const val THEME_TRANSITION_MS = 100
fun PlaylistDetailRoute( fun PlaylistDetailRoute(
playlistId: String, playlistId: String,
onBack: () -> Unit, onBack: () -> Unit,
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val actions =
ItemActions(
playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
PlaylistDetailScreen( PlaylistDetailScreen(
playlist = playlist, playlist = playlist,
songs = viewModel.songs.collectAsLazyPagingItems(), songs = viewModel.songs.collectAsLazyPagingItems(),
@@ -41,10 +58,15 @@ fun PlaylistDetailRoute(
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { menuTarget = it },
onShuffle = viewModel::shuffle, onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll, onPlay = viewModel::playAll,
onMore = { playlist?.let { menuTarget = MenuTarget.Playlist(it) } },
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 ->
ItemContextMenu(target = target, actions = actions, onDismiss = { menuTarget = null })
}
} }
@Composable @Composable
@@ -55,8 +77,10 @@ fun PlaylistDetailScreen(
artwork: ArtworkColors?, artwork: ArtworkColors?,
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit, onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) }, onPlay: () -> Unit = { onSongClick(0) },
onMore: () -> Unit = {},
playingSongId: String? = null, playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -75,7 +99,7 @@ fun PlaylistDetailScreen(
onPlay = onPlay, onPlay = onPlay,
onShuffle = onShuffle, onShuffle = onShuffle,
onDownload = {}, onDownload = {},
onMore = {}, onMore = onMore,
topInset = topInset, topInset = topInset,
controlsModifier = controlsModifier, controlsModifier = controlsModifier,
) )
@@ -89,7 +113,11 @@ fun PlaylistDetailScreen(
coverArtId = item.coverArt, coverArtId = item.coverArt,
coverArt = coverArt, coverArt = coverArt,
isPlaying = item.song.id == playingSongId, isPlaying = item.song.id == playingSongId,
modifier = rowModifier.clickable { onSongClick(index) }, modifier =
rowModifier.combinedClickable(
onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(item.song)) },
),
) )
} }
} }
@@ -9,6 +9,7 @@ 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
@@ -55,4 +56,12 @@ 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) }
}
} }
@@ -43,7 +43,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
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
@@ -62,26 +64,45 @@ import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.subtracks.data.model.CoverArtRef 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.repo.LibraryRepository
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
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache 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
fun NowPlayingRoute( fun NowPlayingRoute(
onBack: () -> Unit, onBack: () -> Unit,
onQueue: () -> Unit, onQueue: () -> Unit,
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
) { ) {
val state by controller.state.collectAsStateWithLifecycle() val state by controller.state.collectAsStateWithLifecycle()
val positionMs by controller.positionMs.collectAsStateWithLifecycle() val positionMs by controller.positionMs.collectAsStateWithLifecycle()
val sourceId = state.context?.sourceId
val songId = state.item?.id
val song by
produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) {
libraryRepository.song(sourceId, songId).collect { value = it }
}
}
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val scope = rememberCoroutineScope()
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val art = controller.coverArt(state.item) val art = controller.coverArt(state.item)
val thumbnail = controller.coverArt(state.item, thumbnail = true) val thumbnail = controller.coverArt(state.item, thumbnail = true)
@@ -117,9 +138,22 @@ 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) } },
onSeek = controller::seekTo, onSeek = controller::seekTo,
modifier = modifier, modifier = modifier,
) )
menuTarget?.let { target ->
ItemContextMenu(
target = target,
actions =
ItemActions(
setStar = { type, id, starred -> scope.launch { libraryRepository.setStar(type, id, starred) } },
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
),
onDismiss = { menuTarget = null },
)
}
} }
internal fun prefetchImage( internal fun prefetchImage(
@@ -159,6 +193,7 @@ fun NowPlayingScreen(
onPrevious: () -> Unit, onPrevious: () -> Unit,
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {}, onRepeat: () -> Unit = {},
onMore: () -> Unit = {},
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null, thumbnailRef: CoverArtRef? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -395,7 +430,7 @@ fun NowPlayingScreen(
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
) )
} }
IconButton(onClick = {}, modifier = Modifier.size(40.dp)) { IconButton(onClick = onMore, modifier = Modifier.size(40.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.MoreHoriz, imageVector = Icons.Rounded.MoreHoriz,
contentDescription = "More", contentDescription = "More",